@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3
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/CHANGELOG.md +91 -0
- package/dist/cli.js +3855 -3798
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +24 -0
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/utils/git.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli.ts +27 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +21 -0
- package/src/cursor.ts +1 -1
- package/src/dap/client.ts +134 -36
- package/src/discovery/helpers.ts +8 -0
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/irc/bus.ts +5 -4
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +119 -20
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +23 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +329 -79
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-history-format.ts +6 -2
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +46 -9
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +39 -37
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +23 -6
- package/src/tools/renderers.ts +24 -0
- package/src/tools/ssh.ts +25 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/utils/git.ts +8 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +23 -6
|
@@ -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
|
+
}
|