@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
package/src/tts/vocalizer.ts
CHANGED
|
@@ -2,32 +2,56 @@
|
|
|
2
2
|
* Streaming assistant speech-vocalization.
|
|
3
3
|
*
|
|
4
4
|
* The vocalizer turns the assistant's STREAMING output into spoken audio as a
|
|
5
|
-
* side effect of the normal turn.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* side effect of the normal turn. Two pipelines share the synthesis/playback
|
|
6
|
+
* tail:
|
|
7
|
+
*
|
|
8
|
+
* - Mechanical (default): deltas run through a {@link SpeakableStream} — which
|
|
9
|
+
* drops code/tables/markup, speaks link labels and URL hosts, and cuts
|
|
10
|
+
* speakable segments the moment a boundary appears — and each ready segment
|
|
11
|
+
* is pushed to the TTS worker.
|
|
12
|
+
* - Enhanced (`speech.enhanced`, requires a session-wired
|
|
13
|
+
* {@link SpeechEnhancer}): deltas accumulate into fence-aware markdown
|
|
14
|
+
* blocks; each block (small ones coalesced) is rewritten into natural spoken
|
|
15
|
+
* prose by the tiny/smol model — at most {@link MAX_REWRITES_IN_FLIGHT}
|
|
16
|
+
* completions in flight, results pushed strictly in order — then segmented
|
|
17
|
+
* by the same {@link SpeakableStream} as a safety net. A failed or timed-out
|
|
18
|
+
* rewrite falls back to mechanical cleanup of the raw block, so speech never
|
|
19
|
+
* blocks on the model.
|
|
20
|
+
*
|
|
21
|
+
* In both modes the synthesis session (worker, player) opens lazily on the
|
|
22
|
+
* first speakable segment, and an idle timer speaks buffered text when
|
|
23
|
+
* generation stalls (tool call, thinking block). One
|
|
24
|
+
* {@link StreamingAudioPlayer} per utterance plays chunks back gaplessly;
|
|
25
|
+
* utterance playback is chained so sequential utterances never overlap.
|
|
11
26
|
*
|
|
12
27
|
* Overspeech control:
|
|
13
|
-
* - {@link clear} stops playback instantly (kills
|
|
14
|
-
* in-flight synthesis — wired to a new turn, an
|
|
15
|
-
* sent message.
|
|
28
|
+
* - {@link clear} stops playback instantly (kills every live player) and
|
|
29
|
+
* aborts in-flight synthesis and rewrites — wired to a new turn, an
|
|
30
|
+
* Esc/Ctrl+C interrupt, and a sent message.
|
|
16
31
|
* - {@link duck}/{@link unduck} lower/restore the volume while the user is
|
|
17
32
|
* speaking (push-to-talk), so the assistant doesn't talk over them.
|
|
18
|
-
* - Sessions are chained, so sequential utterances queue and drain in order
|
|
19
|
-
* rather than overlapping.
|
|
20
33
|
*
|
|
21
34
|
* Errors are swallowed (debug-logged) so a synthesis or playback failure never
|
|
22
35
|
* throws into the turn. A process-level singleton ({@link vocalizer}) is shared
|
|
23
|
-
* by the event controller (streaming deltas) and the ask tool (spoken
|
|
36
|
+
* by the event controller (streaming deltas) and the ask tool (spoken
|
|
37
|
+
* questions); the event controller wires the per-session enhancer via
|
|
38
|
+
* {@link Vocalizer.setEnhancer}.
|
|
24
39
|
*/
|
|
25
40
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
26
41
|
import { settings } from "../config/settings";
|
|
27
42
|
import { DEFAULT_TTS_VOICE } from "./models";
|
|
43
|
+
import { SpeakableStream } from "./speakable";
|
|
44
|
+
import { BlockAccumulator, type SpeechEnhancer } from "./speech-enhancer";
|
|
28
45
|
import { createStreamingPlayer, DUCK_GAIN } from "./streaming-player";
|
|
29
46
|
import { type TtsStreamHandle, ttsClient } from "./tts-client";
|
|
30
47
|
|
|
48
|
+
/** Quiet time on the delta stream before the buffered partial is spoken. */
|
|
49
|
+
const IDLE_FLUSH_MS = 1000;
|
|
50
|
+
/** Coalesce completed blocks until this many chars before one rewrite call. */
|
|
51
|
+
const COALESCE_MIN_CHARS = 400;
|
|
52
|
+
/** Bounded rewrite concurrency across utterances. */
|
|
53
|
+
const MAX_REWRITES_IN_FLIGHT = 2;
|
|
54
|
+
|
|
31
55
|
export interface VocalizerPlayer {
|
|
32
56
|
start(sampleRate: number): void;
|
|
33
57
|
write(pcm: Float32Array): void;
|
|
@@ -36,40 +60,113 @@ export interface VocalizerPlayer {
|
|
|
36
60
|
stop(): void;
|
|
37
61
|
}
|
|
38
62
|
|
|
63
|
+
/**
|
|
64
|
+
* State of one enhanced-mode utterance. Detached from the vocalizer at
|
|
65
|
+
* {@link Vocalizer.flush} so in-flight rewrites finish and the session closes
|
|
66
|
+
* after the last ordered push, even while the next utterance streams.
|
|
67
|
+
*/
|
|
68
|
+
interface EnhancedUtterance {
|
|
69
|
+
blocks: BlockAccumulator;
|
|
70
|
+
/** Segments the (rewritten) prose; also the safety net for leaked markup. */
|
|
71
|
+
speakable: SpeakableStream;
|
|
72
|
+
/** Completed blocks held back for coalescing into one rewrite call. */
|
|
73
|
+
pending: string[];
|
|
74
|
+
pendingChars: number;
|
|
75
|
+
/** First rewrite skips coalescing for fast time-to-first-audio. */
|
|
76
|
+
dispatchedFirst: boolean;
|
|
77
|
+
/** FIFO chain that pushes rewrite results in block order. */
|
|
78
|
+
order: Promise<void>;
|
|
79
|
+
handle: TtsStreamHandle | null;
|
|
80
|
+
/** Aborted by {@link Vocalizer.clear} (current or detached); gates every deferred step. */
|
|
81
|
+
abort: AbortController;
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
export class Vocalizer {
|
|
40
|
-
/**
|
|
85
|
+
/** Mechanical mode: open stream session for the current utterance. */
|
|
41
86
|
#handle: TtsStreamHandle | null = null;
|
|
42
|
-
/**
|
|
43
|
-
#
|
|
44
|
-
/**
|
|
45
|
-
#
|
|
46
|
-
/**
|
|
87
|
+
/** Mechanical mode: markdown → segment transform for the current utterance. */
|
|
88
|
+
#speakable: SpeakableStream | null = null;
|
|
89
|
+
/** Enhanced mode: the utterance currently receiving deltas. */
|
|
90
|
+
#enhanced: EnhancedUtterance | null = null;
|
|
91
|
+
/** Per-session rewrite service; wired by the event controller, null elsewhere. */
|
|
92
|
+
#enhancer: SpeechEnhancer | null = null;
|
|
93
|
+
/** Fires when the delta stream goes quiet mid-sentence; speaks the partial. */
|
|
94
|
+
#idleTimer: NodeJS.Timeout | null = null;
|
|
95
|
+
/** Abort controllers of every not-yet-finished utterance; all aborted on {@link clear}. */
|
|
96
|
+
#liveAborts = new Set<AbortController>();
|
|
97
|
+
/** Players of every not-yet-finished utterance; ducked together, stopped on {@link clear}. */
|
|
98
|
+
#livePlayers = new Set<VocalizerPlayer>();
|
|
99
|
+
/** Serialized playback chain across utterances; awaited by {@link idle}. */
|
|
47
100
|
#chain: Promise<void> = Promise.resolve();
|
|
48
101
|
/** Whether the user is currently speaking; new sessions open ducked. */
|
|
49
102
|
#ducked = false;
|
|
103
|
+
/** Available rewrite slots; blocks queue when exhausted. */
|
|
104
|
+
#rewriteSlots = MAX_REWRITES_IN_FLIGHT;
|
|
105
|
+
#slotWaiters: Array<() => void> = [];
|
|
50
106
|
#createPlayer: () => VocalizerPlayer;
|
|
51
107
|
|
|
52
108
|
constructor(createPlayer: () => VocalizerPlayer = createStreamingPlayer) {
|
|
53
109
|
this.#createPlayer = createPlayer;
|
|
54
110
|
}
|
|
55
111
|
|
|
112
|
+
/** Wire (or drop) the per-session enhanced-rewrite service. */
|
|
113
|
+
setEnhancer(enhancer: SpeechEnhancer | null): void {
|
|
114
|
+
this.#enhancer = enhancer;
|
|
115
|
+
}
|
|
116
|
+
|
|
56
117
|
/**
|
|
57
|
-
* Stream a delta of assistant text into the
|
|
58
|
-
* is disabled. The
|
|
59
|
-
*
|
|
118
|
+
* Stream a delta of assistant text into the pipeline. No-op when
|
|
119
|
+
* vocalization is disabled. The synthesis session (worker, player) is only
|
|
120
|
+
* opened once the first speakable segment exists, so a reply that
|
|
121
|
+
* normalizes to silence (pure code, tables, URLs) costs nothing. The
|
|
122
|
+
* trailing partial is flushed by {@link flush} or the idle timer. The
|
|
123
|
+
* pipeline (enhanced vs mechanical) is latched per utterance.
|
|
60
124
|
*/
|
|
61
125
|
pushDelta(text: string): void {
|
|
62
126
|
if (!settings.get("speech.enabled")) return;
|
|
63
127
|
if (!text) return;
|
|
64
|
-
this.#
|
|
128
|
+
if (this.#enhanced || (!this.#speakable && this.#enhancer && settings.get("speech.enhanced"))) {
|
|
129
|
+
this.#pushEnhanced(text);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.#speakable ??= new SpeakableStream();
|
|
133
|
+
const speakable = this.#speakable;
|
|
134
|
+
this.#pushSegments(speakable.push(text));
|
|
135
|
+
this.#armIdle(() => {
|
|
136
|
+
if (this.#speakable !== speakable) return;
|
|
137
|
+
this.#pushSegments(speakable.flushIdle());
|
|
138
|
+
});
|
|
65
139
|
}
|
|
66
140
|
|
|
67
141
|
/**
|
|
68
|
-
* Close the current input stream (call at message/turn end).
|
|
69
|
-
*
|
|
70
|
-
*
|
|
142
|
+
* Close the current input stream (call at message/turn end). Drains the
|
|
143
|
+
* trailing partial as final segments; in enhanced mode the session ends
|
|
144
|
+
* only after the last in-flight rewrite has pushed, while the next
|
|
145
|
+
* utterance may already be streaming.
|
|
71
146
|
*/
|
|
72
147
|
flush(): void {
|
|
148
|
+
this.#clearIdleTimer();
|
|
149
|
+
const utterance = this.#enhanced;
|
|
150
|
+
if (utterance) {
|
|
151
|
+
this.#enhanced = null;
|
|
152
|
+
const last = utterance.blocks.flush();
|
|
153
|
+
if (last !== null) {
|
|
154
|
+
utterance.pending.push(last);
|
|
155
|
+
utterance.pendingChars += last.length;
|
|
156
|
+
}
|
|
157
|
+
this.#dispatchPending(utterance);
|
|
158
|
+
utterance.order = utterance.order.then(() => {
|
|
159
|
+
if (utterance.abort.signal.aborted) return;
|
|
160
|
+
utterance.handle?.end();
|
|
161
|
+
// A rewrite-only utterance (nothing speakable, no session) has no
|
|
162
|
+
// playback teardown to reap its abort controller — do it here.
|
|
163
|
+
if (!utterance.handle) this.#liveAborts.delete(utterance.abort);
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const speakable = this.#speakable;
|
|
168
|
+
this.#speakable = null;
|
|
169
|
+
if (speakable) this.#pushSegments(speakable.flush());
|
|
73
170
|
this.#handle?.end();
|
|
74
171
|
this.#handle = null;
|
|
75
172
|
}
|
|
@@ -79,34 +176,35 @@ export class Vocalizer {
|
|
|
79
176
|
* message): stream it in and immediately close the input. No-op when disabled.
|
|
80
177
|
*/
|
|
81
178
|
speak(text: string): void {
|
|
82
|
-
|
|
83
|
-
if (!text) return;
|
|
84
|
-
this.#ensureSession().push(text);
|
|
179
|
+
this.pushDelta(text);
|
|
85
180
|
this.flush();
|
|
86
181
|
}
|
|
87
182
|
|
|
88
183
|
/**
|
|
89
|
-
* Interrupt and drop
|
|
90
|
-
*
|
|
184
|
+
* Interrupt and drop every utterance, killing in-flight playback, synthesis,
|
|
185
|
+
* and rewrites (new turn / user message / Esc interrupt). Audio stops at once.
|
|
91
186
|
*/
|
|
92
187
|
clear(): void {
|
|
188
|
+
this.#clearIdleTimer();
|
|
189
|
+
this.#enhanced = null;
|
|
190
|
+
this.#speakable = null;
|
|
93
191
|
this.#handle = null;
|
|
94
|
-
this.#abort
|
|
95
|
-
this.#
|
|
96
|
-
this.#player
|
|
97
|
-
this.#
|
|
192
|
+
for (const abort of this.#liveAborts) abort.abort();
|
|
193
|
+
this.#liveAborts.clear();
|
|
194
|
+
for (const player of this.#livePlayers) player.stop();
|
|
195
|
+
this.#livePlayers.clear();
|
|
98
196
|
}
|
|
99
197
|
|
|
100
198
|
/** Lower the volume while the user is speaking (push-to-talk), so speech doesn't drown them out. */
|
|
101
199
|
duck(): void {
|
|
102
200
|
this.#ducked = true;
|
|
103
|
-
this.#player
|
|
201
|
+
for (const player of this.#livePlayers) player.setGain(DUCK_GAIN);
|
|
104
202
|
}
|
|
105
203
|
|
|
106
204
|
/** Restore full volume once the user stops speaking. */
|
|
107
205
|
unduck(): void {
|
|
108
206
|
this.#ducked = false;
|
|
109
|
-
this.#player
|
|
207
|
+
for (const player of this.#livePlayers) player.setGain(1);
|
|
110
208
|
}
|
|
111
209
|
|
|
112
210
|
/** Resolve once the playback chain has drained (tests / shutdown). */
|
|
@@ -114,26 +212,159 @@ export class Vocalizer {
|
|
|
114
212
|
return this.#chain;
|
|
115
213
|
}
|
|
116
214
|
|
|
215
|
+
// --- Enhanced pipeline ---------------------------------------------------
|
|
216
|
+
|
|
217
|
+
#pushEnhanced(text: string): void {
|
|
218
|
+
if (!this.#enhanced) this.#enhanced = this.#newEnhancedUtterance();
|
|
219
|
+
const utterance = this.#enhanced;
|
|
220
|
+
for (const block of utterance.blocks.push(text)) {
|
|
221
|
+
utterance.pending.push(block);
|
|
222
|
+
utterance.pendingChars += block.length;
|
|
223
|
+
// The first rewrite dispatches immediately for fast first audio;
|
|
224
|
+
// later blocks coalesce so a heading and its list cost one call.
|
|
225
|
+
if (!utterance.dispatchedFirst || utterance.pendingChars >= COALESCE_MIN_CHARS) {
|
|
226
|
+
this.#dispatchPending(utterance);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.#armIdle(() => {
|
|
230
|
+
if (this.#enhanced !== utterance) return;
|
|
231
|
+
const partial = utterance.blocks.flushPartial();
|
|
232
|
+
if (partial !== null) {
|
|
233
|
+
utterance.pending.push(partial);
|
|
234
|
+
utterance.pendingChars += partial.length;
|
|
235
|
+
}
|
|
236
|
+
this.#dispatchPending(utterance);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#newEnhancedUtterance(): EnhancedUtterance {
|
|
241
|
+
const abort = new AbortController();
|
|
242
|
+
this.#liveAborts.add(abort);
|
|
243
|
+
return {
|
|
244
|
+
blocks: new BlockAccumulator(),
|
|
245
|
+
speakable: new SpeakableStream(),
|
|
246
|
+
pending: [],
|
|
247
|
+
pendingChars: 0,
|
|
248
|
+
dispatchedFirst: false,
|
|
249
|
+
order: Promise.resolve(),
|
|
250
|
+
handle: null,
|
|
251
|
+
abort,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
117
255
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
256
|
+
* Send the coalesced pending blocks to the rewriter and chain the ordered
|
|
257
|
+
* push of the result. The completion runs concurrently (bounded by the
|
|
258
|
+
* slot pool); only the push into the synthesizer is serialized, so block
|
|
259
|
+
* N+1's rewrite overlaps block N's synthesis.
|
|
120
260
|
*/
|
|
121
|
-
#
|
|
122
|
-
if (
|
|
261
|
+
#dispatchPending(utterance: EnhancedUtterance): void {
|
|
262
|
+
if (utterance.pending.length === 0) return;
|
|
263
|
+
const block = utterance.pending.join("\n\n");
|
|
264
|
+
utterance.pending = [];
|
|
265
|
+
utterance.pendingChars = 0;
|
|
266
|
+
utterance.dispatchedFirst = true;
|
|
267
|
+
const result = this.#boundedRewrite(block, utterance.abort.signal);
|
|
268
|
+
utterance.order = utterance.order.then(async () => {
|
|
269
|
+
const rewritten = await result;
|
|
270
|
+
if (utterance.abort.signal.aborted) return;
|
|
271
|
+
// Empty string: the model judged the block unspeakable (pure code).
|
|
272
|
+
if (rewritten === "") return;
|
|
273
|
+
// Null: rewrite failed/timed out — mechanical cleanup of the raw
|
|
274
|
+
// block via the SpeakableStream keeps speech flowing.
|
|
275
|
+
const spoken = rewritten ?? block;
|
|
276
|
+
const segments = utterance.speakable.push(spoken.endsWith("\n") ? spoken : `${spoken}\n`);
|
|
277
|
+
if (segments.length === 0) return;
|
|
278
|
+
if (!utterance.handle) utterance.handle = this.#openSession(utterance.abort);
|
|
279
|
+
for (const segment of segments) utterance.handle.push(segment);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Run one rewrite through the bounded slot pool; never rejects. */
|
|
284
|
+
async #boundedRewrite(block: string, signal: AbortSignal): Promise<string | null> {
|
|
285
|
+
await this.#acquireSlot();
|
|
286
|
+
try {
|
|
287
|
+
if (signal.aborted || !this.#enhancer) return null;
|
|
288
|
+
return await this.#enhancer.rewrite(block, signal);
|
|
289
|
+
} finally {
|
|
290
|
+
this.#releaseSlot();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#acquireSlot(): Promise<void> {
|
|
295
|
+
if (this.#rewriteSlots > 0) {
|
|
296
|
+
this.#rewriteSlots--;
|
|
297
|
+
return Promise.resolve();
|
|
298
|
+
}
|
|
299
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
300
|
+
this.#slotWaiters.push(resolve);
|
|
301
|
+
return promise;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#releaseSlot(): void {
|
|
305
|
+
const next = this.#slotWaiters.shift();
|
|
306
|
+
if (next) next();
|
|
307
|
+
else this.#rewriteSlots++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Shared synthesis/playback tail --------------------------------------
|
|
311
|
+
|
|
312
|
+
/** Mechanical mode: feed ready segments, opening the session lazily. */
|
|
313
|
+
#pushSegments(segments: string[]): void {
|
|
314
|
+
if (segments.length === 0) return;
|
|
315
|
+
if (!this.#handle) {
|
|
316
|
+
const abort = new AbortController();
|
|
317
|
+
this.#liveAborts.add(abort);
|
|
318
|
+
this.#handle = this.#openSession(abort);
|
|
319
|
+
}
|
|
320
|
+
for (const segment of segments) this.#handle.push(segment);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Open a streaming-synthesis session and chain its playback after any
|
|
325
|
+
* prior utterance's, so sequential utterances never overlap.
|
|
326
|
+
*/
|
|
327
|
+
#openSession(abort: AbortController): TtsStreamHandle {
|
|
123
328
|
const modelKey = settings.get("tts.localModel");
|
|
124
329
|
const voice = settings.get("speech.voice") || DEFAULT_TTS_VOICE;
|
|
125
|
-
const abort = new AbortController();
|
|
126
|
-
this.#abort = abort;
|
|
127
330
|
const handle = ttsClient.synthesizeStream(modelKey, { voice, signal: abort.signal });
|
|
128
|
-
this.#handle = handle;
|
|
129
331
|
const player = this.#createPlayer();
|
|
130
332
|
player.setGain(this.#ducked ? DUCK_GAIN : 1);
|
|
131
|
-
this.#
|
|
132
|
-
this.#
|
|
333
|
+
this.#liveAborts.add(abort);
|
|
334
|
+
this.#livePlayers.add(player);
|
|
335
|
+
this.#chain = this.#chain.then(async () => {
|
|
336
|
+
try {
|
|
337
|
+
await this.#play(handle, player, abort.signal);
|
|
338
|
+
} finally {
|
|
339
|
+
this.#liveAborts.delete(abort);
|
|
340
|
+
this.#livePlayers.delete(player);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
133
343
|
return handle;
|
|
134
344
|
}
|
|
135
345
|
|
|
136
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* (Re)arm the stall timer: if no delta arrives for {@link IDLE_FLUSH_MS},
|
|
348
|
+
* speak buffered text instead of holding it through a tool call or
|
|
349
|
+
* thinking block. The callback checks utterance identity itself.
|
|
350
|
+
*/
|
|
351
|
+
#armIdle(onIdle: () => void): void {
|
|
352
|
+
this.#clearIdleTimer();
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
this.#idleTimer = null;
|
|
355
|
+
onIdle();
|
|
356
|
+
}, IDLE_FLUSH_MS);
|
|
357
|
+
timer.unref?.();
|
|
358
|
+
this.#idleTimer = timer;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#clearIdleTimer(): void {
|
|
362
|
+
if (this.#idleTimer === null) return;
|
|
363
|
+
clearTimeout(this.#idleTimer);
|
|
364
|
+
this.#idleTimer = null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Feed each synthesized segment into the player in arrival order; abort stops it. */
|
|
137
368
|
async #play(handle: TtsStreamHandle, player: VocalizerPlayer, signal: AbortSignal): Promise<void> {
|
|
138
369
|
let started = false;
|
|
139
370
|
try {
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
1
|
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
|
3
2
|
import * as native from "@oh-my-pi/pi-natives";
|
|
4
3
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Run a subprocess and capture its stdout without blocking the event loop.
|
|
7
|
+
*
|
|
8
|
+
* `readTextFromClipboard`, `readMacFileUrlsFromClipboard`, and the Termux copy
|
|
9
|
+
* path all shell out to CLI clipboard tools. The synchronous `execSync` API
|
|
10
|
+
* parks the render loop until the child exits or the timeout fires, so a hung
|
|
11
|
+
* clipboard daemon freezes the TUI for the full 2000ms budget (#4235). This
|
|
12
|
+
* helper mirrors the previous semantics — read stdout as UTF-8, throw on
|
|
13
|
+
* non-zero exit or timeout, forward optional stdin — but yields to the event
|
|
14
|
+
* loop while the child runs.
|
|
15
|
+
*
|
|
16
|
+
* @throws Error when the child fails to spawn, is killed by the timeout, or
|
|
17
|
+
* exits with a non-zero status. Callers rely on this to fall through to the
|
|
18
|
+
* outer catch and return an empty string / empty list.
|
|
19
|
+
*/
|
|
20
|
+
async function spawnCapture(cmd: string[], options: { input?: string; timeoutMs?: number } = {}): Promise<string> {
|
|
21
|
+
const timeoutMs = options.timeoutMs ?? 2000;
|
|
22
|
+
const proc = Bun.spawn(cmd, {
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "ignore",
|
|
25
|
+
stdin: options.input !== undefined ? Buffer.from(options.input) : "ignore",
|
|
26
|
+
});
|
|
27
|
+
let timedOut = false;
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
timedOut = true;
|
|
30
|
+
proc.kill();
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const stdout = await new Response(proc.stdout).text();
|
|
34
|
+
await proc.exited;
|
|
35
|
+
if (timedOut) {
|
|
36
|
+
throw new Error(`${cmd[0]} timed out after ${timeoutMs}ms`);
|
|
37
|
+
}
|
|
38
|
+
if (proc.exitCode !== 0) {
|
|
39
|
+
throw new Error(`${cmd[0]} exited with code ${proc.exitCode}`);
|
|
40
|
+
}
|
|
41
|
+
return stdout;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
6
47
|
function hasDisplay(): boolean {
|
|
7
48
|
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
8
49
|
}
|
|
@@ -53,11 +94,7 @@ const MAC_FILE_URL_SCRIPT = [
|
|
|
53
94
|
export async function readMacFileUrlsFromClipboard(): Promise<string[]> {
|
|
54
95
|
if (process.platform !== "darwin") return [];
|
|
55
96
|
try {
|
|
56
|
-
const stdout =
|
|
57
|
-
input: MAC_FILE_URL_SCRIPT,
|
|
58
|
-
encoding: "utf8",
|
|
59
|
-
timeout: 2000,
|
|
60
|
-
}).toString();
|
|
97
|
+
const stdout = await spawnCapture(["osascript", "-"], { input: MAC_FILE_URL_SCRIPT });
|
|
61
98
|
return stdout
|
|
62
99
|
.split(/\r?\n/)
|
|
63
100
|
.map(line => line.trim())
|
|
@@ -110,7 +147,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|
|
110
147
|
try {
|
|
111
148
|
if (process.env.TERMUX_VERSION) {
|
|
112
149
|
try {
|
|
113
|
-
|
|
150
|
+
await spawnCapture(["termux-clipboard-set"], { input: text, timeoutMs: 5000 });
|
|
114
151
|
return;
|
|
115
152
|
} catch {
|
|
116
153
|
// Fall through to native
|
|
@@ -286,13 +323,13 @@ export async function readTextFromClipboard(): Promise<string> {
|
|
|
286
323
|
try {
|
|
287
324
|
const p = process.platform;
|
|
288
325
|
if (p === "darwin") {
|
|
289
|
-
return
|
|
326
|
+
return await spawnCapture(["pbpaste"]);
|
|
290
327
|
}
|
|
291
328
|
if (p === "win32") {
|
|
292
329
|
return (await readTextViaPowerShell()) ?? "";
|
|
293
330
|
}
|
|
294
331
|
if (process.env.TERMUX_VERSION) {
|
|
295
|
-
return
|
|
332
|
+
return await spawnCapture(["termux-clipboard-get"]);
|
|
296
333
|
}
|
|
297
334
|
if (isWsl()) {
|
|
298
335
|
const text = await readTextViaPowerShell();
|
|
@@ -303,14 +340,14 @@ export async function readTextFromClipboard(): Promise<string> {
|
|
|
303
340
|
const hasX11Display = Boolean(process.env.DISPLAY);
|
|
304
341
|
if (hasWaylandDisplay) {
|
|
305
342
|
try {
|
|
306
|
-
return
|
|
343
|
+
return await spawnCapture(["wl-paste", "--type", "text/plain", "--no-newline"]);
|
|
307
344
|
} catch {
|
|
308
345
|
if (hasX11Display) {
|
|
309
|
-
return
|
|
346
|
+
return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
|
|
310
347
|
}
|
|
311
348
|
}
|
|
312
349
|
} else if (hasX11Display) {
|
|
313
|
-
return
|
|
350
|
+
return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
|
|
314
351
|
}
|
|
315
352
|
} catch (error) {
|
|
316
353
|
logger.warn("clipboard: failed to read clipboard text", { error: String(error) });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Create an abort signal that fires after a timeout and preserves caller cancellation. */
|
|
2
|
+
export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
3
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
4
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Detect a timeout raised by an abortable fetch. */
|
|
8
|
+
export function isTimeoutError(error: unknown): boolean {
|
|
9
|
+
return error instanceof Error && error.name === "TimeoutError";
|
|
10
|
+
}
|
package/src/utils/git.ts
CHANGED
|
@@ -1919,6 +1919,14 @@ export const ls = {
|
|
|
1919
1919
|
return ls.files(cwd, { others: true, excludeStandard: true, signal });
|
|
1920
1920
|
},
|
|
1921
1921
|
|
|
1922
|
+
/** List paths present in a ref, optionally filtered to specific paths. */
|
|
1923
|
+
async tree(cwd: string, ref: string, files: readonly string[] = [], signal?: AbortSignal): Promise<string[]> {
|
|
1924
|
+
const args = ["ls-tree", "--name-only", "-r", "-z", ref];
|
|
1925
|
+
if (files.length > 0) args.push("--", ...files);
|
|
1926
|
+
const raw = await runText(cwd, args, { readOnly: true, signal });
|
|
1927
|
+
return raw.split("\0").filter(entry => entry.length > 0);
|
|
1928
|
+
},
|
|
1929
|
+
|
|
1922
1930
|
/** List submodule paths (recursive). */
|
|
1923
1931
|
async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
1924
1932
|
const output = await git(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
|
package/src/web/search/index.ts
CHANGED
|
@@ -159,6 +159,13 @@ async function executeSearch(
|
|
|
159
159
|
antigravityEndpointMode = undefined;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
let geminiModel: string | undefined;
|
|
163
|
+
try {
|
|
164
|
+
geminiModel = settings.get("providers.webSearchGeminiModel");
|
|
165
|
+
} catch {
|
|
166
|
+
geminiModel = undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
162
169
|
const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
|
|
163
170
|
let lastProvider = providers[0];
|
|
164
171
|
for (const provider of providers) {
|
|
@@ -176,6 +183,7 @@ async function executeSearch(
|
|
|
176
183
|
authStorage,
|
|
177
184
|
sessionId,
|
|
178
185
|
antigravityEndpointMode,
|
|
186
|
+
geminiModel,
|
|
179
187
|
});
|
|
180
188
|
|
|
181
189
|
if (!hasRenderableSearchContent(response)) {
|