@bojackduy/opencode-voice 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.
@@ -0,0 +1,304 @@
1
+ // OpenAI-compatible LLM client for text normalization.
2
+ //
3
+ // Works with any OpenAI-compatible endpoint:
4
+ // - Anthropic's OpenAI compatibility layer
5
+ // - OpenAI directly
6
+ // - Ollama, vLLM, LM Studio, etc.
7
+ //
8
+ // Configuration is passed from plugin options (tui.json):
9
+ // ["@bojackduy/opencode-voice", {
10
+ // "endpoint": "https://api.anthropic.com/v1",
11
+ // "model": "claude-haiku-4-5",
12
+ // "apiKeyEnv": "ANTHROPIC_API_KEY",
13
+ // "maxTokens": 2048,
14
+ // "reasoningEffort": "low",
15
+ // "chatTemplateKwargs": {"enable_thinking": false},
16
+ // "retries": 2
17
+ // }]
18
+
19
+ const DEFAULTS = {
20
+ maxTokens: 2048,
21
+ reasoningEffort: null,
22
+ chatTemplateKwargs: null,
23
+ retries: 2,
24
+ temperature: null,
25
+ };
26
+
27
+ function isResponsesEndpoint(endpoint, explicitFlag) {
28
+ if (explicitFlag === true) return true;
29
+ if (explicitFlag === false) return false;
30
+ const trimmed = (endpoint || "").replace(/\/+$/, "");
31
+ return trimmed.endsWith("/responses");
32
+ }
33
+
34
+ function isChatCompletionsEndpoint(endpoint) {
35
+ const trimmed = (endpoint || "").replace(/\/+$/, "");
36
+ return trimmed.endsWith("/chat/completions");
37
+ }
38
+
39
+ function resolveEndpoint(raw, useResponses) {
40
+ const trimmed = (raw || "").replace(/\/+$/, "");
41
+ if (!trimmed) return trimmed;
42
+ if (
43
+ isChatCompletionsEndpoint(trimmed) ||
44
+ (isResponsesEndpoint(trimmed, useResponses === true ? true : null) &&
45
+ trimmed.endsWith("/responses"))
46
+ ) {
47
+ // already a full path — use as-is
48
+ if (trimmed.endsWith("/chat/completions") || trimmed.endsWith("/responses")) return trimmed;
49
+ }
50
+ if (isResponsesEndpoint(raw, useResponses)) return `${trimmed}/responses`;
51
+ return `${trimmed}/chat/completions`;
52
+ }
53
+
54
+ function extractResponsesText(data) {
55
+ if (!data) return null;
56
+ if (typeof data.output_text === "string" && data.output_text.trim()) return data.output_text;
57
+ // output: [{ type:"message", content:[{type:"output_text", text:"..."}] }]
58
+ const out = data.output;
59
+ if (Array.isArray(out)) {
60
+ for (const item of out) {
61
+ const content = item?.content;
62
+ if (Array.isArray(content)) {
63
+ for (const c of content) {
64
+ if (typeof c?.text === "string" && c.text.trim()) return c.text;
65
+ if (typeof c?.output_text === "string" && c.output_text.trim()) return c.output_text;
66
+ }
67
+ } else if (typeof content?.text === "string" && content.text.trim()) {
68
+ return content.text;
69
+ }
70
+ }
71
+ }
72
+ // some gateways return choices like chat
73
+ return data?.choices?.[0]?.message?.content || null;
74
+ }
75
+
76
+ function normalizeRetries(value) {
77
+ const parsed = Number(value);
78
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULTS.retries;
79
+ return Math.floor(parsed);
80
+ }
81
+
82
+ /**
83
+ * Resolve an OpenCode provider + model (as picked via /voice-model) into
84
+ * OpenAI-compatible call config. Explicit plugin options always win; this is
85
+ * only the fallback when endpoint/model are not configured.
86
+ *
87
+ * Auth caveat: only env-key providers work (key read live from provider.env).
88
+ * OAuth/Console-managed credentials are not visible to plugins.
89
+ */
90
+ export function resolveProviderConfig(provider, model) {
91
+ if (!provider || !model) return null;
92
+ const options = provider.options || {};
93
+ const endpoint =
94
+ (typeof options.baseURL === "string" && options.baseURL) ||
95
+ (typeof options.endpoint === "string" && options.endpoint) ||
96
+ model?.api?.url ||
97
+ null;
98
+ const envVars = Array.isArray(provider.env) ? provider.env : [];
99
+ // Usability is ANY exported key, but requests must use the key that is
100
+ // actually exported — envVars[0] alone yields unauthenticated requests
101
+ // when the user exported the second alt name instead.
102
+ const exported = envVars.find((name) => name && process.env[name]);
103
+ return {
104
+ endpoint,
105
+ model: model.id,
106
+ apiKeyEnv: exported || envVars[0] || null,
107
+ };
108
+ }
109
+
110
+ function normalizeChatTemplateKwargs(value) {
111
+ if (!value) return null;
112
+ if (typeof value === "object") return value;
113
+ try {
114
+ const parsed = JSON.parse(value);
115
+ return typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function shouldRetry(status) {
122
+ return status === 408 || status === 429 || status >= 500;
123
+ }
124
+
125
+ function wait(ms) {
126
+ return new Promise((resolve) => setTimeout(resolve, ms));
127
+ }
128
+
129
+ /**
130
+ * Create an LLM completion function.
131
+ *
132
+ * @param {object} [pluginOptions] - Static config from tui.json plugin options
133
+ * @param {{ log?: (scope: string, message: string, level?: string) => void }} [logger]
134
+ * @param {() => string | undefined} [getSessionID] - Resolver for the live opencode
135
+ * session ID. Sent as `x-opencode-session` so session-scoped gateways
136
+ * (e.g. opencode.ai/zen/go) can route the request. Omit when the endpoint
137
+ * needs no session binding.
138
+ * @param {() => ({ provider: object, model: object } | null)} [getProviderModel] -
139
+ * Resolver for the voice-selected OpenCode provider + model (see
140
+ * /voice-model). Used only when endpoint/model are not set explicitly.
141
+ * @returns {{ complete: (opts: { system?: string, prompt: string, config?: object }) => Promise<{ text: string | null, error?: string }> }}
142
+ */
143
+ export function createClient(pluginOptions, logger, getSessionID, getProviderModel) {
144
+ function getConfig() {
145
+ const rawUseResponses =
146
+ pluginOptions?.useResponsesApi ??
147
+ pluginOptions?.responsesApi ??
148
+ pluginOptions?.useResponses ??
149
+ null;
150
+ // allow explicit apiMode: "responses" | "chat"
151
+ const modeFlag =
152
+ pluginOptions?.apiMode === "responses"
153
+ ? true
154
+ : pluginOptions?.apiMode === "chat"
155
+ ? false
156
+ : rawUseResponses;
157
+ // Voice-selected OpenCode provider (via /voice-model) fills whatever the
158
+ // explicit options leave out. Explicit endpoint/model/apiKeyEnv win.
159
+ let providerResolved = null;
160
+ try {
161
+ const selection = typeof getProviderModel === "function" ? getProviderModel() : null;
162
+ providerResolved = resolveProviderConfig(selection?.provider, selection?.model);
163
+ } catch {
164
+ providerResolved = null;
165
+ }
166
+ return {
167
+ endpoint: pluginOptions?.endpoint ?? providerResolved?.endpoint,
168
+ model: pluginOptions?.model ?? providerResolved?.model,
169
+ apiKeyEnv: pluginOptions?.apiKeyEnv ?? providerResolved?.apiKeyEnv,
170
+ maxTokens: pluginOptions?.maxTokens ?? DEFAULTS.maxTokens,
171
+ reasoningEffort: pluginOptions?.reasoningEffort ?? DEFAULTS.reasoningEffort,
172
+ chatTemplateKwargs: normalizeChatTemplateKwargs(
173
+ pluginOptions?.chatTemplateKwargs ?? DEFAULTS.chatTemplateKwargs,
174
+ ),
175
+ retries: normalizeRetries(pluginOptions?.retries ?? DEFAULTS.retries),
176
+ temperature:
177
+ pluginOptions?.temperature != null
178
+ ? Number(pluginOptions.temperature)
179
+ : DEFAULTS.temperature,
180
+ useResponsesApi: modeFlag,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Send a chat completion request to an OpenAI-compatible endpoint.
186
+ *
187
+ * @param {object} opts
188
+ * @param {string} [opts.system] - System prompt
189
+ * @param {string} opts.prompt - User message
190
+ * @param {object} [opts.config] - Per-call overrides (e.g. { maxTokens: 4096 })
191
+ * @returns {Promise<{ text: string | null, error?: string }>}
192
+ */
193
+ async function complete({ system, prompt, config: overrides }) {
194
+ const cfg = { ...getConfig(), ...overrides };
195
+ if (!cfg.endpoint) {
196
+ logger?.log?.("LLM", "completion skipped: endpoint not configured", "warn");
197
+ return { text: null, error: "LLM endpoint not configured" };
198
+ }
199
+ if (!cfg.model) {
200
+ logger?.log?.("LLM", "completion skipped: model not configured", "warn");
201
+ return { text: null, error: "LLM model not configured" };
202
+ }
203
+ const apiKey = cfg.apiKeyEnv ? process.env[cfg.apiKeyEnv] : null;
204
+ // Per-call override wins, otherwise ask the live session resolver.
205
+ // The zen/go gateway rejects requests without x-opencode-session (401/400).
206
+ const sessionID =
207
+ cfg.sessionID ?? (typeof getSessionID === "function" ? getSessionID() : null) ?? null;
208
+
209
+ const endpoint = resolveEndpoint(cfg.endpoint, cfg.useResponsesApi);
210
+ const useResponses = endpoint.endsWith("/responses");
211
+
212
+ let body;
213
+ if (useResponses) {
214
+ body = {
215
+ model: cfg.model,
216
+ input: prompt,
217
+ instructions: system || undefined,
218
+ max_output_tokens: cfg.maxTokens,
219
+ };
220
+ if (cfg.temperature != null && Number.isFinite(cfg.temperature))
221
+ body.temperature = cfg.temperature;
222
+ if (cfg.reasoningEffort) body.reasoning = { effort: cfg.reasoningEffort };
223
+ // chat_template_kwargs not standard for responses, but pass through if needed
224
+ if (cfg.chatTemplateKwargs) body.chat_template_kwargs = cfg.chatTemplateKwargs;
225
+ } else {
226
+ const messages = [];
227
+ if (system) messages.push({ role: "system", content: system });
228
+ messages.push({ role: "user", content: prompt });
229
+ body = {
230
+ model: cfg.model,
231
+ max_tokens: cfg.maxTokens,
232
+ messages,
233
+ };
234
+ if (cfg.temperature != null && Number.isFinite(cfg.temperature))
235
+ body.temperature = cfg.temperature;
236
+ if (cfg.reasoningEffort) body.reasoning_effort = cfg.reasoningEffort;
237
+ if (cfg.chatTemplateKwargs) body.chat_template_kwargs = cfg.chatTemplateKwargs;
238
+ }
239
+
240
+ for (let attempt = 0; attempt <= cfg.retries; attempt++) {
241
+ try {
242
+ logger?.log?.(
243
+ "LLM",
244
+ `Completion request attempt=${attempt + 1} model=${cfg.model} maxTokens=${cfg.maxTokens} promptChars=${prompt.length}`,
245
+ "debug",
246
+ );
247
+ const response = await fetch(endpoint, {
248
+ method: "POST",
249
+ headers: {
250
+ "Content-Type": "application/json",
251
+ ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}),
252
+ ...(sessionID ? { "x-opencode-session": sessionID } : {}),
253
+ },
254
+ body: JSON.stringify(body),
255
+ });
256
+
257
+ if (!response.ok) {
258
+ logger?.log?.(
259
+ "LLM",
260
+ `Completion response status=${response.status}`,
261
+ shouldRetry(response.status) ? "warn" : "error",
262
+ );
263
+ if (attempt < cfg.retries && shouldRetry(response.status)) {
264
+ await wait(250 * 2 ** attempt);
265
+ continue;
266
+ }
267
+ return { text: null, error: `LLM request failed (${response.status})` };
268
+ }
269
+
270
+ const data = await response.json();
271
+ const text = useResponses
272
+ ? extractResponsesText(data)
273
+ : data?.choices?.[0]?.message?.content || null;
274
+ if (text) {
275
+ logger?.log?.(
276
+ "LLM",
277
+ `Completion succeeded chars=${text.length} mode=${useResponses ? "responses" : "chat"}`,
278
+ "debug",
279
+ );
280
+ return { text };
281
+ }
282
+
283
+ logger?.log?.("LLM", "Completion returned empty content", "warn");
284
+
285
+ if (attempt < cfg.retries) {
286
+ await wait(250 * 2 ** attempt);
287
+ continue;
288
+ }
289
+ return { text: null, error: "Empty LLM response" };
290
+ } catch (err) {
291
+ logger?.log?.("LLM", `Completion error attempt=${attempt + 1}: ${err.message}`, "warn");
292
+ if (attempt < cfg.retries) {
293
+ await wait(250 * 2 ** attempt);
294
+ continue;
295
+ }
296
+ return { text: null, error: `LLM error: ${err.message}` };
297
+ }
298
+ }
299
+
300
+ return { text: null, error: "LLM request failed after retries" };
301
+ }
302
+
303
+ return { complete };
304
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,18 @@
1
+ export function createLogger(client) {
2
+ async function log(scope, message, level = "debug") {
3
+ try {
4
+ await client?.app?.log?.({
5
+ body: {
6
+ service: "opencode-voice",
7
+ level,
8
+ message,
9
+ extra: { scope },
10
+ },
11
+ });
12
+ } catch {
13
+ // Logging should never interrupt voice features.
14
+ }
15
+ }
16
+
17
+ return { log };
18
+ }
@@ -0,0 +1,224 @@
1
+ // Live-notes output: ordered Markdown transcript + lossless JSONL sidecar.
2
+ //
3
+ // Chunks are transcribed and normalized independently and may resolve out of
4
+ // order (a slow LLM normalize call on chunk N can finish after a fast one on
5
+ // chunk N+1). The writer buffers by sequence number and only appends once
6
+ // every earlier chunk has been written, so the files are always in
7
+ // recording order regardless of processing order.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export function formatClockTime(ms) {
13
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
14
+ const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
15
+ const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
16
+ const s = String(totalSec % 60).padStart(2, "0");
17
+ return `${h}:${m}:${s}`;
18
+ }
19
+
20
+ function normalizeWord(w) {
21
+ return w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
22
+ }
23
+
24
+ /**
25
+ * Forced chunk splits carry ~overlapMs of duplicate audio into the next
26
+ * chunk, so the transcribed text of consecutive chunks can share a few
27
+ * words at the boundary. Drop the longest run (up to maxWords) of leading
28
+ * words in `nextText` that exactly matches the trailing words of
29
+ * `prevText`, so the merged transcript reads once, not twice.
30
+ */
31
+ export function mergeOverlapText(prevText, nextText, options = {}) {
32
+ const maxWords = options.maxWords ?? 8;
33
+ const prevWords = (prevText || "").trim().split(/\s+/).filter(Boolean);
34
+ const nextWords = (nextText || "").trim().split(/\s+/).filter(Boolean);
35
+ if (prevWords.length === 0 || nextWords.length === 0) return nextText || "";
36
+
37
+ const limit = Math.min(maxWords, prevWords.length, nextWords.length);
38
+ let bestK = 0;
39
+ for (let k = limit; k >= 1; k--) {
40
+ const prevTail = prevWords.slice(-k).map(normalizeWord).join(" ");
41
+ const nextHead = nextWords.slice(0, k).map(normalizeWord).join(" ");
42
+ if (prevTail && prevTail === nextHead) {
43
+ bestK = k;
44
+ break;
45
+ }
46
+ }
47
+ if (bestK === 0) return nextText || "";
48
+ return nextWords.slice(bestK).join(" ");
49
+ }
50
+
51
+ export function buildMarkdownHeader({ startedAt, language, model }) {
52
+ const lines = [
53
+ "# Live notes",
54
+ "",
55
+ `Started: ${startedAt}`,
56
+ `Language: ${language || "auto"}`,
57
+ `Model: ${model || "unknown"}`,
58
+ "",
59
+ "## Transcript",
60
+ "",
61
+ ];
62
+ return lines.join("\n");
63
+ }
64
+
65
+ export function buildMarkdownEntry({ startMs, text }) {
66
+ if (!text) return "";
67
+ return `[${formatClockTime(startMs)}] ${text}\n\n`;
68
+ }
69
+
70
+ export function buildJsonlLine(record) {
71
+ return JSON.stringify(record) + "\n";
72
+ }
73
+
74
+ function slugifyBaseName(name) {
75
+ return String(name || "notes")
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "")
80
+ .slice(0, 60);
81
+ }
82
+
83
+ /**
84
+ * Default session file base name: 2026-09-24-143005-notes (title optional).
85
+ * Second resolution plus a per-process disambiguator so two sessions started
86
+ * in the same minute (or the same second across restarts) never share one
87
+ * append-only pair and mix their streams.
88
+ */
89
+ export function buildSessionBaseName(startedAt, title) {
90
+ const d = startedAt instanceof Date ? startedAt : new Date(startedAt);
91
+ const pad = (n) => String(n).padStart(2, "0");
92
+ const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
93
+ const suffix = title ? slugifyBaseName(title) : "notes";
94
+ return `${stamp}-${suffix}`;
95
+ }
96
+
97
+ /**
98
+ * Resolve a collision-free basename: when a previous session already owns
99
+ * `base` (same second, restarted process, or clock skew), append -2, -3, …
100
+ * Keeps the documented one-pair-per-session invariant.
101
+ */
102
+ export function resolveUniqueBaseName(dir, baseName) {
103
+ let candidate = baseName;
104
+ let n = 1;
105
+ while (
106
+ fs.existsSync(path.join(dir, `${candidate}.md`)) ||
107
+ fs.existsSync(path.join(dir, `${candidate}.raw.jsonl`))
108
+ ) {
109
+ n += 1;
110
+ candidate = `${baseName}-${n}`;
111
+ }
112
+ return candidate;
113
+ }
114
+
115
+ /**
116
+ * Create a live-notes writer bound to one recording session. `dir` is
117
+ * created if missing. Returns paths after `close()`.
118
+ */
119
+ export function createNotesWriter({ dir, baseName, startedAt, language, model }) {
120
+ fs.mkdirSync(dir, { recursive: true });
121
+ // Atomically claim a collision-free basename: exclusive-create both files
122
+ // synchronously, so two sessions racing in the same tick cannot both pass
123
+ // an existsSync check and interleave appends into one pair.
124
+ let uniqueBase = baseName;
125
+ let mdPath;
126
+ let jsonlPath;
127
+ for (let n = 1; ; n += 1) {
128
+ uniqueBase = n === 1 ? baseName : `${baseName}-${n}`;
129
+ mdPath = path.join(dir, `${uniqueBase}.md`);
130
+ jsonlPath = path.join(dir, `${uniqueBase}.raw.jsonl`);
131
+ try {
132
+ fs.writeFileSync(mdPath, "", { flag: "wx" });
133
+ fs.writeFileSync(jsonlPath, "", { flag: "wx" });
134
+ break;
135
+ } catch (err) {
136
+ if (err?.code !== "EEXIST") throw err;
137
+ // We may have created mdPath just now while the jsonl name is taken
138
+ // (stale file from an older version): remove our half-claim so no
139
+ // orphan accumulates, then try the next suffix.
140
+ try {
141
+ if (err?.path === jsonlPath) fs.unlinkSync(mdPath);
142
+ } catch {}
143
+ }
144
+ }
145
+
146
+ const mdStream = fs.createWriteStream(mdPath, { flags: "a" });
147
+ const jsonlStream = fs.createWriteStream(jsonlPath, { flags: "a" });
148
+ mdStream.write(buildMarkdownHeader({ startedAt, language, model }));
149
+
150
+ let nextSeq = 0;
151
+ const pending = new Map();
152
+ let lastWrittenText = "";
153
+ let lastWrittenEndMs = 0;
154
+ let entriesWritten = 0;
155
+
156
+ function writeOne(record) {
157
+ jsonlStream.write(buildJsonlLine(record));
158
+ lastWrittenEndMs = record.endMs ?? lastWrittenEndMs;
159
+ // Skipped chunks (silence / hallucination / repeats) still advance the
160
+ // sequence in the JSONL sidecar, but must never reach the readable
161
+ // transcript - their `raw` is noise by definition.
162
+ if (record.skipped) return;
163
+ const text = record.normalized || record.raw || "";
164
+ if (!text) return;
165
+ const merged = record.forced ? mergeOverlapText(lastWrittenText, text) : text;
166
+ if (!merged.trim()) return;
167
+ mdStream.write(buildMarkdownEntry({ startMs: record.startMs, text: merged }));
168
+ lastWrittenText = merged;
169
+ entriesWritten += 1;
170
+ }
171
+
172
+ /**
173
+ * Append a processed chunk. Written immediately if it is the next chunk in
174
+ * sequence; otherwise held until earlier chunks arrive.
175
+ */
176
+ function appendChunk(record) {
177
+ pending.set(record.seq, record);
178
+ while (pending.has(nextSeq)) {
179
+ writeOne(pending.get(nextSeq));
180
+ pending.delete(nextSeq);
181
+ nextSeq += 1;
182
+ }
183
+ }
184
+
185
+ function pendingCount() {
186
+ return pending.size;
187
+ }
188
+
189
+ function entryCount() {
190
+ return entriesWritten;
191
+ }
192
+
193
+ function lastText() {
194
+ return lastWrittenText;
195
+ }
196
+
197
+ function lastWrittenEndMsGetter() {
198
+ return lastWrittenEndMs;
199
+ }
200
+
201
+ async function close() {
202
+ // Flush anything still buffered (out-of-order arrivals that never got
203
+ // their missing predecessor - e.g. a chunk whose transcription errored
204
+ // silently) in sequence-number order, so nothing is silently dropped.
205
+ for (const seq of [...pending.keys()].sort((a, b) => a - b)) {
206
+ writeOne(pending.get(seq));
207
+ pending.delete(seq);
208
+ }
209
+ await new Promise((resolve) => mdStream.end(resolve));
210
+ await new Promise((resolve) => jsonlStream.end(resolve));
211
+ return { mdPath, jsonlPath };
212
+ }
213
+
214
+ return {
215
+ appendChunk,
216
+ pendingCount,
217
+ entryCount,
218
+ lastText,
219
+ lastWrittenEndMs: lastWrittenEndMsGetter,
220
+ close,
221
+ mdPath,
222
+ jsonlPath,
223
+ };
224
+ }
package/lib/session.js ADDED
@@ -0,0 +1,102 @@
1
+ // Shared session helpers for OpenCode TUI plugin.
2
+
3
+ /**
4
+ * Get the title of a specific session by ID. Returns "" if unknown or on error.
5
+ */
6
+ export async function getSessionTitle(client, sessionID) {
7
+ if (!sessionID) return "";
8
+ try {
9
+ const result = await client.session.list();
10
+ const session = result.data?.find((s) => s.id === sessionID);
11
+ return session?.title || "";
12
+ } catch {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Get the title of the most recently updated session. Returns "" on error or
19
+ * when there are no sessions.
20
+ */
21
+ export async function getActiveSessionTitle(client) {
22
+ try {
23
+ const result = await client.session.list();
24
+ if (!result.data || result.data.length === 0) return "";
25
+ const active = result.data.sort((a, b) => b.time.updated - a.time.updated)[0];
26
+ return active?.title || "";
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ async function resolveSessionID(client, api) {
33
+ const route = api?.route?.current;
34
+ if (route?.name === "session" && route?.params?.sessionID) {
35
+ return route.params.sessionID;
36
+ }
37
+ try {
38
+ const result = await client.session.list();
39
+ if (!result.data || result.data.length === 0) return null;
40
+ const active = result.data.sort((a, b) => b.time.updated - a.time.updated)[0];
41
+ return active?.id || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function messageText(api, msg) {
48
+ try {
49
+ const parts = api?.state?.part?.(msg.id) || [];
50
+ const text = parts
51
+ .filter((p) => p?.type === "text" && p?.text)
52
+ .map((p) => p.text.trim())
53
+ .filter(Boolean)
54
+ .join("\n");
55
+ if (text) return text;
56
+ } catch {}
57
+ // Compact sessions may only carry summaries on the message itself.
58
+ const summary = msg?.summary
59
+ ? [msg.summary.title, msg.summary.body].filter(Boolean).join(" — ")
60
+ : "";
61
+ return summary || "";
62
+ }
63
+
64
+ /**
65
+ * Get a compact, bounded slice of recent conversation turns for LLM context
66
+ * (e.g. STT normalization that must resolve names/pronouns like Cristina).
67
+ *
68
+ * Returns "user: ...\nassistant: ..." lines, newest last, capped at
69
+ * maxMessages turns and maxChars total (tail kept on overflow). Returns ""
70
+ * whenever context is unavailable - callers must treat that as "no context"
71
+ * and proceed without it.
72
+ */
73
+ export async function getRecentConversationContext(client, api, options = {}) {
74
+ const maxMessages = Number(options.maxMessages) > 0 ? Math.floor(Number(options.maxMessages)) : 8;
75
+ const maxChars = Number(options.maxChars) > 0 ? Math.floor(Number(options.maxChars)) : 3000;
76
+ const PER_MESSAGE_CHARS = 800;
77
+ try {
78
+ const sessionID = await resolveSessionID(client, api);
79
+ if (!sessionID) return "";
80
+ const messages = api?.state?.session?.messages?.(sessionID) || [];
81
+ if (!Array.isArray(messages) || messages.length === 0) return "";
82
+ const lines = [];
83
+ for (const msg of messages.slice(-maxMessages)) {
84
+ const role = msg?.role === "assistant" ? "assistant" : "user";
85
+ let text = messageText(api, msg).trim().replace(/\s+/g, " ");
86
+ if (!text) continue;
87
+ if (text.length > PER_MESSAGE_CHARS) text = text.slice(0, PER_MESSAGE_CHARS) + "…";
88
+ lines.push(`${role}: ${text}`);
89
+ }
90
+ if (lines.length === 0) return "";
91
+ let joined = lines.join("\n");
92
+ if (joined.length > maxChars) {
93
+ let cut = joined.slice(joined.length - maxChars);
94
+ const newline = cut.indexOf("\n");
95
+ if (newline !== -1) cut = cut.slice(newline + 1);
96
+ joined = cut;
97
+ }
98
+ return joined;
99
+ } catch {
100
+ return "";
101
+ }
102
+ }