chore: retire zen-backed summarization

Disable the active Zen summarization flow because the unauthenticated/free Zen provider is no longer available and now returns usage-limit errors for this feature.

Keep /api/text/summarize as an API-compatible stub that returns local sanitized or distilled fallback text with summarized=false, rather than attempting external model calls.

Remove notification and voice playback summary behavior from runtime paths. Notification {last_message} now always uses normalized truncated text, and TTS playback ignores historical summarize request fields.

Hide the notification summary settings and voice summarize-before-playback controls while preserving legacy persisted settings for compatibility. Also disable Zen model startup validation and make Zen model list routes return empty results.

Update module documentation and tests to describe the retired provider behavior and the remaining compatibility stubs.
This commit is contained in:
Bohdan Triapitsyn
2026-05-19 02:06:52 +03:00
parent a57b02a308
commit 174fa4e96d
27 changed files with 137 additions and 1136 deletions
@@ -1,15 +1,15 @@
# Text Module Documentation
## Purpose
This module provides shared text transformation helpers that are not owned by a single product surface. Today it contains the shared summarization pipeline used by TTS, notifications, and note distillation flows.
This module provides shared text transformation helpers that are not owned by a single product surface. It previously proxied model-backed summarization through the opencode.ai Zen provider; that provider is no longer available for this use, so summarization now returns local sanitized/distilled fallback text only.
## Entrypoints and structure
- `packages/web/server/lib/text/summarization.js`: Shared summarize + sanitize helpers backed by opencode.ai zen API.
- `packages/web/server/lib/text/summarization.js`: Shared summarize stub + sanitize helpers. It performs no external model calls.
## Public exports
### Summarization (summarization.js)
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared summarization entrypoint.
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired summarization entrypoint retained as an API-compatible stub. `zenModel` is ignored.
- `sanitizeForTTS(text)`: Sanitizes text for speech output.
- `sanitizeForNotification(text)`: Sanitizes text for compact notification output.
- `sanitizeForNote(text)`: Sanitizes text for short note/distillation output.
@@ -23,9 +23,9 @@ This module provides shared text transformation helpers that are not owned by a
### `summarizeText`
Returns object with:
- `summary`: Final transformed text.
- `summarized`: Boolean indicating whether model summarization succeeded.
- `reason`: Optional failure/skip reason.
- `summary`: Local sanitized/distilled fallback text.
- `summarized`: Always `false` while the model provider is unavailable.
- `reason`: Skip reason, usually `Model summarization provider unavailable` for text above threshold.
- `originalLength`: Optional original text length.
- `summaryLength`: Optional final summary length.
+28 -184
View File
@@ -7,53 +7,6 @@
* - note: distilled project note
*/
function buildSummarizationPrompt(maxLength, mode = 'tts') {
if (mode === 'note') {
return `You are distilling selected assistant text into a single short project note.
Goal:
- Produce one concise note the user may want to keep in project notes.
Rules:
1. Output ONLY the final note text.
2. Keep the result under ${maxLength} characters.
3. Prefer one sentence or a short sentence fragment.
4. Keep the most useful insight, decision, constraint, or recommendation.
5. Be concrete and specific.
6. Do not use markdown, bullets, code fences, headings, or quotes.
7. Do not mention the assistant, the text, or that this is a summary.
8. Do not include filler like In summary or Heres a note.
9. If the text contains multiple ideas, keep only the most important one.
10. Rewrite and compress the input into a distilled note. Do not copy the source text verbatim unless it is already an extremely short note.
11. Prefer a shorter phrasing than the input whenever possible.
12. Write the result as a plain sentence or sentence fragment, not as a bullet point.`;
}
if (mode === 'notification') {
return `Summarize the following text in approximately ${maxLength} characters. Be concise and capture the key point.
Rules:
1. Output plain text only.
2. Do not use markdown, bullets, headings, code fences, backticks, or quotes.
3. Output only the summary text.
4. Prefer a short notification-friendly sentence.`;
}
return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters.
CRITICAL INSTRUCTIONS:
1. Output ONLY the final summary - no thinking, no reasoning, no explanations
2. Do not show your work or thought process
3. Do not use any special characters, markdown, code, URLs, file paths, or formatting
4. Do not include phrases like "Here's a summary" or "In summary"
5. Just provide clean, speakable text that can be read aloud
6. Stay within the ${maxLength} character limit
Your response should be ready to speak immediately.`;
}
const SUMMARIZE_TIMEOUT_MS = 30_000;
export function sanitizeForTTS(text) {
if (!text || typeof text !== 'string') return '';
@@ -115,65 +68,6 @@ function sanitizeByMode(text, mode) {
return sanitizeForTTS(text);
}
function clampToMaxLength(text, maxLength) {
if (!text) return '';
const limit = Number.isFinite(maxLength) ? Math.max(0, Math.floor(maxLength)) : Infinity;
if (text.length <= limit) return text;
return text.slice(0, limit).trim();
}
function extractZenOutputText(data) {
if (!data || typeof data !== 'object') return null;
const output = data.output;
if (!Array.isArray(output)) return null;
const messageItem = output.find((item) => item && typeof item === 'object' && item.type === 'message');
if (!messageItem) return null;
const content = messageItem.content;
if (!Array.isArray(content)) return null;
const textItem = content.find((item) => item && typeof item === 'object' && item.type === 'output_text');
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
return text || null;
}
function extractZenChatCompletionText(data) {
if (!data || typeof data !== 'object') return null;
const choices = data.choices;
if (!Array.isArray(choices)) return null;
const choice = choices.find((item) => item && typeof item === 'object');
const content = choice?.message?.content;
if (typeof content === 'string') {
const text = content.trim();
return text || null;
}
if (!Array.isArray(content)) return null;
const text = content
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
return '';
})
.join('')
.trim();
return text || null;
}
function getZenCompletionEndpoint(model) {
if (typeof model !== 'string') return 'responses';
if (
model.startsWith('gpt-')
|| model.startsWith('claude-')
|| model.startsWith('gemini-')
) {
return 'responses';
}
return 'chat/completions';
}
function distillNoteFallback(text, maxLength) {
const sanitized = sanitizeForNote(text);
if (!sanitized) return '';
@@ -200,95 +94,45 @@ function distillNoteFallback(text, maxLength) {
return clipped ? `${clipped}` : best.slice(0, idealLimit).trim();
}
function distillNotificationFallback(text, maxLength) {
const sanitized = sanitizeForNotification(text);
if (!sanitized) return '';
const sentences = sanitized
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
const candidate = sentences.find((sentence) => sentence.length >= 20) || sentences[0] || sanitized;
const limit = Number.isFinite(maxLength) ? Math.max(20, Math.floor(maxLength)) : 100;
if (candidate.length <= limit) return candidate;
const clipped = candidate.slice(0, Math.max(0, limit - 1)).trim();
return clipped ? `${clipped}` : candidate.slice(0, limit).trim();
}
function fallbackByMode(text, maxLength, mode) {
if (mode === 'note') return distillNoteFallback(text, maxLength);
if (mode === 'notification') return distillNotificationFallback(text, maxLength);
return sanitizeByMode(text, mode);
}
export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) {
void zenModel;
const summary = fallbackByMode(text || '', maxLength, mode);
if (!text || text.length <= threshold) {
return {
summary: fallbackByMode(text || '', maxLength, mode),
summary,
summarized: false,
reason: text ? 'Text under threshold' : 'No text provided',
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS);
try {
const prompt = buildSummarizationPrompt(maxLength, mode);
const model = zenModel || 'gpt-5-nano';
const endpoint = getZenCompletionEndpoint(model);
const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(endpoint === 'responses'
? {
model,
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
stream: false,
reasoning: { effort: 'low' },
}
: {
model,
messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('[Summarize] zen API error:', response.status, errorBody);
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: `zen API returned ${response.status}`,
};
}
const data = await response.json();
const summary = endpoint === 'responses'
? extractZenOutputText(data)
: extractZenChatCompletionText(data);
if (summary) {
const sanitized = sanitizeByMode(summary, mode);
const finalSummary = mode === 'note'
? (sanitized && sanitized !== sanitizeForNote(text) ? sanitized : distillNoteFallback(text, maxLength))
: sanitized;
const clippedSummary = clampToMaxLength(finalSummary, maxLength);
return {
summary: clippedSummary,
summarized: true,
originalLength: text.length,
summaryLength: clippedSummary.length,
};
}
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: 'No response from model',
};
} catch (error) {
if (error.name === 'AbortError') {
console.error('[Summarize] Request timed out');
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: 'Request timed out',
};
}
console.error('[Summarize] Error:', error);
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: error.message,
};
} finally {
clearTimeout(timer);
}
return {
summary,
summarized: false,
reason: 'Model summarization provider unavailable',
originalLength: text.length,
summaryLength: summary.length,
};
}
@@ -1,118 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { summarizeText } from './summarization.js';
const originalFetch = globalThis.fetch;
describe('text summarization stubs', () => {
it('does not call the retired zen provider', async () => {
const result = await summarizeText({
text: 'The implementation now correctly loads notification templates before dispatching the notification. It also fetches the latest assistant message when the event payload does not include message parts. This should make completion notifications match user settings.',
threshold: 0,
maxLength: 80,
zenModel: 'gpt-5-nano',
mode: 'notification',
});
function stubFetch(fetchMock) {
globalThis.fetch = fetchMock;
}
describe('text summarization zen requests', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
expect(result.summarized).toBe(false);
expect(result.reason).toBe('Model summarization provider unavailable');
expect(result.summary).toBe('The implementation now correctly loads notification templates before dispatchin…');
});
it('uses responses endpoint for gpt models', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
output: [{
type: 'message',
content: [{ type: 'output_text', text: 'Short summary' }],
}],
}),
}));
stubFetch(fetchMock);
it('returns local note fallback while provider is unavailable', async () => {
const result = await summarizeText({
text: 'Long text '.repeat(30),
text: 'First sentence. Second sentence with the useful insight.',
threshold: 0,
maxLength: 100,
zenModel: 'gpt-5-nano',
mode: 'notification',
mode: 'note',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://opencode.ai/zen/v1/responses',
expect.objectContaining({
body: expect.stringContaining('"input"'),
}),
);
expect(result.summary).toBe('Short summary');
});
it('uses chat completions endpoint for openai-compatible zen models', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
choices: [{ message: { content: 'Chat summary' } }],
}),
}));
stubFetch(fetchMock);
const result = await summarizeText({
text: 'Long text '.repeat(30),
threshold: 0,
maxLength: 100,
zenModel: 'big-pickle',
mode: 'notification',
expect(result).toMatchObject({
summary: 'First sentence.',
summarized: false,
reason: 'Model summarization provider unavailable',
});
expect(fetchMock).toHaveBeenCalledWith(
'https://opencode.ai/zen/v1/chat/completions',
expect.objectContaining({
body: expect.stringContaining('"messages"'),
}),
);
expect(result.summary).toBe('Chat summary');
});
it('clamps successful model summaries to the requested max length', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
output: [{
type: 'message',
content: [{ type: 'output_text', text: 'This response is too long' }],
}],
}),
}));
stubFetch(fetchMock);
const result = await summarizeText({
text: 'Long text '.repeat(30),
threshold: 0,
maxLength: 12,
zenModel: 'gpt-5-nano',
mode: 'notification',
});
expect(result.summary).toBe('This respons');
expect(result.summaryLength).toBe(12);
});
it('does not clamp successful model summaries for non-finite max lengths', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
output: [{
type: 'message',
content: [{ type: 'output_text', text: 'Full response' }],
}],
}),
}));
stubFetch(fetchMock);
const result = await summarizeText({
text: 'Long text '.repeat(30),
threshold: 0,
maxLength: Infinity,
zenModel: 'gpt-5-nano',
mode: 'notification',
});
expect(result.summary).toBe('Full response');
expect(result.summaryLength).toBe(13);
});
});