@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
@@ -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. Text deltas are streamed *straight into the
6
- * TTS engine* ({@link Vocalizer.pushDelta} → the worker's incremental text
7
- * input): the engine splits the running text at sentence boundaries and emits
8
- * one audio chunk per sentence, which a single {@link StreamingAudioPlayer}
9
- * plays back gaplessly. So the assistant starts speaking sentence 1 while later
10
- * sentences are still being generatedlow latency, never overlapping.
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 the player) and aborts
14
- * in-flight synthesis — wired to a new turn, an Esc/Ctrl+C interrupt, and a
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 questions).
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
- /** Open stream session for the current utterance; null when none is active. */
85
+ /** Mechanical mode: open stream session for the current utterance. */
41
86
  #handle: TtsStreamHandle | null = null;
42
- /** Aborts the in-flight session on {@link clear}; replaced per session. */
43
- #abort: AbortController | null = null;
44
- /** The current session's player; stopped on {@link clear}, gain-tracked for ducking. */
45
- #player: VocalizerPlayer | null = null;
46
- /** Serialized playback chain across sessions; awaited by {@link idle}. */
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 engine. No-op when vocalization
58
- * is disabled. The engine buffers the running text and emits audio for each
59
- * complete sentence; the trailing partial is flushed by {@link flush}.
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.#ensureSession().push(text);
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). The engine
69
- * flushes its trailing partial as a final chunk; the player keeps draining
70
- * queued audio until it completes.
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
- if (!settings.get("speech.enabled")) return;
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 the current session, killing in-flight playback and
90
- * synthesis (new turn / user message / Esc interrupt). Audio stops at once.
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?.abort();
95
- this.#abort = null;
96
- this.#player?.stop();
97
- this.#player = null;
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?.setGain(DUCK_GAIN);
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?.setGain(1);
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
- * Open a streaming-synthesis session lazily on the first delta and chain its
119
- * playback after any prior session's, so sequential utterances never overlap.
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
- #ensureSession(): TtsStreamHandle {
122
- if (this.#handle) return this.#handle;
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.#player = player;
132
- this.#chain = this.#chain.then(() => this.#play(handle, player, abort.signal));
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
- /** Feed each synthesized sentence into the player in arrival order; abort stops it. */
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/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"], {