opencode-tts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # opencode-tts
2
+
3
+ An OpenCode plugin that speaks a very short summary of the assistant response instead of reading the whole answer aloud.
4
+
5
+ The core idea is simple:
6
+
7
+ - wait until the OpenCode session goes idle
8
+ - grab the latest assistant message
9
+ - ask a fast follow-up model call to compress it to at most two sentences
10
+ - speak that summary with the local TTS engine
11
+
12
+ It also exposes a `tts_summary` tool for manual use.
13
+
14
+ ## Why this plugin
15
+
16
+ Raw text-to-speech for long coding responses is usually too verbose. This plugin makes TTS practical by turning each answer into short spoken audio copy first.
17
+
18
+ ## Current behavior
19
+
20
+ - Automatic mode runs on `session.idle`
21
+ - Summaries are capped to `2` sentences by default
22
+ - TTS defaults to the locally installed `edge_tts` package
23
+ - Default voice is `en-US-AvaMultilingualNeural`
24
+ - Default speech rate is `+25%`
25
+ - A throwaway OpenCode session is used for the summarization pass so the spoken output is model-generated, not truncated raw text
26
+
27
+ ## Configuration
28
+
29
+ OpenCode's main config schema is strict, so arbitrary plugin keys are rejected. This plugin uses a sidecar config file at `~/.config/opencode/plugins/opencode-tts.jsonc`. Preferred: use OpenCode's native `small_model` in your main config. The plugin will use that first for the summary pass, then fall back to the main response model if `small_model` is not set.
30
+
31
+ Example:
32
+
33
+ ```json
34
+ {
35
+ "model": "mlx-local/mlx-community/Qwen3.5-35B-A3B-4bit",
36
+ "small_model": "mlx-local/qwen3.5-0.8b-mlx"
37
+ }
38
+ ```
39
+
40
+ ```jsonc
41
+ // ~/.config/opencode/plugins/opencode-tts.jsonc
42
+ {
43
+ "enabled": true,
44
+ "debug": true,
45
+ "backend": "edge_tts",
46
+ "edge_tts": {
47
+ "command": ["/Users/stefano/.opencode-tts/.venv/bin/python", "-m", "edge_tts"],
48
+ "voice": "en-US-AvaMultilingualNeural",
49
+ "rate": "+25%",
50
+ "volume": "+0%"
51
+ }
52
+ }
53
+ ```
54
+
55
+ Optional overrides: set environment variables before launching OpenCode:
56
+
57
+ ```bash
58
+ export OPENCODE_TTS_AUTO=true
59
+ export OPENCODE_TTS_VOICE=Samantha
60
+ export OPENCODE_TTS_SUMMARY_PROVIDER=openai
61
+ export OPENCODE_TTS_SUMMARY_MODEL=gpt-4.1-mini
62
+ export OPENCODE_TTS_MAX_SENTENCES=2
63
+ ```
64
+
65
+ Notes:
66
+
67
+ - If OpenCode `small_model` is set, the plugin uses that for summaries.
68
+ - If `OPENCODE_TTS_SUMMARY_PROVIDER` and `OPENCODE_TTS_SUMMARY_MODEL` are set, they override `small_model`.
69
+ - If neither is set, the plugin falls back to the provider/model used for the original assistant response.
70
+ - `OPENCODE_TTS_MAX_SENTENCES` is clamped to `1-3`.
71
+
72
+ ## Summary Fallback Order
73
+
74
+ The plugin currently resolves summary generation in this order:
75
+
76
+ 1. `small_model` from OpenCode config, if set
77
+ 2. the same provider/model that produced the assistant response
78
+ 3. a local text fallback that trims the assistant response to the first couple of sentences if the summary call fails
79
+
80
+ This means the plugin can still speak something useful even when the configured small model is unavailable or broken.
81
+
82
+ ## Current MLX Notes
83
+
84
+ During local testing against the MLX server on `localhost:8080`:
85
+
86
+ - `mlx-community/Qwen3.5-0.8B-OptiQ-4bit` failed to load
87
+ - `mlx-community/Qwen3.5-2B-OptiQ-4bit` failed to load with missing `vision_tower` parameters
88
+ - `mlx-community/Qwen2.5-1.5B-Instruct-4bit` failed because the current `mlx_vlm` install did not support that model type
89
+
90
+ Because of that, the recommended current setup is to leave `small_model` unset and let the plugin fall back to the main model until the MLX model compatibility issue is fixed.
91
+
92
+ ## OpenCode Route Bug
93
+
94
+ While debugging the summary path, we found a separate OpenCode server bug in:
95
+
96
+ - `/Users/stefano/repos/opencode/packages/opencode/src/server/routes/session.ts`
97
+
98
+ The `POST /session/:sessionID/message` route used Hono's streaming helper and called `stream.write(JSON.stringify(msg))` without awaiting it. In this runtime, that let the callback return before the write flushed, so the server could respond with:
99
+
100
+ - `HTTP 200`
101
+ - `Content-Length: 0`
102
+ - empty body
103
+
104
+ That made the plugin look like it was failing to parse summary output, but the real issue was that the route returned no bytes even though the throwaway summary session had already generated text internally.
105
+
106
+ The fix is to `await stream.write(...)` before the callback exits.
107
+
108
+ ## Files
109
+
110
+ Current locations:
111
+
112
+ - Plugin source: `/Users/stefano/repos/opencode-tts/src/index.ts`
113
+ - Plugin README: `/Users/stefano/repos/opencode-tts/README.md`
114
+ - OpenCode config: `/Users/stefano/.config/opencode/opencode.json`
115
+ - Plugin config: `/Users/stefano/.config/opencode/plugins/opencode-tts.jsonc`
116
+ - Private TTS environment: `/Users/stefano/.opencode-tts/.venv`
117
+ - Debug log: `/Users/stefano/.opencode-tts/plugin.log`
118
+
119
+ ## TTS configuration
120
+
121
+ Speech settings live in `~/.config/opencode/plugins/opencode-tts.jsonc`. Example:
122
+
123
+ ```jsonc
124
+ {
125
+ "enabled": true,
126
+ "backend": "edge_tts",
127
+ "edge_tts": {
128
+ "command": ["edge-tts"],
129
+ "voice": "en-US-AvaMultilingualNeural",
130
+ "rate": "+25%",
131
+ "volume": "+0%"
132
+ }
133
+ }
134
+ ```
135
+
136
+ If the local package command is not on your PATH, point `command` at the installed executable or use:
137
+
138
+ ```jsonc
139
+ {
140
+ "edge_tts": {
141
+ "command": ["python3", "-m", "edge_tts"]
142
+ }
143
+ }
144
+ ```
145
+
146
+ ## Install for local development
147
+
148
+ ```bash
149
+ cd ~/repos/opencode-tts
150
+ npm install
151
+ npm run build
152
+ ```
153
+
154
+ Then add the plugin in your OpenCode config:
155
+
156
+ ```json
157
+ {
158
+ "plugin": ["file:///Users/stefano/repos/opencode-tts/src/index.ts"]
159
+ }
160
+ ```
161
+
162
+ If you want a bundled artifact for publishing, point OpenCode at `dist/index.js` after `npm run build`.
163
+
164
+ ## Manual tool
165
+
166
+ The plugin exports a `tts_summary` tool with:
167
+
168
+ - `text`: source text to summarize and speak
169
+ - `voice`: optional voice override
170
+
171
+ ## Next useful upgrades
172
+
173
+ - configurable summary prompt styles such as `brief`, `status`, and `next-step`
174
+ - output audio file support
175
+ - per-project voice settings
176
+ - debounce rules so only user-visible assistant turns are spoken
package/dist/index.js ADDED
@@ -0,0 +1,381 @@
1
+ // src/index.ts
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { appendFileSync, readFileSync, unlinkSync } from "node:fs";
5
+ var spokenAssistantMessages = /* @__PURE__ */ new Set();
6
+ var inFlightSessions = /* @__PURE__ */ new Set();
7
+ var latestAssistantMessageBySession = /* @__PURE__ */ new Map();
8
+ var assistantTextByMessage = /* @__PURE__ */ new Map();
9
+ var AUTO_ENABLED = readBooleanEnv("OPENCODE_TTS_AUTO", true);
10
+ var DEFAULT_VOICE = process.env.OPENCODE_TTS_VOICE;
11
+ var MAX_SENTENCES = clampNumber(readNumberEnv("OPENCODE_TTS_MAX_SENTENCES", 2), 1, 3);
12
+ var SUMMARY_PROVIDER = process.env.OPENCODE_TTS_SUMMARY_PROVIDER;
13
+ var SUMMARY_MODEL = process.env.OPENCODE_TTS_SUMMARY_MODEL;
14
+ var DEFAULT_EDGE_TTS_COMMAND = ["edge-tts"];
15
+ var OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
16
+ var VENV_DIR = path.join(OPENCODE_DIR, "tts-venv");
17
+ var VENV_PYTHON = path.join(VENV_DIR, process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python");
18
+ var VENV_EDGE_TTS_COMMAND = [VENV_PYTHON, "-m", "edge_tts"];
19
+ var DEFAULT_EDGE_TTS_VOICE = "en-US-AvaMultilingualNeural";
20
+ var DEFAULT_EDGE_TTS_RATE = "+25%";
21
+ var DEFAULT_EDGE_TTS_VOLUME = "+0%";
22
+ var PLUGIN_CONFIG_PATHS = [
23
+ path.join(OPENCODE_DIR, "plugins", "opencode-tts.jsonc"),
24
+ path.join(OPENCODE_DIR, "plugin", "opencode-tts.jsonc")
25
+ ];
26
+ var LOG_PATH = path.join(OPENCODE_DIR, "logs", "opencode-tts.log");
27
+ var currentPluginConfig = {};
28
+ function readBooleanEnv(name, fallback) {
29
+ const value = process.env[name];
30
+ if (!value) return fallback;
31
+ return ["1", "true", "yes", "on"].includes(value.toLowerCase());
32
+ }
33
+ function readNumberEnv(name, fallback) {
34
+ const value = process.env[name];
35
+ if (!value) return fallback;
36
+ const parsed = Number.parseInt(value, 10);
37
+ return Number.isFinite(parsed) ? parsed : fallback;
38
+ }
39
+ function clampNumber(value, min, max) {
40
+ return Math.min(max, Math.max(min, value));
41
+ }
42
+ function normalizeText(value) {
43
+ return value.replace(/\s+/g, " ").trim();
44
+ }
45
+ function stripThinkingTags(value) {
46
+ return value.replace(/<think>[\s\S]*?<\/think>/gi, " ").replace(/<\/?think>/gi, " ").replace(/<reflection>[\s\S]*?<\/reflection>/gi, " ").replace(/<\/?reflection>/gi, " ");
47
+ }
48
+ function sanitizeForSpeech(value) {
49
+ return normalizeText(stripThinkingTags(value));
50
+ }
51
+ function stripJsonComments(value) {
52
+ return value.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
53
+ }
54
+ function unwrapData(value) {
55
+ if (value && typeof value === "object" && "data" in value) return value.data;
56
+ return value;
57
+ }
58
+ function logLine(message, extra) {
59
+ try {
60
+ const config = getPluginConfig();
61
+ if (!config.debug) return;
62
+ const suffix = extra === void 0 ? "" : ` ${JSON.stringify(extra)}`;
63
+ appendFileSync(LOG_PATH, `${(/* @__PURE__ */ new Date()).toISOString()} ${message}${suffix}
64
+ `);
65
+ } catch {
66
+ }
67
+ }
68
+ function normalizePluginConfig(config) {
69
+ return config ?? {};
70
+ }
71
+ function loadPluginConfigFromDisk() {
72
+ for (const configPath of PLUGIN_CONFIG_PATHS) {
73
+ try {
74
+ const content = readFileSync(configPath, "utf8");
75
+ return normalizePluginConfig(JSON.parse(stripJsonComments(content)));
76
+ } catch {
77
+ }
78
+ }
79
+ return {};
80
+ }
81
+ function setPluginConfig(config) {
82
+ currentPluginConfig = normalizePluginConfig(config);
83
+ }
84
+ function getPluginConfig() {
85
+ if (Object.keys(currentPluginConfig).length === 0) {
86
+ currentPluginConfig = loadPluginConfigFromDisk();
87
+ }
88
+ return currentPluginConfig;
89
+ }
90
+ async function findPython(shell) {
91
+ for (const candidate of ["python3", "python"]) {
92
+ const check = await shell`${candidate} --version`.nothrow().quiet();
93
+ if (check.exitCode === 0) return candidate;
94
+ }
95
+ return void 0;
96
+ }
97
+ async function ensureEdgeTts(shell) {
98
+ const venvCheck = await shell`${VENV_PYTHON} -c "import edge_tts"`.nothrow().quiet();
99
+ if (venvCheck.exitCode === 0) return;
100
+ logLine("edge_tts.install.start", { venvDir: VENV_DIR });
101
+ const python = await findPython(shell);
102
+ if (!python) {
103
+ logLine("edge_tts.install.no-python");
104
+ return;
105
+ }
106
+ const venvSetup = await shell`${python} -m venv ${VENV_DIR}`.nothrow().quiet();
107
+ if (venvSetup.exitCode !== 0) {
108
+ logLine("edge_tts.install.venv-failed", { stderr: venvSetup.stderr.toString() });
109
+ return;
110
+ }
111
+ const install = await shell`${VENV_PYTHON} -m pip install --quiet edge-tts`.nothrow().quiet();
112
+ if (install.exitCode !== 0) {
113
+ logLine("edge_tts.install.pip-failed", { stderr: install.stderr.toString() });
114
+ return;
115
+ }
116
+ logLine("edge_tts.install.success");
117
+ }
118
+ async function resolveEdgeTtsCommand(shell, configured) {
119
+ if (configured?.length) return configured;
120
+ await ensureEdgeTts(shell);
121
+ const venvCheck = await shell`${VENV_PYTHON} -c "import edge_tts"`.nothrow().quiet();
122
+ if (venvCheck.exitCode === 0) return VENV_EDGE_TTS_COMMAND;
123
+ const binary = await shell`command -v edge-tts`.nothrow().quiet();
124
+ if (binary.exitCode === 0) return DEFAULT_EDGE_TTS_COMMAND;
125
+ const pythonModule = await shell`python3 -c "import edge_tts"`.nothrow().quiet();
126
+ if (pythonModule.exitCode === 0) return ["python3", "-m", "edge_tts"];
127
+ return DEFAULT_EDGE_TTS_COMMAND;
128
+ }
129
+ function serializeUnknown(value) {
130
+ if (value instanceof Error) {
131
+ return {
132
+ name: value.name,
133
+ message: value.message,
134
+ stack: value.stack
135
+ };
136
+ }
137
+ if (!value || typeof value !== "object") return value;
138
+ const record = value;
139
+ const output = {};
140
+ for (const [key, entry] of Object.entries(record)) {
141
+ if (key === "request") continue;
142
+ if (key === "response" && entry && typeof entry === "object") {
143
+ const response = entry;
144
+ output.response = {
145
+ status: response.status,
146
+ statusText: response.statusText,
147
+ ok: response.ok,
148
+ url: response.url
149
+ };
150
+ continue;
151
+ }
152
+ output[key] = entry;
153
+ }
154
+ return output;
155
+ }
156
+ function fallbackSummaryFromText(text) {
157
+ const normalized = sanitizeForSpeech(text);
158
+ const sentences = normalized.match(/[^.!?]+[.!?]+/g) ?? [normalized];
159
+ return normalizeText(sentences.slice(0, MAX_SENTENCES).join(" "));
160
+ }
161
+ function parseModel(model) {
162
+ const [providerID, ...rest] = model.split("/");
163
+ return {
164
+ providerID,
165
+ modelID: rest.join("/")
166
+ };
167
+ }
168
+ async function resolveSummaryModel(client, directory, source) {
169
+ if (SUMMARY_PROVIDER && SUMMARY_MODEL) {
170
+ return {
171
+ providerID: SUMMARY_PROVIDER,
172
+ modelID: SUMMARY_MODEL
173
+ };
174
+ }
175
+ const config = await legacyConfigGet(client, directory).then((result) => unwrapData(result)).catch(() => void 0);
176
+ if (config?.small_model) return parseModel(config.small_model);
177
+ if (config?.model) return parseModel(config.model);
178
+ const providerID = source?.providerID;
179
+ const modelID = source?.modelID;
180
+ if (!providerID || !modelID) return void 0;
181
+ return { providerID, modelID };
182
+ }
183
+ async function legacyConfigGet(client, directory) {
184
+ return client.config.get({
185
+ query: { directory },
186
+ throwOnError: false
187
+ });
188
+ }
189
+ async function summarizeText(client, directory, inputText, sourceModel) {
190
+ logLine("summarize.start", { directory, inputLength: inputText.length, sourceModel });
191
+ const summaryModel = await resolveSummaryModel(client, directory, sourceModel);
192
+ logLine("summarize.model", { summaryModel });
193
+ if (!summaryModel) throw new Error("no model available for summarization");
194
+ const config = await legacyConfigGet(client, directory).then((result) => unwrapData(result)).catch(() => void 0);
195
+ const providerOptions = config?.provider?.[summaryModel.providerID]?.options;
196
+ const baseURL = providerOptions?.baseURL;
197
+ const apiKey = providerOptions?.apiKey ?? "local";
198
+ if (!baseURL) throw new Error(`no baseURL found for provider ${summaryModel.providerID}`);
199
+ const summaryPrompt = [
200
+ `Summarize the following assistant response as spoken audio copy in no more than ${MAX_SENTENCES} short sentences.`,
201
+ "Keep concrete facts, decisions, and next actions.",
202
+ "Do not mention that this is a summary.",
203
+ "Paraphrase instead of copying the opening words when possible.",
204
+ "Never output chain-of-thought, think tags, reflection tags, or XML-like tags.",
205
+ "Do not use bullets, numbering, markdown, or preamble.",
206
+ "",
207
+ sanitizeForSpeech(inputText)
208
+ ].join("\n");
209
+ logLine("summarize.direct.request", { baseURL, modelID: summaryModel.modelID });
210
+ const response = await fetch(`${baseURL}/chat/completions`, {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ Authorization: `Bearer ${apiKey}`
215
+ },
216
+ body: JSON.stringify({
217
+ model: summaryModel.modelID,
218
+ messages: [
219
+ { role: "system", content: "You are a compression model. Reply with plain text only." },
220
+ { role: "user", content: summaryPrompt }
221
+ ],
222
+ max_tokens: 200,
223
+ stream: false
224
+ })
225
+ });
226
+ if (!response.ok) {
227
+ const body = await response.text().catch(() => "");
228
+ throw new Error(`summarization request failed: ${response.status} ${body}`);
229
+ }
230
+ const json = await response.json();
231
+ const summary = sanitizeForSpeech(json.choices?.[0]?.message?.content ?? "");
232
+ logLine("summarize.response.text", { summary });
233
+ if (!summary) throw new Error("summary model returned no text");
234
+ logLine("summarize.success", { summary });
235
+ return summary;
236
+ }
237
+ async function runTts(shell, text, voice = DEFAULT_VOICE) {
238
+ const normalized = sanitizeForSpeech(text);
239
+ if (!normalized) return;
240
+ const config = getPluginConfig();
241
+ if (config.enabled === false) return;
242
+ const backend = config.backend ?? "edge_tts";
243
+ if (backend === "edge_tts") {
244
+ const command = await resolveEdgeTtsCommand(shell, config.edge_tts?.command);
245
+ const edgeVoice = voice ?? config.edge_tts?.voice ?? DEFAULT_EDGE_TTS_VOICE;
246
+ const rate = config.edge_tts?.rate ?? DEFAULT_EDGE_TTS_RATE;
247
+ const volume = config.edge_tts?.volume ?? DEFAULT_EDGE_TTS_VOLUME;
248
+ const outputPath = path.join(
249
+ os.tmpdir(),
250
+ `opencode-tts-${Date.now()}-${Math.random().toString(36).slice(2)}.mp3`
251
+ );
252
+ try {
253
+ logLine("tts.edge_tts.start", { command, voice: edgeVoice, rate, volume, outputPath });
254
+ const escaped = command.map((item) => shell.escape(item)).join(" ");
255
+ const runner = shell.nothrow();
256
+ const tts = await runner`${{ raw: `${escaped} --voice ${shell.escape(edgeVoice)} --rate ${shell.escape(rate)} --volume ${shell.escape(volume)} --text ${shell.escape(normalized)} --write-media ${shell.escape(outputPath)}` }}`.quiet();
257
+ if (tts.exitCode !== 0) {
258
+ logLine("tts.edge_tts.failed", { exitCode: tts.exitCode, stderr: tts.stderr.toString() });
259
+ throw new Error(`edge_tts exited with code ${tts.exitCode}`);
260
+ }
261
+ logLine("tts.edge_tts.generated", { outputPath });
262
+ if (process.platform === "darwin") {
263
+ await shell`/usr/bin/afplay ${outputPath}`.quiet();
264
+ logLine("tts.playback.afplay.success", { outputPath });
265
+ return;
266
+ }
267
+ const ffplay = await shell`command -v ffplay`.nothrow().quiet();
268
+ if (ffplay.exitCode === 0) {
269
+ await shell`ffplay -nodisp -autoexit -loglevel quiet ${outputPath}`.quiet();
270
+ return;
271
+ }
272
+ const mpg123 = await shell`command -v mpg123`.nothrow().quiet();
273
+ if (mpg123.exitCode === 0) {
274
+ await shell`mpg123 -q ${outputPath}`.quiet();
275
+ return;
276
+ }
277
+ throw new Error("No audio player found for edge_tts output. Install `afplay`, `ffplay`, or `mpg123`.");
278
+ } finally {
279
+ try {
280
+ unlinkSync(outputPath);
281
+ } catch {
282
+ }
283
+ }
284
+ }
285
+ if (process.platform === "darwin") {
286
+ if (voice) {
287
+ await shell`/usr/bin/say -v ${voice} ${normalized}`.quiet();
288
+ return;
289
+ }
290
+ await shell`/usr/bin/say ${normalized}`.quiet();
291
+ return;
292
+ }
293
+ const spdSay = await shell`command -v spd-say`.nothrow().quiet();
294
+ if (spdSay.exitCode === 0) {
295
+ await shell`spd-say ${normalized}`.quiet();
296
+ return;
297
+ }
298
+ const espeak = await shell`command -v espeak`.nothrow().quiet();
299
+ if (espeak.exitCode === 0) {
300
+ await shell`espeak ${normalized}`.quiet();
301
+ return;
302
+ }
303
+ throw new Error("No supported TTS command found. Install `say`, `spd-say`, or `espeak`.");
304
+ }
305
+ async function summarizeAndSpeak(input, text, sourceModel, voice) {
306
+ let summary;
307
+ try {
308
+ summary = await summarizeText(input.client, input.directory, text, sourceModel);
309
+ } catch (error) {
310
+ logLine("summarize.error", serializeUnknown(error));
311
+ summary = fallbackSummaryFromText(text);
312
+ logLine("summarize.fallback", {
313
+ reason: error instanceof Error ? error.message : String(error),
314
+ summary
315
+ });
316
+ }
317
+ await runTts(input.$, summary, voice);
318
+ return summary;
319
+ }
320
+ var OpenCodeTTSPlugin = async (input) => {
321
+ setPluginConfig(loadPluginConfigFromDisk());
322
+ return {
323
+ event: async ({ event }) => {
324
+ logLine("event.received", { type: event.type });
325
+ if (event.type === "message.updated") {
326
+ const info = event.properties.info;
327
+ if (info.role === "assistant") {
328
+ latestAssistantMessageBySession.set(info.sessionID, {
329
+ messageID: info.id,
330
+ model: info.model
331
+ });
332
+ logLine("message.updated.assistant", { sessionID: info.sessionID, messageID: info.id });
333
+ }
334
+ }
335
+ if (event.type === "message.part.updated") {
336
+ const part = event.properties.part;
337
+ if (part.type === "text") {
338
+ assistantTextByMessage.set(part.messageID, sanitizeForSpeech(part.text));
339
+ logLine("message.part.updated.text", { messageID: part.messageID, length: part.text.length });
340
+ }
341
+ }
342
+ if (!AUTO_ENABLED) return;
343
+ if (event.type !== "session.idle") return;
344
+ const sessionID = event.properties.sessionID;
345
+ logLine("event.session.idle", { sessionID, inputDirectory: input.directory });
346
+ if (inFlightSessions.has(sessionID)) return;
347
+ inFlightSessions.add(sessionID);
348
+ try {
349
+ const latest = latestAssistantMessageBySession.get(sessionID);
350
+ if (!latest) {
351
+ logLine("event.session.idle.no-latest-message", { sessionID });
352
+ return;
353
+ }
354
+ if (spokenAssistantMessages.has(latest.messageID)) return;
355
+ const text = sanitizeForSpeech(assistantTextByMessage.get(latest.messageID) ?? "");
356
+ logLine("event.session.idle.cached-text", { sessionID, messageID: latest.messageID, textLength: text.length });
357
+ if (!text) return;
358
+ const summary = await summarizeAndSpeak(input, text, latest.model);
359
+ spokenAssistantMessages.add(latest.messageID);
360
+ if (getPluginConfig().debug) {
361
+ console.log(`[opencode-tts] ${summary}`);
362
+ }
363
+ logLine("event.session.idle.spoken", { sessionID, messageID: latest.messageID, summary });
364
+ } catch (error) {
365
+ console.error("[opencode-tts] failed to summarize and speak", error);
366
+ logLine("event.session.idle.error", {
367
+ sessionID,
368
+ error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error)
369
+ });
370
+ } finally {
371
+ inFlightSessions.delete(sessionID);
372
+ }
373
+ }
374
+ };
375
+ };
376
+ var index_default = OpenCodeTTSPlugin;
377
+ export {
378
+ OpenCodeTTSPlugin,
379
+ index_default as default
380
+ };
381
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["import type { Plugin } from \"@opencode-ai/plugin\"\nimport type { AssistantMessage, Message, Part } from \"@opencode-ai/sdk\"\nimport os from \"node:os\"\nimport path from \"node:path\"\nimport { appendFileSync, readFileSync, unlinkSync } from \"node:fs\"\n\ntype SummaryModel = {\n providerID: string\n modelID: string\n}\n\ntype ProviderOptions = {\n baseURL?: string\n apiKey?: string\n}\n\ntype OpenCodeConfig = {\n model?: string\n small_model?: string\n provider?: Record<string, { options?: ProviderOptions }>\n}\n\ntype MaybeData<T> = T | { data: T }\n\ntype TTSPluginConfig = {\n enabled?: boolean\n debug?: boolean\n backend?: \"edge_tts\" | \"say\"\n edge_tts?: {\n command?: string[]\n voice?: string\n rate?: string\n volume?: string\n }\n}\n\nconst spokenAssistantMessages = new Set<string>()\nconst inFlightSessions = new Set<string>()\nconst latestAssistantMessageBySession = new Map<string, { messageID: string; model?: AssistantMessage[\"model\"] }>()\nconst assistantTextByMessage = new Map<string, string>()\n\nconst AUTO_ENABLED = readBooleanEnv(\"OPENCODE_TTS_AUTO\", true)\nconst DEFAULT_VOICE = process.env.OPENCODE_TTS_VOICE\nconst MAX_SENTENCES = clampNumber(readNumberEnv(\"OPENCODE_TTS_MAX_SENTENCES\", 2), 1, 3)\nconst SUMMARY_PROVIDER = process.env.OPENCODE_TTS_SUMMARY_PROVIDER\nconst SUMMARY_MODEL = process.env.OPENCODE_TTS_SUMMARY_MODEL\nconst DEFAULT_EDGE_TTS_COMMAND = [\"edge-tts\"]\nconst OPENCODE_DIR = path.join(os.homedir(), \".config\", \"opencode\")\nconst VENV_DIR = path.join(OPENCODE_DIR, \"tts-venv\")\nconst VENV_PYTHON = path.join(VENV_DIR, process.platform === \"win32\" ? \"Scripts\" : \"bin\", process.platform === \"win32\" ? \"python.exe\" : \"python\")\nconst VENV_EDGE_TTS_COMMAND = [VENV_PYTHON, \"-m\", \"edge_tts\"]\nconst DEFAULT_EDGE_TTS_VOICE = \"en-US-AvaMultilingualNeural\"\nconst DEFAULT_EDGE_TTS_RATE = \"+25%\"\nconst DEFAULT_EDGE_TTS_VOLUME = \"+0%\"\nconst PLUGIN_CONFIG_PATHS = [\n path.join(OPENCODE_DIR, \"plugins\", \"opencode-tts.jsonc\"),\n path.join(OPENCODE_DIR, \"plugin\", \"opencode-tts.jsonc\"),\n]\nconst LOG_PATH = path.join(OPENCODE_DIR, \"logs\", \"opencode-tts.log\")\nlet currentPluginConfig: TTSPluginConfig = {}\n\nfunction readBooleanEnv(name: string, fallback: boolean) {\n const value = process.env[name]\n if (!value) return fallback\n return [\"1\", \"true\", \"yes\", \"on\"].includes(value.toLowerCase())\n}\n\nfunction readNumberEnv(name: string, fallback: number) {\n const value = process.env[name]\n if (!value) return fallback\n const parsed = Number.parseInt(value, 10)\n return Number.isFinite(parsed) ? parsed : fallback\n}\n\nfunction clampNumber(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction normalizeText(value: string) {\n return value.replace(/\\s+/g, \" \").trim()\n}\n\nfunction stripThinkingTags(value: string) {\n return value\n .replace(/<think>[\\s\\S]*?<\\/think>/gi, \" \")\n .replace(/<\\/?think>/gi, \" \")\n .replace(/<reflection>[\\s\\S]*?<\\/reflection>/gi, \" \")\n .replace(/<\\/?reflection>/gi, \" \")\n}\n\nfunction sanitizeForSpeech(value: string) {\n return normalizeText(stripThinkingTags(value))\n}\n\nfunction stripJsonComments(value: string) {\n return value.replace(/\\/\\/.*$/gm, \"\").replace(/\\/\\*[\\s\\S]*?\\*\\//g, \"\")\n}\n\nfunction unwrapData<T>(value: MaybeData<T>): T {\n if (value && typeof value === \"object\" && \"data\" in value) return value.data\n return value as T\n}\n\nfunction logLine(message: string, extra?: unknown) {\n try {\n const config = getPluginConfig()\n if (!config.debug) return\n const suffix = extra === undefined ? \"\" : ` ${JSON.stringify(extra)}`\n appendFileSync(LOG_PATH, `${new Date().toISOString()} ${message}${suffix}\\n`)\n } catch {\n // Ignore logging failures.\n }\n}\n\nfunction normalizePluginConfig(config?: TTSPluginConfig): TTSPluginConfig {\n return config ?? {}\n}\n\nfunction loadPluginConfigFromDisk(): TTSPluginConfig {\n for (const configPath of PLUGIN_CONFIG_PATHS) {\n try {\n const content = readFileSync(configPath, \"utf8\")\n return normalizePluginConfig(JSON.parse(stripJsonComments(content)) as TTSPluginConfig)\n } catch {\n // Try the next supported location.\n }\n }\n return {}\n}\n\nfunction setPluginConfig(config?: TTSPluginConfig) {\n currentPluginConfig = normalizePluginConfig(config)\n}\n\nfunction getPluginConfig() {\n if (Object.keys(currentPluginConfig).length === 0) {\n currentPluginConfig = loadPluginConfigFromDisk()\n }\n return currentPluginConfig\n}\n\nasync function findPython(shell: Parameters<Plugin>[0][\"$\"]): Promise<string | undefined> {\n for (const candidate of [\"python3\", \"python\"]) {\n const check = await shell`${candidate} --version`.nothrow().quiet()\n if (check.exitCode === 0) return candidate\n }\n return undefined\n}\n\nasync function ensureEdgeTts(shell: Parameters<Plugin>[0][\"$\"]): Promise<void> {\n // Already installed in our venv?\n const venvCheck = await shell`${VENV_PYTHON} -c \"import edge_tts\"`.nothrow().quiet()\n if (venvCheck.exitCode === 0) return\n\n logLine(\"edge_tts.install.start\", { venvDir: VENV_DIR })\n\n const python = await findPython(shell)\n if (!python) {\n logLine(\"edge_tts.install.no-python\")\n return\n }\n\n // Create venv if it doesn't exist yet\n const venvSetup = await shell`${python} -m venv ${VENV_DIR}`.nothrow().quiet()\n if (venvSetup.exitCode !== 0) {\n logLine(\"edge_tts.install.venv-failed\", { stderr: venvSetup.stderr.toString() })\n return\n }\n\n // Install edge-tts into the venv\n const install = await shell`${VENV_PYTHON} -m pip install --quiet edge-tts`.nothrow().quiet()\n if (install.exitCode !== 0) {\n logLine(\"edge_tts.install.pip-failed\", { stderr: install.stderr.toString() })\n return\n }\n\n logLine(\"edge_tts.install.success\")\n}\n\nasync function resolveEdgeTtsCommand(shell: Parameters<Plugin>[0][\"$\"], configured?: string[]) {\n if (configured?.length) return configured\n\n // Auto-install into our managed venv if needed\n await ensureEdgeTts(shell)\n\n // Prefer our managed venv\n const venvCheck = await shell`${VENV_PYTHON} -c \"import edge_tts\"`.nothrow().quiet()\n if (venvCheck.exitCode === 0) return VENV_EDGE_TTS_COMMAND\n\n // Fall back to whatever is on PATH\n const binary = await shell`command -v edge-tts`.nothrow().quiet()\n if (binary.exitCode === 0) return DEFAULT_EDGE_TTS_COMMAND\n\n const pythonModule = await shell`python3 -c \"import edge_tts\"`.nothrow().quiet()\n if (pythonModule.exitCode === 0) return [\"python3\", \"-m\", \"edge_tts\"]\n\n return DEFAULT_EDGE_TTS_COMMAND\n}\n\nfunction getTextParts(parts: Part[]) {\n return parts\n .filter((part): part is Extract<Part, { type: \"text\" }> => part.type === \"text\")\n .filter((part) => !part.ignored)\n .map((part) => part.text)\n}\n\nfunction extractPromptParts(value: unknown): Part[] {\n if (!value || typeof value !== \"object\") return []\n\n const record = value as Record<string, unknown>\n if (Array.isArray(record.parts)) return record.parts as Part[]\n if (record.data && typeof record.data === \"object\") {\n const nested = record.data as Record<string, unknown>\n if (Array.isArray(nested.parts)) return nested.parts as Part[]\n }\n return []\n}\n\nfunction describeResponseShape(value: unknown) {\n if (!value || typeof value !== \"object\") return { type: typeof value }\n const record = value as Record<string, unknown>\n return {\n keys: Object.keys(record),\n dataKeys:\n record.data && typeof record.data === \"object\" && !Array.isArray(record.data)\n ? Object.keys(record.data as Record<string, unknown>)\n : undefined,\n hasParts: Array.isArray(record.parts),\n hasDataParts:\n !!record.data && typeof record.data === \"object\" && Array.isArray((record.data as Record<string, unknown>).parts),\n }\n}\n\nfunction serializeUnknown(value: unknown): unknown {\n if (value instanceof Error) {\n return {\n name: value.name,\n message: value.message,\n stack: value.stack,\n }\n }\n\n if (!value || typeof value !== \"object\") return value\n\n const record = value as Record<string, unknown>\n const output: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(record)) {\n if (key === \"request\") continue\n if (key === \"response\" && entry && typeof entry === \"object\") {\n const response = entry as Response\n output.response = {\n status: response.status,\n statusText: response.statusText,\n ok: response.ok,\n url: response.url,\n }\n continue\n }\n output[key] = entry\n }\n return output\n}\n\nfunction fallbackSummaryFromText(text: string) {\n const normalized = sanitizeForSpeech(text)\n const sentences = normalized.match(/[^.!?]+[.!?]+/g) ?? [normalized]\n return normalizeText(sentences.slice(0, MAX_SENTENCES).join(\" \"))\n}\n\nfunction parseModel(model: string): SummaryModel {\n const [providerID, ...rest] = model.split(\"/\")\n return {\n providerID,\n modelID: rest.join(\"/\"),\n }\n}\n\nasync function resolveSummaryModel(\n client: Parameters<Plugin>[0][\"client\"],\n directory: string,\n source?: AssistantMessage[\"model\"],\n): Promise<SummaryModel | undefined> {\n if (SUMMARY_PROVIDER && SUMMARY_MODEL) {\n return {\n providerID: SUMMARY_PROVIDER,\n modelID: SUMMARY_MODEL,\n }\n }\n\n const config = await legacyConfigGet(client, directory)\n .then((result) => unwrapData(result as MaybeData<OpenCodeConfig>))\n .catch(() => undefined)\n\n if (config?.small_model) return parseModel(config.small_model)\n if (config?.model) return parseModel(config.model)\n\n const providerID = source?.providerID\n const modelID = source?.modelID\n if (!providerID || !modelID) return undefined\n return { providerID, modelID }\n}\n\nasync function legacyConfigGet(client: Parameters<Plugin>[0][\"client\"], directory: string) {\n return client.config.get({\n query: { directory },\n throwOnError: false,\n } as any)\n}\n\n\nasync function summarizeText(\n client: Parameters<Plugin>[0][\"client\"],\n directory: string,\n inputText: string,\n sourceModel?: AssistantMessage[\"model\"],\n) {\n logLine(\"summarize.start\", { directory, inputLength: inputText.length, sourceModel })\n\n const summaryModel = await resolveSummaryModel(client, directory, sourceModel)\n logLine(\"summarize.model\", { summaryModel })\n if (!summaryModel) throw new Error(\"no model available for summarization\")\n\n const config = await legacyConfigGet(client, directory)\n .then((result) => unwrapData(result as MaybeData<OpenCodeConfig>))\n .catch(() => undefined)\n\n const providerOptions = config?.provider?.[summaryModel.providerID]?.options\n const baseURL = providerOptions?.baseURL\n const apiKey = providerOptions?.apiKey ?? \"local\"\n if (!baseURL) throw new Error(`no baseURL found for provider ${summaryModel.providerID}`)\n\n const summaryPrompt = [\n `Summarize the following assistant response as spoken audio copy in no more than ${MAX_SENTENCES} short sentences.`,\n \"Keep concrete facts, decisions, and next actions.\",\n \"Do not mention that this is a summary.\",\n \"Paraphrase instead of copying the opening words when possible.\",\n \"Never output chain-of-thought, think tags, reflection tags, or XML-like tags.\",\n \"Do not use bullets, numbering, markdown, or preamble.\",\n \"\",\n sanitizeForSpeech(inputText),\n ].join(\"\\n\")\n\n logLine(\"summarize.direct.request\", { baseURL, modelID: summaryModel.modelID })\n const response = await fetch(`${baseURL}/chat/completions`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model: summaryModel.modelID,\n messages: [\n { role: \"system\", content: \"You are a compression model. Reply with plain text only.\" },\n { role: \"user\", content: summaryPrompt },\n ],\n max_tokens: 200,\n stream: false,\n }),\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => \"\")\n throw new Error(`summarization request failed: ${response.status} ${body}`)\n }\n\n const json = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> }\n const summary = sanitizeForSpeech(json.choices?.[0]?.message?.content ?? \"\")\n logLine(\"summarize.response.text\", { summary })\n if (!summary) throw new Error(\"summary model returned no text\")\n logLine(\"summarize.success\", { summary })\n return summary\n}\n\nasync function runTts(\n shell: Parameters<Plugin>[0][\"$\"],\n text: string,\n voice = DEFAULT_VOICE,\n) {\n const normalized = sanitizeForSpeech(text)\n if (!normalized) return\n\n const config = getPluginConfig()\n if (config.enabled === false) return\n\n const backend = config.backend ?? \"edge_tts\"\n\n if (backend === \"edge_tts\") {\n const command = await resolveEdgeTtsCommand(shell, config.edge_tts?.command)\n const edgeVoice = voice ?? config.edge_tts?.voice ?? DEFAULT_EDGE_TTS_VOICE\n const rate = config.edge_tts?.rate ?? DEFAULT_EDGE_TTS_RATE\n const volume = config.edge_tts?.volume ?? DEFAULT_EDGE_TTS_VOLUME\n const outputPath = path.join(\n os.tmpdir(),\n `opencode-tts-${Date.now()}-${Math.random().toString(36).slice(2)}.mp3`,\n )\n\n try {\n logLine(\"tts.edge_tts.start\", { command, voice: edgeVoice, rate, volume, outputPath })\n const escaped = command.map((item) => shell.escape(item)).join(\" \")\n const runner = shell.nothrow()\n const tts = await runner`${{ raw: `${escaped} --voice ${shell.escape(edgeVoice)} --rate ${shell.escape(rate)} --volume ${shell.escape(volume)} --text ${shell.escape(normalized)} --write-media ${shell.escape(outputPath)}` }}`.quiet()\n if (tts.exitCode !== 0) {\n logLine(\"tts.edge_tts.failed\", { exitCode: tts.exitCode, stderr: tts.stderr.toString() })\n throw new Error(`edge_tts exited with code ${tts.exitCode}`)\n }\n logLine(\"tts.edge_tts.generated\", { outputPath })\n\n if (process.platform === \"darwin\") {\n await shell`/usr/bin/afplay ${outputPath}`.quiet()\n logLine(\"tts.playback.afplay.success\", { outputPath })\n return\n }\n\n const ffplay = await shell`command -v ffplay`.nothrow().quiet()\n if (ffplay.exitCode === 0) {\n await shell`ffplay -nodisp -autoexit -loglevel quiet ${outputPath}`.quiet()\n return\n }\n\n const mpg123 = await shell`command -v mpg123`.nothrow().quiet()\n if (mpg123.exitCode === 0) {\n await shell`mpg123 -q ${outputPath}`.quiet()\n return\n }\n\n throw new Error(\"No audio player found for edge_tts output. Install `afplay`, `ffplay`, or `mpg123`.\")\n } finally {\n try {\n unlinkSync(outputPath)\n } catch {\n // Ignore cleanup failures for temp audio files.\n }\n }\n }\n\n if (process.platform === \"darwin\") {\n if (voice) {\n await shell`/usr/bin/say -v ${voice} ${normalized}`.quiet()\n return\n }\n await shell`/usr/bin/say ${normalized}`.quiet()\n return\n }\n\n const spdSay = await shell`command -v spd-say`.nothrow().quiet()\n if (spdSay.exitCode === 0) {\n await shell`spd-say ${normalized}`.quiet()\n return\n }\n\n const espeak = await shell`command -v espeak`.nothrow().quiet()\n if (espeak.exitCode === 0) {\n await shell`espeak ${normalized}`.quiet()\n return\n }\n\n throw new Error(\"No supported TTS command found. Install `say`, `spd-say`, or `espeak`.\")\n}\n\nasync function summarizeAndSpeak(\n input: Parameters<Plugin>[0],\n text: string,\n sourceModel?: AssistantMessage[\"model\"],\n voice?: string,\n) {\n let summary: string\n try {\n summary = await summarizeText(input.client, input.directory, text, sourceModel)\n } catch (error) {\n logLine(\"summarize.error\", serializeUnknown(error))\n summary = fallbackSummaryFromText(text)\n logLine(\"summarize.fallback\", {\n reason: error instanceof Error ? error.message : String(error),\n summary,\n })\n }\n await runTts(input.$, summary, voice)\n return summary\n}\n\nexport const OpenCodeTTSPlugin: Plugin = async (input) => {\n setPluginConfig(loadPluginConfigFromDisk())\n\n return {\n event: async ({ event }) => {\n logLine(\"event.received\", { type: event.type })\n\n if (event.type === \"message.updated\") {\n const info = event.properties.info as Message\n if (info.role === \"assistant\") {\n latestAssistantMessageBySession.set(info.sessionID, {\n messageID: info.id,\n model: info.model,\n })\n logLine(\"message.updated.assistant\", { sessionID: info.sessionID, messageID: info.id })\n }\n }\n\n if (event.type === \"message.part.updated\") {\n const part = event.properties.part\n if (part.type === \"text\") {\n assistantTextByMessage.set(part.messageID, sanitizeForSpeech(part.text))\n logLine(\"message.part.updated.text\", { messageID: part.messageID, length: part.text.length })\n }\n }\n\n if (!AUTO_ENABLED) return\n if (event.type !== \"session.idle\") return\n\n const sessionID = event.properties.sessionID\n logLine(\"event.session.idle\", { sessionID, inputDirectory: input.directory })\n if (inFlightSessions.has(sessionID)) return\n\n inFlightSessions.add(sessionID)\n\n try {\n const latest = latestAssistantMessageBySession.get(sessionID)\n if (!latest) {\n logLine(\"event.session.idle.no-latest-message\", { sessionID })\n return\n }\n if (spokenAssistantMessages.has(latest.messageID)) return\n\n const text = sanitizeForSpeech(assistantTextByMessage.get(latest.messageID) ?? \"\")\n logLine(\"event.session.idle.cached-text\", { sessionID, messageID: latest.messageID, textLength: text.length })\n if (!text) return\n\n const summary = await summarizeAndSpeak(input, text, latest.model)\n spokenAssistantMessages.add(latest.messageID)\n\n if (getPluginConfig().debug) {\n console.log(`[opencode-tts] ${summary}`)\n }\n logLine(\"event.session.idle.spoken\", { sessionID, messageID: latest.messageID, summary })\n } catch (error) {\n console.error(\"[opencode-tts] failed to summarize and speak\", error)\n logLine(\"event.session.idle.error\", {\n sessionID,\n error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),\n })\n } finally {\n inFlightSessions.delete(sessionID)\n }\n },\n }\n}\n\nexport default OpenCodeTTSPlugin\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "opencode-tts",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that speaks short AI summaries instead of full responses",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "node build.mjs",
15
+ "prepare": "node build.mjs",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "plugin",
21
+ "tts",
22
+ "text-to-speech",
23
+ "summary"
24
+ ],
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@opencode-ai/plugin": "^1.2.27",
28
+ "esbuild": "^0.25.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.13.9",
32
+ "typescript": "^5.8.2"
33
+ }
34
+ }