@pellux/goodvibes-tui 1.9.2 → 1.10.1

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 (32) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +2 -2
  3. package/docs/foundation-artifacts/knowledge-graphql.graphql +5 -0
  4. package/docs/foundation-artifacts/knowledge-store.sql +33 -0
  5. package/docs/foundation-artifacts/operator-contract.json +4026 -1376
  6. package/package.json +2 -2
  7. package/src/audio/spoken-turn-wiring.ts +7 -2
  8. package/src/cli/management-utils.ts +4 -0
  9. package/src/export/cost-utils.ts +9 -1
  10. package/src/input/command-registry.ts +11 -0
  11. package/src/input/commands/control-room-runtime.ts +12 -5
  12. package/src/input/commands/incident-runtime.ts +2 -2
  13. package/src/input/commands/memory.ts +11 -11
  14. package/src/input/commands/recall-bundle.ts +8 -8
  15. package/src/input/commands/recall-capture.ts +3 -3
  16. package/src/input/commands/recall-query.ts +41 -15
  17. package/src/input/commands/recall-review.ts +10 -10
  18. package/src/input/settings-modal-data.ts +54 -0
  19. package/src/main.ts +3 -2
  20. package/src/panels/builtin/shared.ts +3 -3
  21. package/src/panels/cost-tracker-panel.ts +2 -2
  22. package/src/panels/modals/memory-modal.ts +77 -16
  23. package/src/runtime/bootstrap-command-context.ts +4 -0
  24. package/src/runtime/bootstrap-command-parts.ts +4 -1
  25. package/src/runtime/bootstrap-shell.ts +15 -4
  26. package/src/runtime/bootstrap.ts +18 -18
  27. package/src/runtime/memory-spine-transport.ts +289 -0
  28. package/src/runtime/orchestrator-core-services.ts +27 -2
  29. package/src/runtime/services.ts +13 -14
  30. package/src/version.ts +1 -1
  31. package/src/audio/spoken-turn-controller.ts +0 -271
  32. package/src/audio/text-chunker.ts +0 -110
@@ -12,7 +12,7 @@ export type OrchestratorCoreServicesSource = Pick<
12
12
  | 'sessionMemoryStore'
13
13
  | 'sessionLineageTracker'
14
14
  | 'idempotencyStore'
15
- | 'memoryRegistry'
15
+ | 'memorySpine'
16
16
  | 'codeIndexStore'
17
17
  | 'codeIndexReindexScheduler'
18
18
  >;
@@ -32,6 +32,18 @@ export type OrchestratorCoreServicesSource = Pick<
32
32
  * omitted it independently; routing them through this one function (with
33
33
  * src/test/runtime/orchestrator-core-services.test.ts pinning the field) is
34
34
  * what keeps that from regressing.
35
+ *
36
+ * SDK 1.2.0 FULL DETACH: the SDK's turn loop reads `memoryRegistry.getAll()`
37
+ * SYNCHRONOUSLY (`TurnKnowledgeRegistrySource`), but the memory spine's wire
38
+ * reads are asynchronous — a sync function cannot await the wire. Per
39
+ * docs/decisions/2026-07-06-memory-wire-full-detach.md (SDK repo) this is
40
+ * satisfied by the spine's freshness-stamped recall snapshot instead of the
41
+ * raw local `memoryRegistry`: `getAll()` reads `memorySpine.recallSnapshot()`,
42
+ * which returns whatever the last `refreshRecallSnapshot()` (an async
43
+ * pre-turn hook — see the `handleUserInput` call sites) captured, honestly
44
+ * empty/stale until refreshed. This is what lets per-turn knowledge injection
45
+ * detach from the local store file when a daemon is adopted, instead of
46
+ * always reading the (possibly divergent) local registry regardless of mode.
35
47
  */
