@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.2

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +3610 -3566
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/helpers.d.ts +2 -0
  5. package/dist/types/irc/bus.d.ts +5 -4
  6. package/dist/types/session/agent-session.d.ts +13 -13
  7. package/dist/types/task/discovery.d.ts +5 -2
  8. package/dist/types/task/renderer.d.ts +1 -0
  9. package/dist/types/tools/ast-grep.d.ts +4 -2
  10. package/dist/types/tools/browser/render.d.ts +2 -0
  11. package/dist/types/tools/debug.d.ts +1 -0
  12. package/dist/types/tools/eval-render.d.ts +2 -0
  13. package/dist/types/tools/glob.d.ts +4 -2
  14. package/dist/types/tools/grep.d.ts +4 -3
  15. package/dist/types/tools/path-utils.d.ts +7 -0
  16. package/dist/types/tools/renderers.d.ts +11 -0
  17. package/dist/types/tools/ssh.d.ts +2 -1
  18. package/dist/types/tts/index.d.ts +2 -0
  19. package/dist/types/tts/speakable.d.ts +47 -0
  20. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  21. package/dist/types/tts/streaming-player.d.ts +1 -2
  22. package/dist/types/tts/tts-client.d.ts +11 -10
  23. package/dist/types/tts/tts-protocol.d.ts +7 -0
  24. package/dist/types/tts/vocalizer.d.ts +15 -8
  25. package/dist/types/utils/git.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/src/advisor/__tests__/advisor.test.ts +1 -1
  28. package/src/cli/gallery-fixtures/fs.ts +2 -2
  29. package/src/cli/gallery-fixtures/search.ts +2 -2
  30. package/src/cli.ts +27 -5
  31. package/src/config/model-resolver.ts +31 -10
  32. package/src/config/settings-schema.ts +11 -0
  33. package/src/cursor.ts +1 -1
  34. package/src/discovery/helpers.ts +8 -0
  35. package/src/export/html/tool-views.generated.js +34 -34
  36. package/src/extensibility/custom-tools/loader.ts +3 -3
  37. package/src/extensibility/extensions/loader.ts +10 -3
  38. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  39. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  40. package/src/extensibility/plugins/loader.ts +30 -1
  41. package/src/irc/bus.ts +5 -4
  42. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  43. package/src/modes/components/assistant-message.ts +1 -1
  44. package/src/modes/components/move-overlay.ts +35 -23
  45. package/src/modes/components/tool-execution.ts +45 -12
  46. package/src/modes/components/tree-selector.ts +10 -3
  47. package/src/modes/controllers/event-controller.ts +16 -0
  48. package/src/prompts/goals/goal-mode-context.md +4 -0
  49. package/src/prompts/goals/goal-todo-context.md +10 -2
  50. package/src/prompts/system/speech-rewrite.md +15 -0
  51. package/src/prompts/system/tiny-title-system.md +1 -1
  52. package/src/prompts/system/title-system-marker.md +2 -1
  53. package/src/prompts/system/title-system.md +2 -1
  54. package/src/prompts/tools/ast-grep.md +3 -3
  55. package/src/prompts/tools/glob.md +1 -1
  56. package/src/prompts/tools/grep.md +1 -1
  57. package/src/sdk.ts +8 -5
  58. package/src/session/agent-session.ts +163 -49
  59. package/src/session/session-history-format.ts +6 -2
  60. package/src/task/discovery.ts +25 -2
  61. package/src/task/executor.ts +24 -4
  62. package/src/task/render.ts +26 -12
  63. package/src/task/renderer.ts +1 -0
  64. package/src/task/worktree.ts +144 -16
  65. package/src/tiny/text.ts +109 -17
  66. package/src/tools/ast-grep.ts +20 -17
  67. package/src/tools/bash.ts +16 -8
  68. package/src/tools/browser/render.ts +2 -0
  69. package/src/tools/debug.ts +1 -0
  70. package/src/tools/eval-render.ts +5 -2
  71. package/src/tools/gh-renderer.ts +3 -0
  72. package/src/tools/glob.ts +24 -20
  73. package/src/tools/grep.ts +18 -36
  74. package/src/tools/path-utils.ts +55 -10
  75. package/src/tools/read.ts +9 -2
  76. package/src/tools/renderers.ts +11 -0
  77. package/src/tools/ssh.ts +17 -7
  78. package/src/tts/index.ts +2 -0
  79. package/src/tts/speakable.ts +382 -0
  80. package/src/tts/speech-enhancer.ts +204 -0
  81. package/src/tts/streaming-player.ts +71 -16
  82. package/src/tts/tts-client.ts +11 -10
  83. package/src/tts/tts-protocol.ts +14 -5
  84. package/src/tts/tts-worker.ts +52 -49
  85. package/src/tts/vocalizer.ts +277 -46
  86. package/src/utils/git.ts +8 -0
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Streaming markdown → speakable-segment transform for assistant speech.
3
+ *
4
+ * Sits between the assistant's raw streaming text deltas and the TTS engine,
5
+ * deciding both *what* is worth speaking and *when* a piece of text is ready
6
+ * to synthesize. Three passes:
7
+ *
8
+ * 1. Block pass (per character, stateful): drops fenced code blocks and table
9
+ * rows, strips heading/bullet/blockquote markers (numbered-list markers are
10
+ * spoken as "1, …"), and turns newlines into hard segment breaks.
11
+ * 2. Segmentation (stateful): emits a segment the moment a sentence boundary
12
+ * appears — no next-sentence confirmation, which is what made the previous
13
+ * engine-side splitter stall a full sentence behind generation. The first
14
+ * segment cuts early at a clause boundary for fast time-to-first-audio, and
15
+ * over-long unpunctuated runs are force-split so no segment exceeds the
16
+ * synthesizer's input budget.
17
+ * 3. Inline normalization (per segment): markdown links speak their label,
18
+ * bare URLs speak their host, inline-code ticks and emphasis markers are
19
+ * stripped, multi-directory file paths collapse to their basename, HTML
20
+ * tags are dropped, and whitespace is collapsed. Segments with no letters
21
+ * or digits left are not spoken at all.
22
+ *
23
+ * Pure and synchronous — the vocalizer owns timers (idle flush) and the
24
+ * session lifecycle, so this class stays trivially unit-testable.
25
+ */
26
+
27
+ /** Minimum length before the very first segment may cut at a sentence boundary. */
28
+ const FIRST_SEGMENT_MIN = 12;
29
+ /** Buffer length past which the first segment may cut at a clause boundary instead. */
30
+ const FIRST_CLAUSE_MIN = 40;
31
+ /** Hard cap for the first segment: force a word cut for fast time-to-first-audio. */
32
+ const FIRST_FORCED_MAX = 140;
33
+ /** Minimum segment length once speech has started (merges stubby sentences). */
34
+ const MIN_SEGMENT = 24;
35
+ /**
36
+ * Mid-stream soft cut: once this much unpunctuated text is buffered, split at
37
+ * a clause boundary instead of waiting for the sentence to end. Long sentences
38
+ * synthesize as clause-sized pieces, keeping the playback pipeline fed (a
39
+ * 280-char segment costs ~6s of synthesis — enough to drain the player dry).
40
+ */
41
+ const SOFT_CLAUSE_LEN = 160;
42
+ /**
43
+ * Hard cap per segment. Kokoro's `generate()` truncates past ~510 phoneme
44
+ * tokens rather than splitting, so every emission path must stay well under it.
45
+ */
46
+ const MAX_SEGMENT = 280;
47
+
48
+ /** Sentence-ending punctuation, optional closers, then whitespace. */
49
+ const SENTENCE_BOUNDARY_RE = /[.!?…]+[)\]"'»”’]*\s/g;
50
+ /** Clause punctuation followed by whitespace — early-cut and force-split points. */
51
+ const CLAUSE_BOUNDARY_RE = /[,;:—–]\s/g;
52
+ /** Abbreviations whose trailing dot is not a sentence boundary. */
53
+ const ABBREVIATION_RE = /(?:^|\s)(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|St|No)\.$/i;
54
+
55
+ /** Line-start prefixes that may still grow into a block marker. */
56
+ const UNDECIDED_PREFIX_RE = /^(?:#{1,6}|[-*+]|-{2,}|\*{2,}|_{2,}|\d{1,3}|\d{1,3}[.)]|>+|`{1,2}|~{1,2})$/;
57
+ /** A whole line that is a horizontal rule (or setext underline) — silence. */
58
+ const HR_LINE_RE = /^(?:-{3,}|\*{3,}|_{3,})\s*$/;
59
+
60
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^()]*)\)/g;
61
+ const LINK_RE = /\[([^\]]+)\]\(([^()]*)\)/g;
62
+ const AUTOLINK_RE = /<(https?:\/\/[^\s>]+)>/g;
63
+ const BARE_URL_RE = /\bhttps?:\/\/[^\s<>()"'\]]+|\bwww\.[\w-]+(?:\.[\w-]+)+[^\s<>()"'\]]*/g;
64
+ const INLINE_CODE_RE = /`{1,2}([^`]+)`{1,2}/g;
65
+ const BOLD_STRIKE_RE = /\*\*|__|~~/g;
66
+ const EMPHASIS_ASTERISK_RE = /\*(?=\S)|(?<=\S)\*/g;
67
+ const EMPHASIS_UNDERSCORE_RE = /(^|\s)_+|_+(?=\s|$)/g;
68
+ const HTML_TAG_RE = /<\/?[a-zA-Z][^<>]*>/g;
69
+ const HR_INLINE_RE = /(^|\s)[-*_]{3,}(?=\s|$)/g;
70
+ const PATH_RE = /(^|[\s("'`])((?:~|\.{1,2})?\/?[\w.@+-]+(?:\/[\w.@+-]+){2,}\/?)/g;
71
+ const HAS_SPEAKABLE_RE = /[\p{L}\p{N}]/u;
72
+
73
+ /** "https://github.com/foo/bar?x#y" → "github.com". */
74
+ function speakableUrl(url: string): string {
75
+ return url
76
+ .replace(/^[a-z][\w+.-]*:\/\//i, "")
77
+ .replace(/^www\./i, "")
78
+ .replace(/[/?#].*$/, "");
79
+ }
80
+
81
+ /**
82
+ * Collapse one raw segment to its speakable form; empty string when nothing
83
+ * in it is worth vocalizing (pure markup, URLs-only, whitespace).
84
+ */
85
+ function normalizeSpeakable(raw: string): string {
86
+ const spoken = raw
87
+ .replace(IMAGE_RE, "$1")
88
+ .replace(LINK_RE, "$1")
89
+ .replace(AUTOLINK_RE, (_match, url: string) => speakableUrl(url))
90
+ .replace(BARE_URL_RE, match => speakableUrl(match))
91
+ .replace(INLINE_CODE_RE, "$1")
92
+ .replace(BOLD_STRIKE_RE, "")
93
+ .replace(EMPHASIS_ASTERISK_RE, "")
94
+ .replace(EMPHASIS_UNDERSCORE_RE, "$1")
95
+ .replace(HTML_TAG_RE, " ")
96
+ .replace(HR_INLINE_RE, "$1")
97
+ .replace(PATH_RE, (_match, lead: string, path: string) => {
98
+ // "packages/coding-agent/src/tts/vocalizer.ts" → "vocalizer.ts".
99
+ const parts = path.split("/").filter(part => part.length > 0);
100
+ return lead + (parts[parts.length - 1] ?? path);
101
+ })
102
+ .replace(/\s+/g, " ")
103
+ .trim();
104
+ return HAS_SPEAKABLE_RE.test(spoken) ? spoken : "";
105
+ }
106
+
107
+ /**
108
+ * Earliest sentence boundary at or past `min` chars; -1 when none. Skips cuts
109
+ * that would strand an unclosed inline-code span or split an abbreviation.
110
+ */
111
+ function findSentenceCut(text: string, min: number): number {
112
+ SENTENCE_BOUNDARY_RE.lastIndex = 0;
113
+ for (let match = SENTENCE_BOUNDARY_RE.exec(text); match; match = SENTENCE_BOUNDARY_RE.exec(text)) {
114
+ const cut = match.index + match[0].length;
115
+ if (cut < min) continue;
116
+ const head = text.slice(0, cut);
117
+ if (ABBREVIATION_RE.test(head.trimEnd())) continue;
118
+ if ((head.match(/`/g)?.length ?? 0) % 2 !== 0) continue;
119
+ return cut;
120
+ }
121
+ return -1;
122
+ }
123
+
124
+ /** Earliest clause boundary at or past `min` chars; -1 when none. */
125
+ function findClauseCut(text: string, min: number): number {
126
+ CLAUSE_BOUNDARY_RE.lastIndex = 0;
127
+ for (let match = CLAUSE_BOUNDARY_RE.exec(text); match; match = CLAUSE_BOUNDARY_RE.exec(text)) {
128
+ const cut = match.index + match[0].length;
129
+ if (cut >= min) return cut;
130
+ }
131
+ return -1;
132
+ }
133
+
134
+ /**
135
+ * Latest clause boundary in `[min, max]` chars; -1 when none. Keeps soft-cut
136
+ * segments grouped near the target length instead of shaving off the earliest
137
+ * stale clause.
138
+ */
139
+ function findLastClauseCut(text: string, min: number, max: number): number {
140
+ CLAUSE_BOUNDARY_RE.lastIndex = 0;
141
+ let best = -1;
142
+ for (let match = CLAUSE_BOUNDARY_RE.exec(text); match; match = CLAUSE_BOUNDARY_RE.exec(text)) {
143
+ const cut = match.index + match[0].length;
144
+ if (cut > max) break;
145
+ if (cut >= min) best = cut;
146
+ }
147
+ return best;
148
+ }
149
+
150
+ /** Word-level cut for text with no usable punctuation: last space at or before `max`. */
151
+ function findForcedCut(text: string, max: number): number {
152
+ const space = text.lastIndexOf(" ", max);
153
+ return space > 0 ? space + 1 : Math.min(max, text.length);
154
+ }
155
+
156
+ /** How a line-start prefix resolved. */
157
+ type PrefixDecision =
158
+ | { kind: "undecided" }
159
+ | { kind: "prose"; text: string }
160
+ | { kind: "marker"; spoken: string }
161
+ | { kind: "swallow" }
162
+ | { kind: "fence"; fence: string };
163
+
164
+ function classifyPrefix(prefix: string): PrefixDecision {
165
+ if (prefix === "|") return { kind: "swallow" };
166
+ if (/^(?:`{3}|~{3})/.test(prefix)) return { kind: "fence", fence: prefix.slice(0, 3) };
167
+ if (/^#{1,6}[ \t]/.test(prefix)) return { kind: "marker", spoken: "" };
168
+ if (/^[-*+][ \t]/.test(prefix)) return { kind: "marker", spoken: "" };
169
+ const numbered = /^(\d{1,3})[.)][ \t]/.exec(prefix);
170
+ if (numbered) return { kind: "marker", spoken: `${numbered[1]}, ` };
171
+ if (/^>+/.test(prefix) && !/^>+$/.test(prefix)) {
172
+ return { kind: "prose", text: prefix.replace(/^>+[ \t]?/, "") };
173
+ }
174
+ if (UNDECIDED_PREFIX_RE.test(prefix)) return { kind: "undecided" };
175
+ return { kind: "prose", text: prefix };
176
+ }
177
+
178
+ /** Block-pass state: where the current character lands. */
179
+ type BlockMode = "linestart" | "prose" | "swallow" | "code";
180
+
181
+ /**
182
+ * One per utterance. Feed raw assistant deltas through {@link push}; each call
183
+ * returns the segments that became ready to speak. {@link flush} drains the
184
+ * remainder at message end; {@link flushIdle} drains it when generation stalls
185
+ * mid-sentence so speech doesn't sit on buffered text through a tool call.
186
+ */
187
+ export class SpeakableStream {
188
+ #mode: BlockMode = "linestart";
189
+ /** Pending line-start characters while the block marker is still ambiguous. */
190
+ #prefix = "";
191
+ /** Opening fence of the code block being swallowed (``` or ~~~). */
192
+ #fence = "";
193
+ /** First characters of the current line inside a code block (fence-close probe). */
194
+ #codeLine = "";
195
+ /** Mode to enter after the current swallowed line ends (code for an opening fence). */
196
+ #afterSwallow: BlockMode = "linestart";
197
+ /** Prose accumulator the segmenter cuts from. */
198
+ #buf = "";
199
+ /** Whether anything has been emitted yet (enables the fast first segment). */
200
+ #spoke = false;
201
+
202
+ /** Consume a raw delta; returns segments now ready to speak, in order. */
203
+ push(delta: string): string[] {
204
+ const out: string[] = [];
205
+ for (const ch of delta) this.#consume(ch, out);
206
+ this.#extract(out);
207
+ return out;
208
+ }
209
+
210
+ /** Message end: drain everything left, including a trailing partial sentence. */
211
+ flush(): string[] {
212
+ const out: string[] = [];
213
+ if (this.#mode === "linestart" && this.#prefix.length > 0 && !HR_LINE_RE.test(this.#prefix)) {
214
+ this.#buf += this.#prefix;
215
+ }
216
+ this.#prefix = "";
217
+ this.#mode = "linestart";
218
+ this.#drain(out);
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Generation stalled (tool call, thinking block): speak what we have rather
224
+ * than sit silent on buffered text. Keeps block state so the stream resumes
225
+ * afterwards, and refuses stubby mid-sentence fragments — the buffer must be
226
+ * a complete thought (trailing sentence punctuation) or at least
227
+ * {@link MIN_SEGMENT} long, so a stall right after "The" stays silent
228
+ * instead of turning into choppy one-word speech.
229
+ */
230
+ flushIdle(): string[] {
231
+ const out: string[] = [];
232
+ const pending = this.#buf.trimEnd();
233
+ const completeThought = /[.!?…][)\]"'»”’]*$/.test(pending);
234
+ if (!completeThought && pending.length < MIN_SEGMENT) return out;
235
+ this.#drain(out);
236
+ return out;
237
+ }
238
+
239
+ #consume(ch: string, out: string[]): void {
240
+ switch (this.#mode) {
241
+ case "linestart":
242
+ this.#consumeLineStart(ch, out);
243
+ return;
244
+ case "prose":
245
+ if (ch === "\n") this.#hardBreak(out);
246
+ else this.#buf += ch;
247
+ return;
248
+ case "swallow":
249
+ if (ch === "\n") this.#mode = this.#afterSwallow;
250
+ return;
251
+ case "code":
252
+ this.#consumeCode(ch);
253
+ return;
254
+ }
255
+ }
256
+
257
+ #consumeLineStart(ch: string, out: string[]): void {
258
+ if (ch === "\n") {
259
+ // The whole line fit in the prefix: an hr/blank line is silence; a
260
+ // short undecided prefix ("Hi.", "OK") was prose all along.
261
+ const line = this.#prefix;
262
+ this.#prefix = "";
263
+ if (line.length > 0 && !HR_LINE_RE.test(line)) this.#buf += line;
264
+ this.#hardBreak(out);
265
+ return;
266
+ }
267
+ this.#prefix += ch;
268
+ const decision = classifyPrefix(this.#prefix);
269
+ if (decision.kind === "undecided") {
270
+ if (this.#prefix.length > 8) {
271
+ this.#buf += this.#prefix;
272
+ this.#prefix = "";
273
+ this.#mode = "prose";
274
+ }
275
+ return;
276
+ }
277
+ this.#prefix = "";
278
+ switch (decision.kind) {
279
+ case "prose":
280
+ this.#buf += decision.text;
281
+ this.#mode = "prose";
282
+ return;
283
+ case "marker":
284
+ this.#buf += decision.spoken;
285
+ this.#mode = "prose";
286
+ return;
287
+ case "swallow":
288
+ this.#mode = "swallow";
289
+ this.#afterSwallow = "linestart";
290
+ return;
291
+ case "fence":
292
+ this.#fence = decision.fence;
293
+ this.#codeLine = "";
294
+ this.#mode = "swallow";
295
+ this.#afterSwallow = "code";
296
+ return;
297
+ }
298
+ }
299
+
300
+ #consumeCode(ch: string): void {
301
+ if (ch === "\n") {
302
+ this.#codeLine = "";
303
+ return;
304
+ }
305
+ if (this.#codeLine.length < 3) {
306
+ this.#codeLine += ch;
307
+ if (this.#codeLine === this.#fence) {
308
+ // Closing fence: swallow the rest of its line, then resume prose.
309
+ this.#mode = "swallow";
310
+ this.#afterSwallow = "linestart";
311
+ }
312
+ }
313
+ }
314
+
315
+ /** Newline in prose: everything buffered is a complete unit — emit it now. */
316
+ #hardBreak(out: string[]): void {
317
+ this.#mode = "linestart";
318
+ this.#drain(out);
319
+ }
320
+
321
+ /** Emit every buffered character, force-splitting anything over the cap. */
322
+ #drain(out: string[]): void {
323
+ let text = this.#buf;
324
+ this.#buf = "";
325
+ while (text.length > MAX_SEGMENT) {
326
+ const cut = findForcedCut(text, MAX_SEGMENT);
327
+ this.#emit(text.slice(0, cut), out);
328
+ text = text.slice(cut);
329
+ }
330
+ this.#emit(text, out);
331
+ }
332
+
333
+ /** Cut ready segments off the front of the buffer (streaming path). */
334
+ #extract(out: string[]): void {
335
+ for (;;) {
336
+ const buf = this.#buf;
337
+ const min = this.#spoke ? MIN_SEGMENT : FIRST_SEGMENT_MIN;
338
+ const sentence = findSentenceCut(buf, min);
339
+ if (sentence !== -1) {
340
+ this.#cut(sentence, out);
341
+ continue;
342
+ }
343
+ if (!this.#spoke && buf.length >= FIRST_CLAUSE_MIN) {
344
+ const clause = findClauseCut(buf, FIRST_SEGMENT_MIN);
345
+ if (clause !== -1) {
346
+ this.#cut(clause, out);
347
+ continue;
348
+ }
349
+ if (buf.length >= FIRST_FORCED_MAX) {
350
+ this.#cut(findForcedCut(buf, FIRST_FORCED_MAX), out);
351
+ continue;
352
+ }
353
+ }
354
+ if (this.#spoke && buf.length >= SOFT_CLAUSE_LEN) {
355
+ const clause = findLastClauseCut(buf, MIN_SEGMENT, SOFT_CLAUSE_LEN);
356
+ if (clause !== -1) {
357
+ this.#cut(clause, out);
358
+ continue;
359
+ }
360
+ }
361
+ if (buf.length > MAX_SEGMENT) {
362
+ const clause = findLastClauseCut(buf, MIN_SEGMENT, MAX_SEGMENT);
363
+ this.#cut(clause !== -1 ? clause : findForcedCut(buf, MAX_SEGMENT), out);
364
+ continue;
365
+ }
366
+ return;
367
+ }
368
+ }
369
+
370
+ #cut(at: number, out: string[]): void {
371
+ const head = this.#buf.slice(0, at);
372
+ this.#buf = this.#buf.slice(at);
373
+ this.#emit(head, out);
374
+ }
375
+
376
+ #emit(raw: string, out: string[]): void {
377
+ const spoken = normalizeSpeakable(raw);
378
+ if (!spoken) return;
379
+ out.push(spoken);
380
+ this.#spoke = true;
381
+ }
382
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Enhanced speech rewriting (`speech.enhanced`): turn assistant markdown into
3
+ * natural spoken prose with the tiny/smol model before synthesis.
4
+ *
5
+ * Two pieces:
6
+ * - {@link BlockAccumulator} — fence-aware paragraph splitter over the raw
7
+ * streaming deltas. Blocks are blank-line-delimited; a fenced code block
8
+ * never splits, and an idle-time {@link BlockAccumulator.flushPartial} while
9
+ * inside a fence stays silent so half a code block is never sent to the
10
+ * rewriter.
11
+ * - {@link SpeechEnhancer} — the per-session rewrite service the event
12
+ * controller hands to the vocalizer. Resolves the tiny/smol role (same chain
13
+ * as the auto-thinking classifier), sends one bounded completion per block,
14
+ * and returns null on any failure or timeout so the caller falls back to the
15
+ * mechanical {@link SpeakableStream} cleanup — speech never blocks on the
16
+ * model.
17
+ */
18
+ import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
19
+ import { logger, prompt } from "@oh-my-pi/pi-utils";
20
+ import type { ModelRegistry } from "../config/model-registry";
21
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
22
+ import type { Settings } from "../config/settings";
23
+ import speechRewritePrompt from "../prompts/system/speech-rewrite.md" with { type: "text" };
24
+
25
+ const SYSTEM_PROMPT = prompt.render(speechRewritePrompt);
26
+ /** Rewrite budget: a paragraph in, a spoken paragraph (usually shorter) out. */
27
+ const ANSWER_MAX_TOKENS = 512;
28
+ /** Reasoning backends may burn tokens before the answer despite `disableReasoning`. */
29
+ const REASONING_SAFE_MAX_TOKENS = 1536;
30
+ /** Per-block completion deadline before falling back to mechanical cleanup. */
31
+ const REWRITE_TIMEOUT_MS = 6000;
32
+ /** Bound block characters sent to the model (huge diffs/code dumps get elided). */
33
+ const MAX_BLOCK_CHARS = 4000;
34
+
35
+ /** Session-scoped dependencies; mirrors the auto-thinking classifier's deps. */
36
+ export interface SpeechEnhancerDeps {
37
+ settings: Settings;
38
+ registry: ModelRegistry;
39
+ sessionId: string;
40
+ metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
41
+ }
42
+
43
+ function extractText(content: AssistantMessage["content"]): string {
44
+ return content
45
+ .filter((block): block is Extract<AssistantMessage["content"][number], { type: "text" }> => block.type === "text")
46
+ .map(block => block.text)
47
+ .join(" ")
48
+ .trim();
49
+ }
50
+
51
+ /**
52
+ * Rewrites one markdown block into spoken prose via the tiny/smol role.
53
+ * Constructed per session by the event controller and handed to the vocalizer.
54
+ */
55
+ export class SpeechEnhancer {
56
+ #deps: SpeechEnhancerDeps;
57
+
58
+ constructor(deps: SpeechEnhancerDeps) {
59
+ this.#deps = deps;
60
+ }
61
+
62
+ /**
63
+ * Rewrite `block` for speech. Returns the spoken text (empty string when
64
+ * the model judged the block unspeakable — pure code/markup), or null when
65
+ * the rewrite failed, timed out, or no model/key resolved; the caller then
66
+ * falls back to mechanical normalization.
67
+ */
68
+ async rewrite(block: string, signal?: AbortSignal): Promise<string | null> {
69
+ try {
70
+ const { settings, registry, sessionId } = this.#deps;
71
+ // `pi/tiny` expands a configured `modelRoles.tiny` and otherwise falls
72
+ // through tiny's alias to the smol priority chain — unlike bare role
73
+ // lookup, this resolves even with no roles configured.
74
+ const model = resolveModelRoleValue("pi/tiny", registry.getAvailable(), {
75
+ settings,
76
+ matchPreferences: getModelMatchPreferences(settings),
77
+ }).model;
78
+ if (!model) return null;
79
+ const apiKey = await registry.getApiKey(model, sessionId);
80
+ if (!apiKey) return null;
81
+ // Resolve metadata after getApiKey so the session-sticky credential is recorded first.
82
+ const metadata = this.#deps.metadataResolver?.(model.provider);
83
+ const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS);
84
+ const response = await completeSimple(
85
+ model,
86
+ {
87
+ systemPrompt: [SYSTEM_PROMPT],
88
+ messages: [{ role: "user", content: boundBlock(block), timestamp: Date.now() }],
89
+ },
90
+ {
91
+ apiKey: registry.resolver(model, sessionId),
92
+ maxTokens: model.reasoning ? REASONING_SAFE_MAX_TOKENS : ANSWER_MAX_TOKENS,
93
+ disableReasoning: true,
94
+ metadata,
95
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
96
+ },
97
+ );
98
+ if (response.stopReason === "error") {
99
+ logger.debug("speech-enhancer: rewrite errored", { error: response.errorMessage });
100
+ return null;
101
+ }
102
+ return extractText(response.content);
103
+ } catch (error) {
104
+ if (!signal?.aborted) {
105
+ logger.debug("speech-enhancer: rewrite failed", {
106
+ error: error instanceof Error ? error.message : String(error),
107
+ });
108
+ }
109
+ return null;
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Elide the middle of an oversized block so the prompt stays bounded. */
115
+ function boundBlock(block: string): string {
116
+ if (block.length <= MAX_BLOCK_CHARS) return block;
117
+ const half = MAX_BLOCK_CHARS / 2;
118
+ return `${block.slice(0, half)}\n… (elided) …\n${block.slice(-half)}`;
119
+ }
120
+
121
+ /**
122
+ * Fence-aware paragraph accumulator over raw streaming deltas. One instance
123
+ * per utterance.
124
+ */
125
+ export class BlockAccumulator {
126
+ /** Complete lines of the block being accumulated. */
127
+ #lines: string[] = [];
128
+ /** Trailing characters of the current, still-incomplete line. */
129
+ #partial = "";
130
+ /** Opening fence chars while inside a code block, else null. */
131
+ #fence: string | null = null;
132
+ /** Index into {@link #lines} where the open fence started (drop point for a truncated fence). */
133
+ #fenceStart = 0;
134
+
135
+ /** Feed a delta; returns the blocks it completed, in order. */
136
+ push(delta: string): string[] {
137
+ const out: string[] = [];
138
+ let text = this.#partial + delta;
139
+ for (;;) {
140
+ const nl = text.indexOf("\n");
141
+ if (nl === -1) break;
142
+ const line = text.slice(0, nl);
143
+ text = text.slice(nl + 1);
144
+ this.#consumeLine(line, out);
145
+ }
146
+ this.#partial = text;
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Message end: drain everything. An unterminated code fence is dropped from
152
+ * its opening line onward (a truncated block is never worth speaking); the
153
+ * prose before it still comes out.
154
+ */
155
+ flush(): string | null {
156
+ if (this.#partial.length > 0) {
157
+ this.#lines.push(this.#partial);
158
+ this.#partial = "";
159
+ }
160
+ if (this.#fence !== null) {
161
+ this.#lines.length = this.#fenceStart;
162
+ this.#fence = null;
163
+ }
164
+ return this.#take();
165
+ }
166
+
167
+ /**
168
+ * Generation stalled: drain the pending partial block — unless we are
169
+ * inside a code fence, where the only thing buffered is code and speaking
170
+ * or rewriting half a fence would re-introduce vocalized code. Fence state
171
+ * is preserved so the eventual closing fence still matches.
172
+ */
173
+ flushPartial(): string | null {
174
+ if (this.#fence !== null) return null;
175
+ if (this.#partial.length > 0) {
176
+ this.#lines.push(this.#partial);
177
+ this.#partial = "";
178
+ }
179
+ return this.#take();
180
+ }
181
+
182
+ #consumeLine(line: string, out: string[]): void {
183
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
184
+ if (this.#fence === null && fence !== undefined) {
185
+ this.#fence = fence.slice(0, 3);
186
+ this.#fenceStart = this.#lines.length;
187
+ } else if (this.#fence !== null && fence?.startsWith(this.#fence)) {
188
+ this.#fence = null;
189
+ }
190
+ if (this.#fence === null && fence === undefined && line.trim().length === 0) {
191
+ const block = this.#take();
192
+ if (block !== null) out.push(block);
193
+ return;
194
+ }
195
+ this.#lines.push(line);
196
+ }
197
+
198
+ #take(): string | null {
199
+ if (this.#lines.length === 0) return null;
200
+ const block = this.#lines.join("\n");
201
+ this.#lines = [];
202
+ return block;
203
+ }
204
+ }