refactor(text): extract shared summarization service with tts/note/notification modes

Move server-side summarization out of lib/tts/summarization.js into a shared
lib/text module exposing tts, note and notification modes. TTS and notification
runtimes now delegate to the shared service, note mode is used to distill a
selected excerpt into a short project note. Expose the new endpoint via
/api/text/summarize and route it through the common request middleware.

Client voice/summarize.ts accepts a mode option and points at the new
endpoint with an absolute URL that respects the desktop server origin.
VS Code webview swaps the stubbed tts summarize path for /api/text/summarize
and hardens URL parsing with window.location.href as the base.
This commit is contained in:
Bohdan Triapitsyn
2026-04-18 13:46:51 +03:00
parent 4901cf60b8
commit a9c51f115e
11 changed files with 357 additions and 229 deletions
+39 -7
View File
@@ -1,12 +1,24 @@
/**
* Text summarization utility for TTS
* Text summarization utility
*
* Calls the server-side summarization endpoint which uses
* Calls the server-side text summarization endpoint which uses
* the opencode.ai zen API with gpt-5-nano.
*/
import { useConfigStore } from '@/stores/useConfigStore';
const resolveSummarizeUrl = (): string => {
if (typeof window === 'undefined') {
return '/api/text/summarize';
}
const desktopServer = (window as typeof window & {
__OPENCHAMBER_DESKTOP_SERVER__?: { origin: string };
}).__OPENCHAMBER_DESKTOP_SERVER__;
const baseOrigin = desktopServer?.origin || window.location.origin;
return new URL('/api/text/summarize', baseOrigin).toString();
};
/**
* Summarize text using the server-side zen API endpoint
*
@@ -21,25 +33,32 @@ export async function summarizeText(
threshold?: number;
/** Max characters for the summary output */
maxLength?: number;
/** Summarization mode */
mode?: 'tts' | 'note';
}
): Promise<string> {
const store = useConfigStore.getState();
const threshold = options?.threshold ?? store.summarizeCharacterThreshold;
const maxLength = options?.maxLength ?? store.summarizeMaxLength;
const mode = options?.mode ?? 'tts';
const normalizedSource = text.replace(/\s+/g, ' ').trim();
// Don't summarize if text is under threshold
if (text.length <= threshold) {
if (mode === 'note') {
throw new Error('Note summarization threshold bypass is not allowed');
}
return text;
}
try {
const zenModel = store.settingsZenModel;
const response = await fetch('/api/tts/summarize', {
const response = await fetch(resolveSummarizeUrl(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, threshold, maxLength, ...(zenModel ? { zenModel } : {}) }),
body: JSON.stringify({ text, threshold, maxLength, mode, ...(zenModel ? { zenModel } : {}) }),
});
if (!response.ok) {
@@ -56,14 +75,27 @@ export async function summarizeText(
summaryLength?: number;
};
if (data.summarized && data.summary) {
return data.summary;
if (typeof data.summary === 'string' && data.summary.trim().length > 0) {
const summary = data.summary.trim();
if (mode === 'note') {
const normalizedSummary = summary.replace(/\s+/g, ' ').trim();
if (normalizedSummary === normalizedSource) {
throw new Error('Note distillation returned source text unchanged');
}
}
return summary;
}
// Return original text if not summarized
if (mode === 'note') {
throw new Error('Note summarization returned no distilled result');
}
// Return original text if the server produced nothing usable
return text;
} catch (err) {
console.error('[summarize] Failed to summarize:', err);
if (mode === 'note') {
throw err instanceof Error ? err : new Error('Note summarization failed');
}
// Return original text on error
return text;
}
+2 -2
View File
@@ -294,7 +294,7 @@ if (workspaceFolder) {
const normalizeUrl = (input: string | URL) => {
try {
return typeof input === 'string' ? new URL(input, window.location.origin) : new URL(input.toString());
return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href);
} catch {
return null;
}
@@ -523,7 +523,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
});
}
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/tts/summarize') && method === 'POST') {
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/text/summarize') && method === 'POST') {
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
status: 501,
headers: { 'Content-Type': 'application/json' },
@@ -67,7 +67,7 @@ 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 + zen helper runtime.
- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime and consumes shared text summarization from `packages/web/server/lib/text/summarization.js` in `notification` mode.
- Returned API:
- `resolveNotificationTemplate(template, variables)`
- `shouldApplyResolvedTemplateMessage(template, resolved, variables)`
@@ -1,3 +1,5 @@
import { summarizeText as summarizeSharedText } from '../text/summarization.js';
export const createNotificationTemplateRuntime = (deps) => {
const {
readSettingsFromDisk,
@@ -136,39 +138,16 @@ export const createNotificationTemplateRuntime = (deps) => {
const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
try {
const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output plain text only. Do not use markdown, bullets, headings, code fences, backticks, or quotes. Output only the summary text.\n\nText:\n${text}`;
const completionTimeout = createTimeoutSignal(15000);
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: zenModel || ZEN_DEFAULT_MODEL,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1000,
stream: false,
reasoning: { effort: 'low' },
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) return text;
const data = await response.json();
const summary = data?.output?.find((item) => item?.type === 'message')
?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
return summary || text;
} catch {
return text;
}
const result = await summarizeSharedText({
text,
threshold: 0,
maxLength: targetLength,
zenModel: zenModel || ZEN_DEFAULT_MODEL,
mode: 'notification',
});
return typeof result?.summary === 'string' && result.summary.trim().length > 0
? result.summary
: text;
};
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
@@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/terminal') ||
req.path.startsWith('/api/opencode') ||
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/text') ||
req.path.startsWith('/api/voice') ||
req.path.startsWith('/api/tts') ||
req.path.startsWith('/api/openchamber/tunnel')
@@ -0,0 +1,35 @@
# 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.
## Entrypoints and structure
- `packages/web/server/lib/text/summarization.js`: Shared summarize + sanitize helpers backed by opencode.ai zen API.
## Public exports
### Summarization (summarization.js)
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared summarization entrypoint.
- `sanitizeForTTS(text)`: Sanitizes text for speech output.
- `sanitizeForNotification(text)`: Sanitizes text for compact notification output.
- `sanitizeForNote(text)`: Sanitizes text for short note/distillation output.
## Modes
- `tts`: Speakable summary for TTS flows.
- `notification`: Short plain-text summary for notification bodies.
- `note`: Distilled short project-memory note.
## Response contract
### `summarizeText`
Returns object with:
- `summary`: Final transformed text.
- `summarized`: Boolean indicating whether model summarization succeeded.
- `reason`: Optional failure/skip reason.
- `originalLength`: Optional original text length.
- `summaryLength`: Optional final summary length.
## Notes for contributors
- Keep this module neutral. Do not re-couple it to TTS-specific naming or routing.
- Add new mode semantics here when multiple product surfaces need the same text pipeline.
- Prefer mode-specific prompt and sanitize behavior over creating duplicated summarizers in unrelated modules.
@@ -0,0 +1,240 @@
/**
* Shared text summarization service.
*
* Modes:
* - tts: concise speakable text
* - notification: concise notification text
* - 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 '';
return text
.replace(/[*_~`#]/g, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/^\s*[$#>]\s*/gm, '')
.replace(/[|&;<>]/g, ' ')
.replace(/\\/g, '')
.replace(/[\[\]{}()]/g, '')
.replace(/["']/g, '')
.replace(/https?:\/\/[^\s]+/g, ' a link ')
.replace(/\/[\w\-./]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export function sanitizeForNotification(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/^[\t ]*[-*+]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/\s*\n\s*/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function sanitizeForNote(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/https?:\/\/[^\s]+/g, '')
.replace(/["']/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeByMode(text, mode) {
if (mode === 'note') return sanitizeForNote(text);
if (mode === 'notification') return sanitizeForNotification(text);
return sanitizeForTTS(text);
}
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 distillNoteFallback(text, maxLength) {
const sanitized = sanitizeForNote(text);
if (!sanitized) return '';
const normalized = sanitized
.replace(/^In summary[:,]?\s*/i, '')
.replace(/^Here(?:s| is) (?:a )?note[:,]?\s*/i, '')
.trim();
const sentences = normalized
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
const best = (sentences[0] || normalized)
.split(/[;:()-]\s+/)[0]
.split(/,\s+/)[0]
.trim();
const idealLimit = Math.min(maxLength, Math.max(32, Math.floor(normalized.length * 0.65)));
if (best.length <= idealLimit) return best;
const clipped = best.slice(0, Math.max(0, idealLimit - 1)).trim();
return clipped ? `${clipped}` : best.slice(0, idealLimit).trim();
}
function fallbackByMode(text, maxLength, mode) {
if (mode === 'note') return distillNoteFallback(text, maxLength);
return sanitizeByMode(text, mode);
}
export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) {
if (!text || text.length <= threshold) {
return {
summary: fallbackByMode(text || '', maxLength, mode),
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 response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: zenModel || 'gpt-5-nano',
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
stream: false,
reasoning: { effort: 'low' },
}),
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 = extractZenOutputText(data);
if (summary) {
const sanitized = sanitizeByMode(summary, mode);
const finalSummary = mode === 'note'
? (sanitized && sanitized !== sanitizeForNote(text) ? sanitized : distillNoteFallback(text, maxLength))
: sanitized;
return {
summary: finalSummary,
summarized: true,
originalLength: text.length,
summaryLength: finalSummary.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);
}
}
+11 -6
View File
@@ -1,14 +1,14 @@
# TTS Module Documentation
## Purpose
This module provides server-side Text-to-Speech services using OpenAI's TTS API, along with text summarization and sanitization utilities for preparing content for speech synthesis.
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.
## 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/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API.
- `packages/web/server/lib/text/summarization.js`: Shared text summarization and sanitization utilities using opencode.ai zen API.
- `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.
@@ -19,9 +19,10 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API,
- `TTSService`: TTS service class for OpenAI audio generation.
- `TTS_VOICES`: Array of supported OpenAI voice identifiers.
### Summarization (from summarization.js)
- `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API.
### Shared text summarization (re-exported from ../text/summarization.js)
- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared text summarizer. TTS uses `mode: 'tts'`.
- `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.
### Capability runtime (capability-runtime.js)
- `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`.
@@ -35,7 +36,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API,
- `SUMMARIZE_TIMEOUT_MS`: 30000 (30 seconds timeout for zen API requests).
### Default values
- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano'.
- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano', `mode` = 'tts'.
- `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
- `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0.
@@ -66,6 +67,8 @@ Returns object with:
- `originalLength`: Optional number for original text length.
- `summaryLength`: Optional number for summarized text length.
The route-level text summarize API is now `/api/text/summarize`.
### `sanitizeForTTS`
Returns sanitized string with markdown, URLs, file paths, and special characters removed.
@@ -90,6 +93,8 @@ The TTS module is used by `packages/web/server/index.js` for:
- Summarizing long messages before TTS synthesis.
- 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 server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients.
## Notes for contributors
@@ -108,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 falls back to original text with `summarized: false`.
- `summarizeText` catches zen API errors and returns mode-specific fallback text with `summarized: false`.
- All errors are logged to console with `[TTSService]` or `[Summarize]` prefix.
### API key management
+2 -1
View File
@@ -13,6 +13,7 @@ export {
export {
summarizeText,
sanitizeForTTS,
} from './summarization.js';
sanitizeForNote,
} from '../text/summarization.js';
export { transcribeAudio } from './stt.js';
+14 -8
View File
@@ -1,5 +1,6 @@
import express from 'express';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
let ttsModulePromise = null;
@@ -76,9 +77,8 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
// Optionally summarize long text before speaking using zen API
if (summarize && textToSpeak.length > threshold) {
try {
const { summarizeText } = await getTtsModule();
const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel });
const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel, mode: 'tts' });
if (result.summarized && result.summary) {
textToSpeak = result.summary;
@@ -115,23 +115,29 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
}
});
app.post('/api/tts/summarize', async (req, res) => {
app.post('/api/text/summarize', async (req, res) => {
try {
const { summarizeText } = await getTtsModule();
const { text, threshold = 200, maxLength = 500 } = req.body || {};
const { text, threshold = 200, maxLength = 500, mode } = req.body || {};
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: 'Text is required' });
}
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel });
const result = await summarizeText({
text,
threshold,
maxLength,
zenModel: sumZenModel,
mode: typeof mode === 'string' ? mode : 'tts',
});
return res.json(result);
} catch (error) {
console.error('[Summarize] Error:', error);
const { sanitizeForTTS } = await getTtsModule();
const sanitized = sanitizeForTTS(req.body?.text || '');
const sanitized = typeof req.body?.mode === 'string' && req.body.mode === 'note'
? sanitizeForNote(req.body?.text || '')
: sanitizeForTTS(req.body?.text || '');
return res.json({ summary: sanitized, summarized: false, reason: error.message });
}
});
@@ -1,171 +0,0 @@
/**
* Text Summarization Service
*
* Uses the opencode.ai zen API with gpt-5-nano for fast, lightweight summarization.
* Used by all TTS implementations (Browser, Say, OpenAI).
*/
function buildSummarizationPrompt(maxLength) {
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;
/**
* Sanitize text for TTS output
* Removes markdown, URLs, file paths, and other non-speakable content
*/
export function sanitizeForTTS(text) {
if (!text || typeof text !== 'string') return '';
return text
// Remove markdown formatting
.replace(/[*_~`#]/g, '')
// Remove code blocks
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
// Remove shell-like command patterns
.replace(/^\s*[$#>]\s*/gm, '')
// Remove common shell operators
.replace(/[|&;<>]/g, ' ')
// Remove backslashes (escape characters)
.replace(/\\/g, '')
// Remove brackets that might be interpreted specially
.replace(/[[\]{}()]/g, '')
// Remove quotes that might cause issues
.replace(/["']/g, '')
// Remove URLs
.replace(/https?:\/\/[^\s]+/g, ' a link ')
// Remove file paths
.replace(/\/[\w\-./]+/g, '')
// Collapse multiple spaces/newlines
.replace(/\s+/g, ' ')
.trim();
}
/**
* Extract text from zen API response
*/
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;
}
/**
* Summarize text using the opencode.ai zen API
*
* @param {Object} options
* @param {string} options.text - The text to summarize
* @param {number} options.threshold - Character threshold (don't summarize if under this length)
* @param {number} options.maxLength - Maximum character length for the summary output (50-2000)
* @param {string} [options.zenModel] - Override zen model (defaults to gpt-5-nano)
* @returns {Promise<{summary: string, summarized: boolean, reason?: string}>}
*/
export async function summarizeText({
text,
threshold = 200,
maxLength = 500,
zenModel,
}) {
// Don't summarize if text is under threshold
if (!text || text.length <= threshold) {
return {
summary: sanitizeForTTS(text || ''),
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);
const response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: zenModel || 'gpt-5-nano',
input: [
{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` },
],
stream: false,
reasoning: { effort: 'low' },
}),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('[Summarize] zen API error:', response.status, errorBody);
return {
summary: sanitizeForTTS(text),
summarized: false,
reason: `zen API returned ${response.status}`,
};
}
const data = await response.json();
const summary = extractZenOutputText(data);
if (summary) {
const sanitized = sanitizeForTTS(summary);
return {
summary: sanitized,
summarized: true,
originalLength: text.length,
summaryLength: sanitized.length,
};
}
return {
summary: sanitizeForTTS(text),
summarized: false,
reason: 'No response from model',
};
} catch (error) {
if (error.name === 'AbortError') {
console.error('[Summarize] Request timed out');
return {
summary: sanitizeForTTS(text),
summarized: false,
reason: 'Request timed out',
};
}
console.error('[Summarize] Error:', error);
return {
summary: sanitizeForTTS(text),
summarized: false,
reason: error.message,
};
} finally {
clearTimeout(timer);
}
}