36
48
  export function buildSharedOrchestratorCoreServices(input: {
37
49
  readonly services: OrchestratorCoreServicesSource;
@@ -47,7 +59,7 @@ export function buildSharedOrchestratorCoreServices(input: {
47
59
  sessionMemoryStore: services.sessionMemoryStore,
48
60
  sessionLineageTracker: services.sessionLineageTracker,
49
61
  idempotencyStore: services.idempotencyStore,
50
- memoryRegistry: services.memoryRegistry,
62
+ memoryRegistry: { getAll: () => services.memorySpine.recallSnapshot().records },
51
63
  // Main-session code auto-injection + tool-site reindex. Injection is
52
64
  // additionally gated by the default-off `agent-passive-code-injection` flag inside the
53
65
  // SDK; here we supply the source, the live storage.codeIndexEnabled predicate, and the
@@ -57,3 +69,16 @@ export function buildSharedOrchestratorCoreServices(input: {
57
69
  codeIndexReindexScheduler: services.codeIndexReindexScheduler,
58
70
  };
59
71
  }
72
+
73
+ /**
74
+ * The async pre-turn hook: refreshes the memory spine's recall snapshot over
75
+ * the CURRENT route (wire when adopted, local otherwise) so the synchronous
76
+ * per-turn knowledge injection (`memoryRegistry.getAll()` above) reads
77
+ * up-to-date records instead of whatever the previous refresh captured.
78
+ * Failures are swallowed — an honest stale/empty snapshot (with its own
79
+ * degradation note, surfaced via `recallSnapshot().note`) is preferable to
80
+ * blocking the user's turn on a memory-read failure.
81
+ */
82
+ export async function refreshMemoryRecallSnapshot(services: Pick<RuntimeServices, 'memorySpine'>): Promise<void> {
83
+ await services.memorySpine.refreshRecallSnapshot().catch(() => {});
84
+ }
@@ -24,6 +24,7 @@ import { MultimodalService } from '@pellux/goodvibes-sdk/platform/multimodal';
24
24
  import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
25
25
  import { AgentManager, OverflowHandler, ProcessManager, createWorkflowServices, type WorkflowServices } from '@pellux/goodvibes-sdk/platform/tools';
26
26
  import { FileStateCache, FileUndoManager, MemoryEmbeddingProviderRegistry, MemoryRegistry, MemoryStore, ModeManager, ProjectIndex, resolveCanonicalMemoryDbPath, type CodeIndexStore, type CodeIndexReindexScheduler } from '@pellux/goodvibes-sdk/platform/state';
27
+ import { MemorySpineClient, createLocalMemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
27
28
  import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
28
29
  import type { RuntimeEventBus } from '@/runtime/index.ts';
29
30
  import { createDomainDispatch } from './store/index.ts';
@@ -166,6 +167,8 @@ export interface RuntimeServices {
166
167
  readonly workPlanStore: WorkPlanStore;
167
168
  readonly memoryStore: MemoryStore;
168
169
  readonly memoryRegistry: MemoryRegistry;
170
+ /** Host-vs-client memory access: local until bootstrap.ts activates it for an adopted 'external' daemon (mirrors sessionSpine). */
171
+ readonly memorySpine: MemorySpineClient;
169
172
  readonly serviceRegistry: ServiceRegistry;
170
173
  readonly secretsManager: SecretsManager;
171
174
  readonly subscriptionManager: SubscriptionManager;
@@ -234,12 +237,7 @@ export interface RuntimeServices {
234
237
  readonly fileUndoManager: FileUndoManager;
235
238
  readonly workspaceCheckpointManager: WorkspaceCheckpointManager;
236
239
  readonly integrationHelpers: IntegrationHelperService;
237
- /**
238
- * Re-root path-bound stores (MemoryStore, ProjectIndex) to a new working directory.
239
- * Called by WorkspaceSwapManager after the new directory has been verified.
240
- * Stores that require a process restart emit a warn-level log; they continue serving
241
- * the old path until the daemon restarts with the new --working-dir.
242
- */
240
+ /** Re-root path-bound stores (MemoryStore, ProjectIndex) to a new working directory, called by WorkspaceSwapManager after verification; stores needing a process restart just warn-log and keep serving the old path until the daemon restarts with the new --working-dir. */
243
241
  rerootStores(newWorkingDir: string): Promise<void>;
244
242
  }
245
243
 
@@ -393,6 +391,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
393
391
  embeddingRegistry: memoryEmbeddingRegistry,
394
392
  });
395
393
  const memoryRegistry = new MemoryRegistry(memoryStore);
394
+ // Local-until-adopted access facade for spine-shaped consumers (the Memory
395
+ // modal): bootstrap.ts activates the wire transport when a compatible
396
+ // external daemon is adopted, same signal as the session spine.
397
+ const memorySpine = new MemorySpineClient({ local: createLocalMemoryAccess(memoryRegistry) });
396
398
  const deliveryManager = new AutomationDeliveryManager({
397
399
  configManager,
398
400
  serviceRegistry,
@@ -526,14 +528,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
526
528
  automationBridge: automationManager,
527
529
  });
528
530
 
529
- // Daemon handler surfaces: attach HOST handlers to the SDK-auto-registered
530
- // builtin gateway descriptors (channels.* / email.* / calendar.*). The SDK
531
- // owns every id/descriptor/schema; this only fills the handler slot via
532
- // catalog.register(descriptor, handler, { replace: true }). Routing returns
533
- // the resolver the inbox surface consumes; triage decorates channels.inbox.list.
534
- // The remote surface reuses the SAME DistributedRuntimeManager the SDK facade
535
- // injects, so its peer/work methods share one persistent store; remote.peers.*
536
- // stay SDK-published routes (not catalog methods).
531
+ // Daemon handler surfaces: attach HOST handlers to the SDK-auto-registered builtin
532
+ // gateway descriptors (channels.* / email.* / calendar.*) via catalog.register(descriptor,
533
+ // handler, { replace: true }) — the SDK owns every id/descriptor/schema. The remote
534
+ // surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
537
535
  const handlerLogger: HandlerLogger = {
538
536
  info: (message, meta) => console.info(message, meta ?? ''),
539
537
  warn: (message, meta) => console.warn(message, meta ?? ''),
@@ -725,6 +723,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
725
723
  workPlanStore,
726
724
  memoryStore,
727
725
  memoryRegistry,
726
+ memorySpine,
728
727
  serviceRegistry,
729
728
  secretsManager,
730
729
  subscriptionManager,
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.9.2';
9
+ let _version = '1.10.1';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;
@@ -1,271 +0,0 @@
1
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
- import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
3
- import type { TurnEvent } from '@/runtime/index.ts';
4
- import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice';
5
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
- import { TtsTextChunker } from './text-chunker.ts';
7
- import type { StreamingAudioPlayer } from './player.ts';
8
-
9
- export interface SpokenTurnControllerOptions {
10
- readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
11
- readonly configManager: Pick<ConfigManager, 'get'>;
12
- readonly player: StreamingAudioPlayer;
13
- readonly notify?: (message: string) => void;
14
- readonly now?: () => number;
15
- readonly setInterval?: typeof setInterval;
16
- readonly clearInterval?: typeof clearInterval;
17
- }
18
-
19
- export class SpokenTurnController {
20
- private pendingPrompt: string | null = null;
21
- private activeTurnId: string | null = null;
22
- private chunker: TtsTextChunker | null = null;
23
- private chunkSequence = 0;
24
- private playbackChain: Promise<void> = Promise.resolve();
25
- private readonly abortControllers = new Set<AbortController>();
26
- private timer: ReturnType<typeof setInterval> | null = null;
27
- private errorReportedForTurn = false;
28
- private noPlayerNoticed = false;
29
- private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
30
- private readonly configManager: Pick<ConfigManager, 'get'>;
31
- private readonly player: StreamingAudioPlayer;
32
- private readonly notify?: (message: string) => void;
33
- private readonly now: () => number;
34
- private readonly setIntervalImpl: typeof setInterval;
35
- private readonly clearIntervalImpl: typeof clearInterval;
36
-
37
- constructor(options: SpokenTurnControllerOptions) {
38
- this.voiceService = options.voiceService;
39
- this.configManager = options.configManager;
40
- this.player = options.player;
41
- this.notify = options.notify;
42
- this.now = options.now ?? (() => Date.now());
43
- this.setIntervalImpl = options.setInterval ?? setInterval;
44
- this.clearIntervalImpl = options.clearInterval ?? clearInterval;
45
- }
46
-
47
- submitNextTurn(prompt: string): boolean {
48
- const normalized = prompt.trim();
49
- if (!normalized) return false;
50
- this.stop();
51
- if (!this.player.available) {
52
- if (!this.noPlayerNoticed) {
53
- this.noPlayerNoticed = true;
54
- this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
55
- }
56
- return false;
57
- }
58
- // Reset the no-player notice if player becomes available again.
59
- this.noPlayerNoticed = false;
60
- this.pendingPrompt = normalized;
61
- return true;
62
- }
63
-
64
- /**
65
- * Returns whether speech was actually ACTIVE when stopped. The notice only
66
- * prints in that case — stop() on an idle controller used to notify anyway,
67
- * spamming "[TTS] Spoken output stopped." on every Ctrl+C (an earlier replay
68
- * fix); callers use the return to decide whether the press "did a
69
- * job" (see handleCtrlC's consume-on-speech-stop).
70
- */
71
- stop(message?: string): boolean {
72
- const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
73
- || this.chunker !== null || this.abortControllers.size > 0;
74
- this.pendingPrompt = null;
75
- this.activeTurnId = null;
76
- this.chunker?.reset();
77
- this.chunker = null;
78
- this.stopTimer();
79
- for (const controller of this.abortControllers) controller.abort();
80
- this.abortControllers.clear();
81
- this.player.stop();
82
- this.playbackChain = Promise.resolve();
83
- this.errorReportedForTurn = false;
84
- if (message && wasActive) this.notify?.(`[TTS] ${message}`);
85
- return wasActive;
86
- }
87
-
88
- /**
89
- * Exit-path teardown: drops everything not yet audible (pending arm,
90
- * buffered text, queued chunks) but lets the audio the user is already
91
- * hearing finish naturally, capped at `drainTimeoutMs`, before the hard
92
- * stop. Deliberate interrupts (Ctrl+C, /tts stop, turn cancel) keep their
93
- * instant path through stop(); this is only for exiting the app while the
94
- * final audio of a completed response is still draining.
95
- */
96
- async stopForExit(drainTimeoutMs = 2000): Promise<void> {
97
- this.pendingPrompt = null;
98
- this.activeTurnId = null;
99
- this.chunker?.reset();
100
- this.chunker = null;
101
- this.stopTimer();
102
- // Cancel chunks that have not started playing; the chunk currently in the
103
- // sink is not in this set (its controller is released before playback).
104
- for (const controller of this.abortControllers) controller.abort();
105
- this.abortControllers.clear();
106
- await this.player.waitForDrain(drainTimeoutMs);
107
- // Backstop: anything still alive after the window is torn down hard.
108
- this.stop();
109
- }
110
-
111
- handleTurnEvent(event: TurnEvent): void {
112
- if (event.type === 'TURN_SUBMITTED') {
113
- this.maybeStartTurn(event.turnId, event.prompt);
114
- return;
115
- }
116
- if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
117
-
118
- if (event.type === 'STREAM_DELTA') {
119
- this.enqueueChunks(this.chunker?.push(event.content) ?? []);
120
- return;
121
- }
122
- if (event.type === 'STREAM_END') {
123
- return;
124
- }
125
- if (event.type === 'TURN_COMPLETED') {
126
- this.finishTurn(event.turnId);
127
- return;
128
- }
129
- if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
130
- this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
131
- }
132
- }
133
-
134
- private maybeStartTurn(turnId: string, prompt: string): void {
135
- if (!this.pendingPrompt) return;
136
- if (prompt.trim() !== this.pendingPrompt) return;
137
- this.pendingPrompt = null;
138
- this.activeTurnId = turnId;
139
- this.chunkSequence = 0;
140
- this.errorReportedForTurn = false;
141
- this.chunker = new TtsTextChunker({ now: this.now });
142
- this.playbackChain = Promise.resolve();
143
- this.startTimer();
144
- this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
145
- }
146
-
147
- private finishTurn(turnId: string): void {
148
- if (turnId !== this.activeTurnId) return;
149
- this.enqueueChunks(this.chunker?.flushAll() ?? []);
150
- this.stopTimer();
151
- const chain = this.playbackChain;
152
- chain.finally(() => {
153
- if (this.activeTurnId !== turnId) return;
154
- this.activeTurnId = null;
155
- this.chunker = null;
156
- this.abortControllers.clear();
157
- }).catch(() => {
158
- // Errors are already reported in the queued task.
159
- });
160
- }
161
-
162
- private startTimer(): void {
163
- this.stopTimer();
164
- this.timer = this.setIntervalImpl(() => {
165
- if (!this.activeTurnId || !this.chunker) return;
166
- this.enqueueChunks(this.chunker.flushDue());
167
- }, 250);
168
- }
169
-
170
- private stopTimer(): void {
171
- if (!this.timer) return;
172
- this.clearIntervalImpl(this.timer);
173
- this.timer = null;
174
- }
175
-
176
- private enqueueChunks(chunks: readonly string[]): void {
177
- for (const chunk of chunks) {
178
- this.enqueueChunk(chunk);
179
- }
180
- }
181
-
182
- private enqueueChunk(text: string): void {
183
- const turnId = this.activeTurnId;
184
- if (!turnId || !text.trim()) return;
185
- const sequence = ++this.chunkSequence;
186
- const abortController = new AbortController();
187
- this.abortControllers.add(abortController);
188
- const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
189
- .then((result) => ({ ok: true as const, result }))
190
- .catch((error: unknown) => ({ ok: false as const, error }));
191
-
192
- this.playbackChain = this.playbackChain.then(async () => {
193
- if (abortController.signal.aborted) return;
194
- const result = await resultPromise;
195
- this.abortControllers.delete(abortController);
196
- // Re-check after the await: an abort that landed while synthesis was in
197
- // flight (deliberate stop or exit) makes the rejection expected — it
198
- // must not route into reportError, which would print a spurious error
199
- // and hard-stop a sink that may still be draining the previous chunk.
200
- if (abortController.signal.aborted) return;
201
- if (!result.ok) {
202
- this.reportError(result.error);
203
- return;
204
- }
205
- await this.player.play(result.result.chunks, {
206
- format: String(result.result.format ?? 'mp3'),
207
- signal: abortController.signal,
208
- });
209
- }).catch((error: unknown) => {
210
- this.abortControllers.delete(abortController);
211
- this.reportError(error);
212
- });
213
- }
214
-
215
- private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
216
- // tts.speed: VoiceSynthesisRequest accepts speed (number | undefined).
217
- // No ConfigKey for tts.speed exists in the current SDK schema — pending
218
- // SDK schema addition. Speed is not threaded from config until that key
219
- // is added. See docs/voice-and-live-tts.md § Speed.
220
- return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
221
- text,
222
- voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
223
- format: 'mp3',
224
- speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
225
- signal,
226
- metadata: {
227
- source: 'goodvibes-tui',
228
- feature: 'live-tts',
229
- turnId,
230
- sequence,
231
- },
232
- });
233
- }
234
-
235
- private reportError(error: unknown): void {
236
- if (this.errorReportedForTurn) return;
237
- this.errorReportedForTurn = true;
238
- this.activeTurnId = null;
239
- this.chunker = null;
240
- this.stopTimer();
241
- for (const controller of this.abortControllers) controller.abort();
242
- this.abortControllers.clear();
243
- this.player.stop();
244
- this.playbackChain = Promise.resolve();
245
- this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
246
- }
247
- }
248
-
249
- function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
250
- const value = String(configManager.get(key) ?? '').trim();
251
- return value || undefined;
252
- }
253
-
254
- /**
255
- * readOptionalConfigNumber — reads a numeric config value by key.
256
- *
257
- * `tts.speed` is not yet a ConfigKey in the SDK schema. This helper accepts
258
- * a string key and casts it, returning undefined when the value is absent,
259
- * zero, or not a finite positive number. Once `tts.speed` is added to the
260
- * SDK schema the cast can be removed and the key typed statically.
261
- *
262
- * SDK handoff note: add { key: 'tts.speed', type: 'number', default: 1,
263
- * description: '...' } to schema-domain-core.js and `tts: { ..., speed: 1 }`
264
- * to DEFAULT_CONFIG.tts to complete this feature.
265
- */
266
- function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
267
- // Cast required: key is not yet a valid ConfigKey in the SDK schema.
268
- const raw = configManager.get(key as ConfigKey);
269
- const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
270
- return isFinite(value) && value > 0 ? value : undefined;
271
- }
@@ -1,110 +0,0 @@
1
- export interface TtsTextChunkerOptions {
2
- readonly minBoundaryChars?: number;
3
- readonly maxChunkChars?: number;
4
- readonly maxLatencyMs?: number;
5
- readonly now?: () => number;
6
- }
7
-
8
- export class TtsTextChunker {
9
- private buffer = '';
10
- private firstBufferedAt: number | null = null;
11
- private readonly minBoundaryChars: number;
12
- private readonly maxChunkChars: number;
13
- private readonly maxLatencyMs: number;
14
- private readonly now: () => number;
15
-
16
- constructor(options: TtsTextChunkerOptions = {}) {
17
- this.minBoundaryChars = options.minBoundaryChars ?? 24;
18
- this.maxChunkChars = options.maxChunkChars ?? 320;
19
- this.maxLatencyMs = options.maxLatencyMs ?? 1_000;
20
- this.now = options.now ?? (() => Date.now());
21
- }
22
-
23
- push(delta: string): string[] {
24
- if (!delta) return [];
25
- if (this.firstBufferedAt === null) this.firstBufferedAt = this.now();
26
- this.buffer += delta;
27
- return this.drainReady(false);
28
- }
29
-
30
- flushDue(): string[] {
31
- if (!this.buffer.trim() || this.firstBufferedAt === null) return [];
32
- if (this.now() - this.firstBufferedAt < this.maxLatencyMs) return [];
33
- return this.drainReady(true);
34
- }
35
-
36
- flushAll(): string[] {
37
- if (!this.buffer.trim()) {
38
- this.buffer = '';
39
- this.firstBufferedAt = null;
40
- return [];
41
- }
42
- return [this.takeChunk(this.buffer.length)].filter(Boolean);
43
- }
44
-
45
- reset(): void {
46
- this.buffer = '';
47
- this.firstBufferedAt = null;
48
- }
49
-
50
- private drainReady(forceLatencyFlush: boolean): string[] {
51
- const chunks: string[] = [];
52
- while (this.buffer.trim()) {
53
- const boundary = this.findBoundary(forceLatencyFlush);
54
- if (boundary <= 0) break;
55
- const chunk = this.takeChunk(boundary);
56
- if (chunk) chunks.push(chunk);
57
- forceLatencyFlush = false;
58
- }
59
- return chunks;
60
- }
61
-
62
- private findBoundary(forceLatencyFlush: boolean): number {
63
- const latestSentence = this.findLatestSentenceBoundary();
64
- if (latestSentence >= this.minBoundaryChars) return latestSentence;
65
-
66
- if (this.buffer.length >= this.maxChunkChars) {
67
- return this.findWordBoundaryBefore(this.maxChunkChars) || this.maxChunkChars;
68
- }
69
-
70
- if (forceLatencyFlush) {
71
- return this.buffer.length;
72
- }
73
-
74
- return -1;
75
- }
76
-
77
- private findLatestSentenceBoundary(): number {
78
- let best = -1;
79
- for (let i = 0; i < this.buffer.length; i++) {
80
- const char = this.buffer[i];
81
- if (char !== '.' && char !== '!' && char !== '?' && char !== ';' && char !== ':' && char !== '\n') {
82
- continue;
83
- }
84
- const next = this.buffer[i + 1];
85
- if (i === this.buffer.length - 1 || next === undefined || /\s/.test(next)) {
86
- best = i + 1;
87
- }
88
- }
89
- return best;
90
- }
91
-
92
- private findWordBoundaryBefore(index: number): number {
93
- const max = Math.min(index, this.buffer.length);
94
- for (let i = max; i > 0; i--) {
95
- if (/\s/.test(this.buffer[i - 1] ?? '')) return i;
96
- }
97
- return -1;
98
- }
99
-
100
- private takeChunk(end: number): string {
101
- const raw = this.buffer.slice(0, end);
102
- this.buffer = this.buffer.slice(end);
103
- this.firstBufferedAt = this.buffer.trim() ? this.now() : null;
104
- return normalizeSpeechText(raw);
105
- }
106
- }
107
-
108
- export function normalizeSpeechText(text: string): string {
109
- return text.replace(/\s+/g, ' ').trim();
110
- }