@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.
- package/LICENSE +21 -0
- package/README.md +729 -0
- package/index.js +124 -0
- package/lib/audio-chunker.js +231 -0
- package/lib/audio-enhance.js +172 -0
- package/lib/conversation.js +528 -0
- package/lib/live-notes.js +622 -0
- package/lib/llm-client.js +304 -0
- package/lib/logger.js +18 -0
- package/lib/notes-writer.js +224 -0
- package/lib/session.js +102 -0
- package/lib/streaming-editor.js +308 -0
- package/lib/streaming-stt.js +1322 -0
- package/lib/streaming-transcript.js +236 -0
- package/lib/stt.js +2339 -0
- package/lib/tts.js +671 -0
- package/lib/voice-model.js +122 -0
- package/lib/whisper-server.js +471 -0
- package/package.json +47 -0
|
@@ -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
|
+
}
|