@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.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -2
- package/docs/foundation-artifacts/knowledge-graphql.graphql +5 -0
- package/docs/foundation-artifacts/knowledge-store.sql +33 -0
- package/docs/foundation-artifacts/operator-contract.json +4026 -1376
- package/package.json +2 -2
- package/src/audio/spoken-turn-wiring.ts +7 -2
- package/src/cli/management-utils.ts +4 -0
- package/src/export/cost-utils.ts +9 -1
- package/src/input/command-registry.ts +11 -0
- package/src/input/commands/control-room-runtime.ts +12 -5
- package/src/input/commands/incident-runtime.ts +2 -2
- package/src/input/commands/memory.ts +11 -11
- package/src/input/commands/recall-bundle.ts +8 -8
- package/src/input/commands/recall-capture.ts +3 -3
- package/src/input/commands/recall-query.ts +41 -15
- package/src/input/commands/recall-review.ts +10 -10
- package/src/input/settings-modal-data.ts +54 -0
- package/src/main.ts +3 -2
- package/src/panels/builtin/shared.ts +3 -3
- package/src/panels/cost-tracker-panel.ts +2 -2
- package/src/panels/modals/memory-modal.ts +77 -16
- package/src/runtime/bootstrap-command-context.ts +4 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -1
- package/src/runtime/bootstrap-shell.ts +15 -4
- package/src/runtime/bootstrap.ts +18 -18
- package/src/runtime/memory-spine-transport.ts +289 -0
- package/src/runtime/orchestrator-core-services.ts +27 -2
- package/src/runtime/services.ts +13 -14
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -271
- 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
|
-
| '
|
|
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.
|
|
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
|
+
}
|
package/src/runtime/services.ts
CHANGED
|
@@ -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
|
-
//
|
|
531
|
-
// owns every id/descriptor/schema
|
|
532
|
-
//
|
|
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
|
+
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
|
-
}
|