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,7 +1,7 @@
# Notifications Module Documentation
## Purpose
This module provides notification message preparation utilities for the web server runtime, including text truncation, plain-text normalization, and optional message summarization for system notifications.
This module provides notification message preparation utilities for the web server runtime, including text truncation and plain-text normalization for system notifications.
## Entrypoints and structure
- `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`.
@@ -9,7 +9,7 @@ This module provides notification message preparation utilities for the web serv
- `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime.
- `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
- `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime.
- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
- `packages/web/server/lib/notifications/message.js`: helper implementation module.
- `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers.
@@ -17,7 +17,7 @@ This module provides notification message preparation utilities for the web serv
### Notifications API (re-exported from message.js)
- `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated.
- `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support.
- `prepareNotificationLastMessage({ message, settings })`: Prepares the last message for notification display by normalizing and truncating text.
### Route registration API (routes.js)
- `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints:
@@ -67,14 +67,14 @@ This module provides notification message preparation utilities for the web serv
- `broadcastUiNotification(payload)`
### Template runtime API (template-runtime.js)
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime and consumes shared text summarization from `packages/web/server/lib/text/summarization.js` in `notification` mode.
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime. Model-backed summarization was retired after the Zen provider became unavailable.
- Returned API:
- `resolveNotificationTemplate(template, variables)`
- `shouldApplyResolvedTemplateMessage(template, resolved, variables)`
- `fetchFreeZenModels()`
- `resolveZenModel(override)`
- `validateZenModelAtStartup()`
- `summarizeText(text, targetLength, zenModel)`
- `fetchFreeZenModels()` compatibility stub returning `[]`
- `resolveZenModel(override)` compatibility stub preserving stored values without validation
- `validateZenModelAtStartup()` compatibility no-op
- `summarizeText(text, targetLength, zenModel)` compatibility stub returning local fallback text
- `extractLastMessageText(payload, maxLength?)`
- `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)`
- `maybeCacheSessionInfoFromEvent(payload)`
@@ -85,16 +85,10 @@ This module provides notification message preparation utilities for the web serv
### Default values
- `DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH`: 250 (default max length for notification text).
- `DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD`: 200 (minimum message length to trigger summarization).
- `DEFAULT_NOTIFICATION_SUMMARY_LENGTH`: 100 (target length for summarized messages).
## Settings object format
The `settings` parameter for `prepareNotificationLastMessage` supports:
- `summarizeLastMessage` (boolean): Whether to enable summarization for long messages.
- `summaryThreshold` (number): Minimum message length to trigger summarization (default: 200).
- `summaryLength` (number): Target length for summarized messages (default: 100).
- `maxLastMessageLength` (number): Maximum length for the final notification text (default: 250).
The `settings` parameter for `prepareNotificationLastMessage` supports `maxLastMessageLength` (number), the maximum length for the final notification text (default: 250). Legacy summarization settings may still exist in persisted settings but are ignored.
## Response contracts
@@ -105,8 +99,7 @@ The `settings` parameter for `prepareNotificationLastMessage` supports:
### `prepareNotificationLastMessage`
- Returns empty string for empty/null message.
- Returns truncated original message if summarization disabled, message under threshold, or summarization fails.
- Returns truncated summary if summarization succeeds and returns non-empty string.
- Returns truncated original message. Model-backed notification summarization is retired.
- Normalizes markdown-like formatting to plain text before truncation.
- Always applies `maxLastMessageLength` truncation to final result.
@@ -120,10 +113,10 @@ The `settings` parameter for `prepareNotificationLastMessage` supports:
5. Add corresponding unit tests in `packages/web/server/lib/notifications/message.test.js`.
### Error handling
- `prepareNotificationLastMessage` catches summarization errors and falls back to original message.
- `prepareNotificationLastMessage` does not call model summarization.
- Invalid numeric parameters default to safe fallback values.
- Non-string inputs are handled gracefully (return empty string).
### Testing
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
- Unit tests should cover truncation behavior, summarization success/failure, and edge cases (empty strings, invalid inputs).
- Unit tests should cover truncation behavior and edge cases (empty strings, invalid inputs).
@@ -1,6 +1,4 @@
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
const DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD = 200;
const DEFAULT_NOTIFICATION_SUMMARY_LENGTH = 100;
const resolvePositiveNumber = (value, fallback) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
@@ -42,29 +40,13 @@ export const truncateNotificationText = (text, maxLength = DEFAULT_NOTIFICATION_
return `${text.slice(0, safeMaxLength)}...`;
};
export const prepareNotificationLastMessage = async ({ message, settings, summarize }) => {
export const prepareNotificationLastMessage = async ({ message, settings }) => {
const originalMessage = typeof message === 'string' ? message : '';
if (!originalMessage) {
return '';
}
const shouldSummarize = settings?.summarizeLastMessage === true && typeof summarize === 'function';
const summaryThreshold = resolvePositiveNumber(settings?.summaryThreshold, DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD);
const summaryLength = resolvePositiveNumber(settings?.summaryLength, DEFAULT_NOTIFICATION_SUMMARY_LENGTH);
const maxLastMessageLength = resolvePositiveNumber(settings?.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH);
let messageForNotification = originalMessage;
if (shouldSummarize && originalMessage.length > summaryThreshold) {
try {
const summary = await summarize(originalMessage, summaryLength);
if (typeof summary === 'string' && summary.trim().length > 0) {
messageForNotification = summary;
}
} catch {
messageForNotification = originalMessage;
}
}
const plainTextMessage = normalizeNotificationPlainText(messageForNotification) || normalizeNotificationPlainText(originalMessage);
const plainTextMessage = normalizeNotificationPlainText(originalMessage);
return truncateNotificationText(plainTextMessage, maxLastMessageLength);
};
@@ -7,15 +7,9 @@ describe('notification message helpers', () => {
expect(truncateNotificationText('abcdef', 3)).toBe('abc...');
});
it('falls back to original message when summarization fails', async () => {
const message = '0123456789';
const summarize = async () => {
throw new Error('summarization failed');
};
it('ignores retired summarization settings and truncates original message', async () => {
const result = await prepareNotificationLastMessage({
message,
summarize,
message: '0123456789',
settings: {
summarizeLastMessage: true,
summaryThreshold: 5,
@@ -27,44 +21,10 @@ describe('notification message helpers', () => {
expect(result).toBe('0123...');
});
it('falls back to original message when summary is empty', async () => {
it('normalizes markdown message to plain text', async () => {
const result = await prepareNotificationLastMessage({
message: '0123456789',
summarize: async () => ' ',
message: "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`",
settings: {
summarizeLastMessage: true,
summaryThreshold: 5,
summaryLength: 3,
maxLastMessageLength: 4,
},
});
expect(result).toBe('0123...');
});
it('uses summary when summarization succeeds', async () => {
const result = await prepareNotificationLastMessage({
message: '0123456789',
summarize: async () => 'short summary',
settings: {
summarizeLastMessage: true,
summaryThreshold: 5,
summaryLength: 3,
maxLastMessageLength: 100,
},
});
expect(result).toBe('short summary');
});
it('normalizes markdown summary to plain text', async () => {
const result = await prepareNotificationLastMessage({
message: '0123456789',
summarize: async () => "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`",
settings: {
summarizeLastMessage: true,
summaryThreshold: 5,
summaryLength: 80,
maxLastMessageLength: 200,
},
});
@@ -2,8 +2,6 @@ export const createNotificationTriggerRuntime = (deps) => {
const {
readSettingsFromDisk,
prepareNotificationLastMessage,
summarizeText,
resolveZenModel,
buildTemplateVariables,
extractLastMessageText,
fetchLastAssistantMessageText,
@@ -248,11 +246,9 @@ export const createNotificationTriggerRuntime = (deps) => {
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
}
const notifZenModel = await resolveZenModel(settings?.zenModel);
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
summarize: (text, len) => summarizeText(text, len, notifZenModel),
});
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
@@ -310,11 +306,9 @@ export const createNotificationTriggerRuntime = (deps) => {
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
}
const errZenModel = await resolveZenModel(settings?.zenModel);
variables.last_message = await prepareNotificationLastMessage({
message: lastMessage,
settings,
summarize: (text, len) => summarizeText(text, len, errZenModel),
});
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
@@ -3,20 +3,15 @@ import { summarizeText as summarizeSharedText } from '../text/summarization.js';
export const createNotificationTemplateRuntime = (deps) => {
const {
readSettingsFromDisk,
persistSettings,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
resolveGitBinaryForSpawn,
} = deps;
const NOTIFICATION_BODY_MAX_CHARS = 1000;
const ZEN_DEFAULT_MODEL = 'gpt-5-nano';
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000;
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
let validatedZenFallback = null;
let cachedZenModels = null;
let cachedZenModelsTimestamp = 0;
const cachedZenModels = { models: [] };
const sessionTitleCache = new Map();
const sessionInfoCache = new Map();
@@ -62,116 +57,18 @@ export const createNotificationTemplateRuntime = (deps) => {
return true;
};
const fetchFreeZenModels = async () => {
const now = Date.now();
if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) {
return cachedZenModels.models;
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const [zenResponse, metadataResponse] = await Promise.all([
fetch('https://opencode.ai/zen/v1/models', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
}),
fetch('https://models.dev/api.json', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
}),
]);
if (!zenResponse.ok) {
throw new Error(`zen/v1/models responded with status ${zenResponse.status}`);
}
if (!metadataResponse.ok) {
throw new Error(`models.dev responded with status ${metadataResponse.status}`);
}
const data = await zenResponse.json();
const metadata = await metadataResponse.json();
const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object'
? metadata.opencode.models
: {};
const allModels = Array.isArray(data?.data) ? data.data : [];
const freeModels = allModels
.filter((model) => {
const id = typeof model?.id === 'string' ? model.id.trim() : '';
const cost = id ? metadataModels[id]?.cost : null;
return id && cost?.input === 0 && cost?.output === 0;
})
.map((model) => ({ id: model.id.trim(), owned_by: model.owned_by }));
cachedZenModels = { models: freeModels };
cachedZenModelsTimestamp = Date.now();
return freeModels;
} finally {
if (timeout) clearTimeout(timeout);
}
};
const fetchFreeZenModels = async () => [];
const resolveZenModel = async (override) => {
const overrideModel = typeof override === 'string' ? override.trim() : '';
let settingsModel = '';
try {
const settings = await readSettingsFromDisk();
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
settingsModel = settings.zenModel.trim();
}
} catch {
}
const candidate = overrideModel || settingsModel;
try {
const models = await fetchFreeZenModels();
const modelIds = models.map((model) => model.id);
if (candidate && modelIds.includes(candidate)) {
return candidate;
}
if (modelIds.includes(ZEN_DEFAULT_MODEL)) {
return ZEN_DEFAULT_MODEL;
}
if (modelIds.length > 0) {
return modelIds[0];
}
} catch {
if (candidate) {
return candidate;
}
}
return validatedZenFallback || ZEN_DEFAULT_MODEL;
if (overrideModel) return overrideModel;
const settings = await readSettingsFromDisk().catch(() => ({}));
return typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0
? settings.zenModel.trim()
: '';
};
const validateZenModelAtStartup = async () => {
try {
const freeModels = await fetchFreeZenModels();
const freeModelIds = freeModels.map((model) => model.id);
if (freeModelIds.length > 0) {
validatedZenFallback = freeModelIds[0];
const settings = await readSettingsFromDisk();
const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : '';
if (!storedModel || !freeModelIds.includes(storedModel)) {
const fallback = freeModelIds[0];
console.log(
storedModel
? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"`
: `[zen] No model configured, setting default to "${fallback}"`
);
await persistSettings({ zenModel: fallback });
} else {
console.log(`[zen] Stored model "${storedModel}" verified as available`);
}
} else {
console.warn('[zen] No free models returned from API, skipping validation');
}
} catch (error) {
console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error);
}
};
const validateZenModelAtStartup = async () => {};
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
@@ -179,7 +76,7 @@ export const createNotificationTemplateRuntime = (deps) => {
text,
threshold: 0,
maxLength: targetLength,
zenModel: zenModel || ZEN_DEFAULT_MODEL,
zenModel,
mode: 'notification',
});
return typeof result?.summary === 'string' && result.summary.trim().length > 0
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createNotificationTemplateRuntime } from './template-runtime.js';
@@ -11,78 +11,16 @@ const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
});
describe('notification template runtime zen models', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('uses zen models with zero-cost metadata as selectable', async () => {
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (String(url).includes('models.dev')) {
return {
ok: true,
json: async () => ({
opencode: {
models: {
'big-pickle': { cost: { input: 0, output: 0 } },
'gpt-5-nano': { cost: { input: 0, output: 0 } },
'gpt-5.5': { cost: { input: 5, output: 30 } },
'hy3-preview-free': { cost: { input: 0, output: 0 } },
},
},
}),
};
}
return {
ok: true,
json: async () => ({
data: [
{ id: 'big-pickle', owned_by: 'opencode' },
{ id: 'gpt-5-nano', owned_by: 'opencode' },
{ id: 'gpt-5.5', owned_by: 'opencode' },
{ id: 'hy3-preview-free', owned_by: 'opencode' },
],
}),
};
}));
it('returns no selectable zen models after provider retirement', async () => {
const runtime = createRuntime();
const models = await runtime.fetchFreeZenModels();
expect(models.map((model) => model.id)).toEqual([
'big-pickle',
'gpt-5-nano',
'hy3-preview-free',
]);
expect(models).toEqual([]);
});
it('falls back to a valid unauthenticated model when stored zen model is stale', async () => {
vi.stubGlobal('fetch', vi.fn(async (url) => {
if (String(url).includes('models.dev')) {
return {
ok: true,
json: async () => ({
opencode: {
models: {
'big-pickle': { cost: { input: 0, output: 0 } },
'gpt-5-nano': { cost: { input: 0, output: 0 } },
},
},
}),
};
}
return {
ok: true,
json: async () => ({
data: [
{ id: 'big-pickle', owned_by: 'opencode' },
{ id: 'gpt-5-nano', owned_by: 'opencode' },
],
}),
};
}));
it('preserves stored zen model value for compatibility without validation', async () => {
const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' });
await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano');
await expect(runtime.resolveZenModel()).resolves.toBe('trinity-large-preview-free');
});
});
+1 -2
View File
@@ -23,7 +23,6 @@ export const createBootstrapRuntime = (dependencies) => {
tunnelAuthController,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
resolveZenModel,
sayTTSCapability,
ensurePushInitialized,
ensureGlobalWatcherStarted,
@@ -78,7 +77,7 @@ export const createBootstrapRuntime = (dependencies) => {
normalizeTunnelSessionTtlMs,
});
registerTtsRoutes(app, { resolveZenModel, sayTTSCapability });
registerTtsRoutes(app, { sayTTSCapability });
registerNotificationRoutes(app, {
uiAuthController,
@@ -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);
});
});
+12 -12
View File
@@ -1,14 +1,14 @@
# TTS Module Documentation
## Purpose
This module provides server-side Text-to-Speech services using OpenAI's TTS API. Shared text summarization now lives in `packages/web/server/lib/text/` and is consumed here in `tts` mode.
This module provides server-side Text-to-Speech services using OpenAI's TTS API. The historical shared text summarization endpoint now lives in `packages/web/server/lib/text/` as an API-compatible stub because the previous Zen model provider is unavailable.
## Entrypoints and structure
- `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*`, `/api/tts/*`, and `/api/stt/*` endpoints.
- `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability.
- `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration.
- `packages/web/server/lib/text/summarization.js`: Shared text summarization and sanitization utilities using opencode.ai zen API.
- `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls.
- `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints.
- `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints.
@@ -20,7 +20,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
- `TTS_VOICES`: Array of supported OpenAI voice identifiers.
### Shared text summarization (re-exported from ../text/summarization.js)
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared text summarizer. TTS uses `mode: 'tts'`.
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired shared text summarizer retained as a stub. TTS uses `mode: 'tts'`; `zenModel` is ignored.
- `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content.
- `sanitizeForNote(text)`: Re-exported for note-mode callers that still import through the TTS surface.
@@ -33,10 +33,10 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
- `TTS_VOICES`: Array of supported OpenAI voices: `['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar']`.
### Summarization defaults
- `SUMMARIZE_TIMEOUT_MS`: 30000 (30 seconds timeout for zen API requests).
- No model request timeout is used; the summarization provider is disabled.
### Default values
- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano', `mode` = 'tts'.
- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `mode` = 'tts'. `zenModel` is ignored.
- `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
- `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
@@ -61,9 +61,9 @@ Generates speech and returns as Buffer for caching purposes.
### `summarizeText`
Returns object with:
- `summary`: Sanitized summary text or original text (if not summarized).
- `summarized`: Boolean indicating if summarization was performed.
- `reason`: Optional string explaining why summarization was skipped (e.g., 'Text under threshold', 'Request timed out').
- `summary`: Sanitized or locally distilled fallback text.
- `summarized`: Always `false` while the model provider is unavailable.
- `reason`: String explaining why summarization was skipped.
- `originalLength`: Optional number for original text length.
- `summaryLength`: Optional number for summarized text length.
@@ -90,10 +90,10 @@ OpenAI API keys are resolved in order:
The TTS module is used by `packages/web/server/index.js` for:
- Generating speech streams for client playback.
- Generating speech buffers for caching.
- Summarizing long messages before TTS synthesis.
- Sanitizing text before TTS synthesis. Historical summarization calls now return local fallback text.
- Sanitizing text to remove non-speakable content.
The summarization logic itself is shared with notifications and notes, but this module uses it only in `tts` mode.
The historical summarization API is shared with notifications and notes, but currently acts as a no-model fallback/stub.
The server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients.
@@ -113,7 +113,7 @@ The server-side TTS approach bypasses mobile Safari's audio context restrictions
### Error handling
- `generateSpeechStream` and `generateSpeechBuffer` throw descriptive errors for missing API keys or empty text.
- `summarizeText` catches zen API errors and returns mode-specific fallback text with `summarized: false`.
- `summarizeText` does not call Zen and returns mode-specific fallback text with `summarized: false`.
- All errors are logged to console with `[TTSService]` or `[Summarize]` prefix.
### API key management
@@ -125,7 +125,7 @@ The server-side TTS approach bypasses mobile Safari's audio context restrictions
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
- Test API key resolution with environment variable and auth file.
- Test speech generation with various text lengths and voice options.
- Test summarization behavior above and below threshold.
- Test summarization stub behavior above and below threshold.
- Test sanitization with markdown, URLs, and code blocks.
- Verify streaming and buffer generation produce valid MP3 audio.
+6 -41
View File
@@ -1,8 +1,8 @@
import express from 'express';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
import { summarizeText, sanitizeForTTS, sanitizeForNote, sanitizeForNotification } from '../text/summarization.js';
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
export function registerTtsRoutes(app, { sayTTSCapability }) {
let ttsModulePromise = null;
const getTtsModule = async () => {
if (!ttsModulePromise) {
@@ -44,7 +44,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
// Server-side TTS endpoint - streams audio from OpenAI TTS API
app.post('/api/tts/speak', async (req, res) => {
try {
const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey, baseURL } = req.body || {};
const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, providerId, modelId, apiKey, baseURL } = req.body || {};
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
if (normalizedBaseURLResult.error) {
@@ -74,20 +74,8 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
let textToSpeak = text.trim();
// Optionally summarize long text before speaking using zen API
if (summarize && textToSpeak.length > threshold) {
try {
const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel, mode: 'tts' });
if (result.summarized && result.summary) {
textToSpeak = result.summary;
}
} catch (summarizeError) {
console.error('[TTS/speak] Summarization failed:', summarizeError);
// Continue with original text if summarization fails
}
}
// Historical summarize request fields are intentionally ignored. The
// model-backed summarization provider is retired.
const result = await ttsService.generateSpeechStream({
text: textToSpeak,
@@ -123,36 +111,13 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
return res.status(400).json({ error: 'Text is required' });
}
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
let result = await summarizeText({
const result = await summarizeText({
text,
threshold,
maxLength,
zenModel: sumZenModel,
mode: typeof mode === 'string' ? mode : 'tts',
});
if (mode === 'note' && !result.summarized) {
const notificationResult = await summarizeText({
text,
threshold,
maxLength,
zenModel: sumZenModel,
mode: 'notification',
});
if (notificationResult.summarized && notificationResult.summary) {
result = {
...notificationResult,
summary: sanitizeForNote(sanitizeForNotification(notificationResult.summary)),
};
} else {
return res.status(502).json({
error: 'Note summarization failed',
reason: notificationResult.reason || result.reason || 'No distilled result from model',
});
}
}
return res.json(result);
} catch (error) {
console.error('[Summarize] Error:', error);
+7 -56
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import express from 'express';
import request from 'supertest';
@@ -15,17 +15,7 @@ const createApp = () => {
};
describe('tts routes', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('retries note summarization with notification mode before failing', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 503,
json: async () => ({ error: 'unavailable' }),
})));
it('returns local note fallback while model summarization is retired', async () => {
const response = await request(createApp())
.post('/api/text/summarize')
.send({
@@ -35,54 +25,15 @@ describe('tts routes', () => {
mode: 'note',
});
expect(response.status).toBe(502);
expect(fetch).toHaveBeenCalledTimes(2);
expect(response.body).toEqual({
error: 'Note summarization failed',
reason: 'zen API returned 503',
});
});
it('uses notification summarizer result when note mode falls back', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ error: 'unavailable' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
output: [{
type: 'message',
content: [{ type: 'output_text', text: '**Keep provider state stable** during streaming.' }],
}],
}),
}));
const response = await request(createApp())
.post('/api/text/summarize')
.send({
text: 'First sentence. Preserve provider state references during streaming to avoid wide rerenders.',
threshold: 0,
maxLength: 100,
mode: 'note',
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
summary: 'Keep provider state stable during streaming.',
summarized: true,
summary: 'First sentence.',
summarized: false,
reason: 'Model summarization provider unavailable',
});
});
it('keeps notification fallback behavior', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: false,
status: 503,
json: async () => ({ error: 'unavailable' }),
})));
it('keeps notification fallback behavior without calling zen', async () => {
const response = await request(createApp())
.post('/api/text/summarize')
.send({
@@ -96,7 +47,7 @@ describe('tts routes', () => {
expect(response.body).toMatchObject({
summary: 'Notification text that should fall back cleanly.',
summarized: false,
reason: 'zen API returned 503',
reason: 'Model summarization provider unavailable',
});
});
});