@pellux/goodvibes-agent 1.5.9 → 1.6.0
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 +7 -0
- package/README.md +1 -1
- package/dist/package/main.js +11310 -4960
- package/docs/README.md +1 -1
- package/docs/voice-and-live-tts.md +47 -0
- package/package.json +2 -2
- package/src/agent/memory-prompt.ts +12 -1
- package/src/agent/prompt-context-receipts.ts +71 -6
- package/src/agent/vibe-file.ts +9 -5
- package/src/audio/spoken-turn-wiring.ts +3 -2
- package/src/cli/memory-command-wire.ts +373 -0
- package/src/cli/memory-command.ts +31 -22
- package/src/runtime/bootstrap-core.ts +2 -2
- package/src/runtime/bootstrap.ts +68 -0
- package/src/runtime/memory-spine-adoption.ts +50 -0
- package/src/runtime/memory-spine-rest-transport.ts +327 -0
- package/src/runtime/services.ts +33 -0
- package/src/tools/agent-local-registry-memory.ts +42 -11
- package/src/tools/agent-local-registry-tool.ts +5 -4
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -455
- package/src/audio/text-chunker.ts +0 -110
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
* search so an unavailable index degrades honestly (stated reason, literal
|
|
13
13
|
* fallback) instead of silently returning zero matches that read as "nothing
|
|
14
14
|
* was ever stored."
|
|
15
|
+
*
|
|
16
|
+
* MEMORY-SPINE WIRING (SDK 1.2.0 full-detach catalog). Every action below now
|
|
17
|
+
* routes through `memorySpine` — local by default, or over the wire the moment
|
|
18
|
+
* the agent has adopted a daemon (see services.ts's memorySpineClient).
|
|
19
|
+
* get/create/review/stale/delete and the literal `search` path use the five
|
|
20
|
+
* 1.1.0 core verbs (get/add/updateReview/updateReview/delete/honestSearch);
|
|
21
|
+
* `list` and `update` use the 1.2.0 extended verbs of the same name
|
|
22
|
+
* (list/update); the SEMANTIC search path uses the 1.2.0 `searchSemantic`
|
|
23
|
+
* extended verb. Only the index-health check (`registry.vectorStats()`) stays
|
|
24
|
+
* on the raw local `registry` — vector-index diagnostics are maintenance a
|
|
25
|
+
* store performs on its OWN index, so reporting them honestly means reading
|
|
26
|
+
* this process's own index, not a wire-forwarded view of the daemon's (see the
|
|
27
|
+
* CLI's identical ruling in memory-command-wire.ts). That is a deliberate,
|
|
28
|
+
* stated scope limit, not an oversight.
|
|
15
29
|
*/
|
|
16
30
|
import {
|
|
17
31
|
HASHED_MEMORY_EMBEDDING_PROVIDER,
|
|
@@ -22,6 +36,7 @@ import {
|
|
|
22
36
|
type MemorySemanticSearchResult,
|
|
23
37
|
type MemoryVectorStats,
|
|
24
38
|
} from '@pellux/goodvibes-sdk/platform/state';
|
|
39
|
+
import type { MemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
25
40
|
import { assertNoSecretLikeMemoryText } from '../agent/memory-safety.ts';
|
|
26
41
|
import { formatAgentRecordReference, formatAgentRecordReviewState } from '../agent/record-labels.ts';
|
|
27
42
|
import {
|
|
@@ -105,9 +120,16 @@ function renderMemorySearch(mode: string, query: string, lines: readonly string[
|
|
|
105
120
|
return [`Agent-local memory search (${mode})`, `query ${query || '(all)'}`, ...lines].join('\n');
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
export async function handleMemory(
|
|
123
|
+
export async function handleMemory(
|
|
124
|
+
registry: MemoryRegistry,
|
|
125
|
+
memorySpine: MemoryAccess,
|
|
126
|
+
action: AgentLocalRegistryAction,
|
|
127
|
+
args: AgentLocalRegistryToolArgs,
|
|
128
|
+
): Promise<string> {
|
|
109
129
|
if (action === 'list') {
|
|
110
|
-
|
|
130
|
+
// list() is a 1.2.0 extended verb — routes through the spine (no filter =
|
|
131
|
+
// getAll semantics, matching the previous registry.getAll() call exactly).
|
|
132
|
+
const records = await memorySpine.list();
|
|
111
133
|
return records.length === 0
|
|
112
134
|
? 'Agent-local memory\nNo Agent-local memory records.'
|
|
113
135
|
: ['Agent-local memory', ...records.map(formatMemory)].join('\n');
|
|
@@ -116,20 +138,27 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
116
138
|
const query = readString(args.query);
|
|
117
139
|
const wantsLiteral = args.semantic === false;
|
|
118
140
|
if (wantsLiteral) {
|
|
119
|
-
|
|
141
|
+
// Literal search IS wire-covered — routes through the spine (honestSearch
|
|
142
|
+
// with no `semantic` flag is exactly a literal scan; see
|
|
143
|
+
// runHonestMemorySearch in the SDK).
|
|
144
|
+
const { records } = await memorySpine.honestSearch({ query, limit: 10 });
|
|
120
145
|
return renderMemorySearch('literal — explicitly requested', query, records.map(formatMemory));
|
|
121
146
|
}
|
|
122
147
|
// SEMANTIC BY DEFAULT: natural-language recall must not depend on the query text
|
|
123
148
|
// appearing as a literal substring in the stored summary/detail. Check index
|
|
124
149
|
// health FIRST so an unavailable index degrades honestly instead of silently
|
|
125
|
-
// returning zero matches that read as "nothing was ever stored."
|
|
150
|
+
// returning zero matches that read as "nothing was ever stored." The health
|
|
151
|
+
// check itself stays on the raw local registry (vector-index diagnostics are
|
|
152
|
+
// this process's own — see the file-header note); the actual search, once the
|
|
153
|
+
// index is known healthy, is the 1.2.0 searchSemantic extended verb and
|
|
154
|
+
// routes through the spine.
|
|
126
155
|
const stats = registry.vectorStats();
|
|
127
156
|
const unavailable = describeMemoryIndexUnavailable(stats);
|
|
128
157
|
if (unavailable) {
|
|
129
158
|
const records = registry.search({ query, limit: 10 });
|
|
130
159
|
return renderMemorySearch(`literal fallback — ${unavailable}`, query, records.map(formatMemory));
|
|
131
160
|
}
|
|
132
|
-
const results =
|
|
161
|
+
const results = await memorySpine.searchSemantic({ query, limit: 10 });
|
|
133
162
|
const consultedSemanticIndex = query.length > 0 && results.some((entry) => entry.similarity > 0);
|
|
134
163
|
const caveat = describeMemoryIndexCaveat(stats);
|
|
135
164
|
const mode = !consultedSemanticIndex && query.length > 0
|
|
@@ -140,7 +169,7 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
140
169
|
return renderMemorySearch(mode, query, results.map(formatMemoryMatch));
|
|
141
170
|
}
|
|
142
171
|
if (action === 'get') {
|
|
143
|
-
const record =
|
|
172
|
+
const record = await memorySpine.get(requireId(args));
|
|
144
173
|
if (!record) return `Unknown Agent-local memory ${readString(args.id)}`;
|
|
145
174
|
return [
|
|
146
175
|
formatMemory(record),
|
|
@@ -158,7 +187,7 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
158
187
|
const tags = readStringList(args.tags);
|
|
159
188
|
const confidence = readOptionalConfidence(args.confidence);
|
|
160
189
|
assertNoSecretLikeMemoryText([summary, detail, ...tags]);
|
|
161
|
-
const record = await
|
|
190
|
+
const record = await memorySpine.add({
|
|
162
191
|
scope: readMemoryScope(args),
|
|
163
192
|
cls: requireMemoryClass(args),
|
|
164
193
|
summary,
|
|
@@ -174,11 +203,13 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
174
203
|
].join('\n');
|
|
175
204
|
}
|
|
176
205
|
if (action === 'update') {
|
|
206
|
+
// update() is a 1.2.0 extended verb (summary/detail/tags/scope patch) — routes
|
|
207
|
+
// through the spine.
|
|
177
208
|
const summary = readString(args.summary || args.description);
|
|
178
209
|
const detail = readString(args.detail || args.body);
|
|
179
210
|
const tags = args.tags === undefined ? undefined : [...readStringList(args.tags)];
|
|
180
211
|
assertNoSecretLikeMemoryText([summary, detail, ...(tags ?? [])]);
|
|
181
|
-
const record =
|
|
212
|
+
const record = await memorySpine.update(requireId(args), {
|
|
182
213
|
summary: summary || undefined,
|
|
183
214
|
detail: detail || undefined,
|
|
184
215
|
tags,
|
|
@@ -192,7 +223,7 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
192
223
|
].join('\n');
|
|
193
224
|
}
|
|
194
225
|
if (action === 'review') {
|
|
195
|
-
const record =
|
|
226
|
+
const record = await memorySpine.updateReview(requireId(args), {
|
|
196
227
|
state: 'reviewed',
|
|
197
228
|
confidence: readOptionalConfidence(args.confidence),
|
|
198
229
|
reviewedBy: 'agent',
|
|
@@ -204,7 +235,7 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
204
235
|
].join('\n');
|
|
205
236
|
}
|
|
206
237
|
if (action === 'stale') {
|
|
207
|
-
const record =
|
|
238
|
+
const record = await memorySpine.updateReview(requireId(args), {
|
|
208
239
|
state: 'stale',
|
|
209
240
|
staleReason: readString(args.reason) || 'Marked stale by Agent.',
|
|
210
241
|
});
|
|
@@ -217,7 +248,7 @@ export async function handleMemory(registry: MemoryRegistry, action: AgentLocalR
|
|
|
217
248
|
if (action === 'delete') {
|
|
218
249
|
const id = requireId(args);
|
|
219
250
|
requireConfirmedDelete(args, 'Agent-local memory');
|
|
220
|
-
if (!
|
|
251
|
+
if (!(await memorySpine.delete(id))) return `Unknown Agent-local memory ${id}`;
|
|
221
252
|
return [
|
|
222
253
|
'Deleted Agent-local memory',
|
|
223
254
|
` id ${id}`,
|
|
@@ -2,6 +2,7 @@ import type { Tool } from '@pellux/goodvibes-sdk/platform/types';
|
|
|
2
2
|
import type { ToolRegistry } from '@pellux/goodvibes-sdk/platform/tools';
|
|
3
3
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
4
4
|
import { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
5
|
+
import type { MemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
5
6
|
import { AgentPersonaRegistry, type AgentPersonaRecord } from '../agent/persona-registry.ts';
|
|
6
7
|
import { AgentNoteRegistry, type AgentNoteRecord } from '../agent/note-registry.ts';
|
|
7
8
|
import { AgentRoutineRegistry, type AgentRoutineRecord } from '../agent/routine-registry.ts';
|
|
@@ -512,7 +513,7 @@ function handleRoutine(shellPaths: ShellPathService, action: AgentLocalRegistryA
|
|
|
512
513
|
throw new Error(`Action ${action} is not valid for routines.`);
|
|
513
514
|
}
|
|
514
515
|
|
|
515
|
-
export function createAgentLocalRegistryTool(shellPaths: ShellPathService, memoryRegistry: MemoryRegistry): Tool {
|
|
516
|
+
export function createAgentLocalRegistryTool(shellPaths: ShellPathService, memoryRegistry: MemoryRegistry, memorySpine: MemoryAccess): Tool {
|
|
516
517
|
return {
|
|
517
518
|
definition: {
|
|
518
519
|
name: 'agent_local_registry',
|
|
@@ -560,7 +561,7 @@ export function createAgentLocalRegistryTool(shellPaths: ShellPathService, memor
|
|
|
560
561
|
if (!isDomain(args.domain)) return registryError(`Unknown domain. Valid values ${DOMAINS.join(', ')}.`);
|
|
561
562
|
if (!isAction(args.action)) return registryError(`Unknown action. Valid values ${ACTIONS.join(', ')}.`);
|
|
562
563
|
try {
|
|
563
|
-
if (args.domain === 'memory') return registryOutput(await handleMemory(memoryRegistry, args.action, args));
|
|
564
|
+
if (args.domain === 'memory') return registryOutput(await handleMemory(memoryRegistry, memorySpine, args.action, args));
|
|
564
565
|
if (args.domain === 'note') return registryOutput(handleNote(shellPaths, args.action, args));
|
|
565
566
|
if (args.domain === 'persona') return registryOutput(handlePersona(shellPaths, args.action, args));
|
|
566
567
|
if (args.domain === 'skill') return registryOutput(handleSkill(shellPaths, args.action, args));
|
|
@@ -573,6 +574,6 @@ export function createAgentLocalRegistryTool(shellPaths: ShellPathService, memor
|
|
|
573
574
|
};
|
|
574
575
|
}
|
|
575
576
|
|
|
576
|
-
export function registerAgentLocalRegistryTool(registry: ToolRegistry, shellPaths: ShellPathService, memoryRegistry: MemoryRegistry): void {
|
|
577
|
-
registry.register(createAgentLocalRegistryTool(shellPaths, memoryRegistry));
|
|
577
|
+
export function registerAgentLocalRegistryTool(registry: ToolRegistry, shellPaths: ShellPathService, memoryRegistry: MemoryRegistry, memorySpine: MemoryAccess): void {
|
|
578
|
+
registry.register(createAgentLocalRegistryTool(shellPaths, memoryRegistry, memorySpine));
|
|
578
579
|
}
|
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.6.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8')) as {
|
|
12
12
|
readonly version?: unknown;
|
|
@@ -1,455 +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
|
-
/**
|
|
10
|
-
* How many synthesis requests may sit in the pipeline at once (synthesizing,
|
|
11
|
-
* waiting to play, or playing). 2 = the chunk being played plus ONE prefetch,
|
|
12
|
-
* so the next audio is ready the moment the current sink drains. Bounding
|
|
13
|
-
* this is what keeps a streaming answer from bursting N concurrent requests
|
|
14
|
-
* at the voice provider — ElevenLabs plans allow as few as 3 concurrent, and
|
|
15
|
-
* an unbounded burst 429s the whole turn. The SDK config schema has no tts.*
|
|
16
|
-
* key for pipeline tuning, so this is a constant by design.
|
|
17
|
-
*/
|
|
18
|
-
const SYNTHESIS_PIPELINE_WINDOW = 2;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Upper bound for one merged synthesis request's text. The ElevenLabs
|
|
22
|
-
* provider passes request text through verbatim (no cap of its own); the API
|
|
23
|
-
* caps text per request by plan — 2,500 chars on the lowest tiers, 5,000 on
|
|
24
|
-
* most others. 1,500 stays safely under every plan while still folding a
|
|
25
|
-
* multi-paragraph answer into one or two requests.
|
|
26
|
-
*/
|
|
27
|
-
const SYNTHESIS_MERGE_MAX_CHARS = 1500;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Backoff schedule for transient synthesis failures (429 rate/concurrency
|
|
31
|
-
* limits, transient 5xx, network drops): first retry after 1s, second after
|
|
32
|
-
* 2.5s, then the chunk is skipped honestly and the turn continues. The SDK's
|
|
33
|
-
* provider errors are plain Error strings with the HTTP status embedded — no
|
|
34
|
-
* Retry-After header is exposed — so the schedule is fixed, not server-driven.
|
|
35
|
-
*/
|
|
36
|
-
const SYNTHESIS_RETRY_DELAYS_MS = [1000, 2500] as const;
|
|
37
|
-
|
|
38
|
-
export interface SpokenTurnControllerOptions {
|
|
39
|
-
readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
40
|
-
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
41
|
-
readonly player: StreamingAudioPlayer;
|
|
42
|
-
readonly notify?: (message: string) => void;
|
|
43
|
-
readonly now?: () => number;
|
|
44
|
-
readonly setInterval?: typeof setInterval;
|
|
45
|
-
readonly clearInterval?: typeof clearInterval;
|
|
46
|
-
/** Injectable clock for retry backoff timers (tests use fakes). */
|
|
47
|
-
readonly setTimeout?: typeof setTimeout;
|
|
48
|
-
readonly clearTimeout?: typeof clearTimeout;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export class SpokenTurnController {
|
|
52
|
-
private pendingPrompt: string | null = null;
|
|
53
|
-
private activeTurnId: string | null = null;
|
|
54
|
-
private chunker: TtsTextChunker | null = null;
|
|
55
|
-
private chunkSequence = 0;
|
|
56
|
-
private playbackChain: Promise<void> = Promise.resolve();
|
|
57
|
-
private readonly abortControllers = new Set<AbortController>();
|
|
58
|
-
private timer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
-
private errorReportedForTurn = false;
|
|
60
|
-
private noPlayerNoticed = false;
|
|
61
|
-
/** Chunker output waiting to be merged into a synthesis request. */
|
|
62
|
-
private pendingTexts: string[] = [];
|
|
63
|
-
/** Requests currently in the pipeline (synthesizing / waiting / playing). */
|
|
64
|
-
private pipelineDepth = 0;
|
|
65
|
-
/** Bumped on every teardown so stale pipeline releases are ignored. */
|
|
66
|
-
private pipelineGeneration = 0;
|
|
67
|
-
private pumpScheduled = false;
|
|
68
|
-
/** Set when TURN_COMPLETED arrives; the turn releases once the pipeline drains. */
|
|
69
|
-
private completedTurnId: string | null = null;
|
|
70
|
-
private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
71
|
-
private readonly configManager: Pick<ConfigManager, 'get'>;
|
|
72
|
-
private readonly player: StreamingAudioPlayer;
|
|
73
|
-
private readonly notify?: (message: string) => void;
|
|
74
|
-
private readonly now: () => number;
|
|
75
|
-
private readonly setIntervalImpl: typeof setInterval;
|
|
76
|
-
private readonly clearIntervalImpl: typeof clearInterval;
|
|
77
|
-
private readonly setTimeoutImpl: typeof setTimeout;
|
|
78
|
-
private readonly clearTimeoutImpl: typeof clearTimeout;
|
|
79
|
-
|
|
80
|
-
constructor(options: SpokenTurnControllerOptions) {
|
|
81
|
-
this.voiceService = options.voiceService;
|
|
82
|
-
this.configManager = options.configManager;
|
|
83
|
-
this.player = options.player;
|
|
84
|
-
this.notify = options.notify;
|
|
85
|
-
this.now = options.now ?? (() => Date.now());
|
|
86
|
-
this.setIntervalImpl = options.setInterval ?? setInterval;
|
|
87
|
-
this.clearIntervalImpl = options.clearInterval ?? clearInterval;
|
|
88
|
-
this.setTimeoutImpl = options.setTimeout ?? setTimeout;
|
|
89
|
-
this.clearTimeoutImpl = options.clearTimeout ?? clearTimeout;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
submitNextTurn(prompt: string): boolean {
|
|
93
|
-
const normalized = prompt.trim();
|
|
94
|
-
if (!normalized) return false;
|
|
95
|
-
this.stop();
|
|
96
|
-
if (!this.player.available) {
|
|
97
|
-
if (!this.noPlayerNoticed) {
|
|
98
|
-
this.noPlayerNoticed = true;
|
|
99
|
-
this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
// Reset the no-player notice if player becomes available again.
|
|
104
|
-
this.noPlayerNoticed = false;
|
|
105
|
-
this.pendingPrompt = normalized;
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Returns whether speech was actually ACTIVE when stopped. The notice only
|
|
111
|
-
* prints in that case — stop() on an idle controller used to notify anyway,
|
|
112
|
-
* spamming "[TTS] Spoken output stopped." on every Ctrl+C (an earlier replay
|
|
113
|
-
* fix); callers use the return to decide whether the press "did a
|
|
114
|
-
* job" (see handleCtrlC's consume-on-speech-stop).
|
|
115
|
-
*/
|
|
116
|
-
stop(message?: string): boolean {
|
|
117
|
-
const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
|
|
118
|
-
|| this.chunker !== null || this.abortControllers.size > 0;
|
|
119
|
-
this.pendingPrompt = null;
|
|
120
|
-
this.activeTurnId = null;
|
|
121
|
-
this.chunker?.reset();
|
|
122
|
-
this.chunker = null;
|
|
123
|
-
this.stopTimer();
|
|
124
|
-
this.resetPipeline();
|
|
125
|
-
for (const controller of this.abortControllers) controller.abort();
|
|
126
|
-
this.abortControllers.clear();
|
|
127
|
-
this.player.stop();
|
|
128
|
-
this.playbackChain = Promise.resolve();
|
|
129
|
-
this.errorReportedForTurn = false;
|
|
130
|
-
if (message && wasActive) this.notify?.(`[TTS] ${message}`);
|
|
131
|
-
return wasActive;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Exit-path teardown: drops everything not yet audible (pending arm,
|
|
136
|
-
* buffered text, queued chunks) but lets the audio the user is already
|
|
137
|
-
* hearing finish naturally, capped at `drainTimeoutMs`, before the hard
|
|
138
|
-
* stop. Deliberate interrupts (Ctrl+C, /tts stop, turn cancel) keep their
|
|
139
|
-
* instant path through stop(); this is only for exiting the app while the
|
|
140
|
-
* final audio of a completed response is still draining.
|
|
141
|
-
*/
|
|
142
|
-
async stopForExit(drainTimeoutMs = 2000): Promise<void> {
|
|
143
|
-
this.pendingPrompt = null;
|
|
144
|
-
this.activeTurnId = null;
|
|
145
|
-
this.chunker?.reset();
|
|
146
|
-
this.chunker = null;
|
|
147
|
-
this.stopTimer();
|
|
148
|
-
this.resetPipeline();
|
|
149
|
-
// Cancel chunks that have not started playing; the chunk currently in the
|
|
150
|
-
// sink is not in this set (its controller is released before playback).
|
|
151
|
-
for (const controller of this.abortControllers) controller.abort();
|
|
152
|
-
this.abortControllers.clear();
|
|
153
|
-
await this.player.waitForDrain(drainTimeoutMs);
|
|
154
|
-
// Backstop: anything still alive after the window is torn down hard.
|
|
155
|
-
this.stop();
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
handleTurnEvent(event: TurnEvent): void {
|
|
159
|
-
if (event.type === 'TURN_SUBMITTED') {
|
|
160
|
-
this.maybeStartTurn(event.turnId, event.prompt);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
|
|
164
|
-
|
|
165
|
-
if (event.type === 'STREAM_DELTA') {
|
|
166
|
-
this.queueTexts(this.chunker?.push(event.content) ?? []);
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (event.type === 'STREAM_END') {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
if (event.type === 'TURN_COMPLETED') {
|
|
173
|
-
this.finishTurn(event.turnId);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
|
|
177
|
-
this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private maybeStartTurn(turnId: string, prompt: string): void {
|
|
182
|
-
if (!this.pendingPrompt) return;
|
|
183
|
-
if (prompt.trim() !== this.pendingPrompt) return;
|
|
184
|
-
this.pendingPrompt = null;
|
|
185
|
-
this.activeTurnId = turnId;
|
|
186
|
-
this.chunkSequence = 0;
|
|
187
|
-
this.errorReportedForTurn = false;
|
|
188
|
-
this.chunker = new TtsTextChunker({ now: this.now });
|
|
189
|
-
this.playbackChain = Promise.resolve();
|
|
190
|
-
this.resetPipeline();
|
|
191
|
-
this.startTimer();
|
|
192
|
-
this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private finishTurn(turnId: string): void {
|
|
196
|
-
if (turnId !== this.activeTurnId) return;
|
|
197
|
-
this.queueTexts(this.chunker?.flushAll() ?? []);
|
|
198
|
-
this.stopTimer();
|
|
199
|
-
this.completedTurnId = turnId;
|
|
200
|
-
// Nothing pending and nothing in flight releases immediately; otherwise
|
|
201
|
-
// the last pipeline slot to free performs the release.
|
|
202
|
-
this.maybeReleaseTurn();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private resetPipeline(): void {
|
|
206
|
-
this.pendingTexts = [];
|
|
207
|
-
this.pipelineDepth = 0;
|
|
208
|
-
this.pipelineGeneration++;
|
|
209
|
-
this.completedTurnId = null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
private startTimer(): void {
|
|
213
|
-
this.stopTimer();
|
|
214
|
-
this.timer = this.setIntervalImpl(() => {
|
|
215
|
-
if (!this.activeTurnId || !this.chunker) return;
|
|
216
|
-
this.queueTexts(this.chunker.flushDue());
|
|
217
|
-
}, 250);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
private stopTimer(): void {
|
|
221
|
-
if (!this.timer) return;
|
|
222
|
-
this.clearIntervalImpl(this.timer);
|
|
223
|
-
this.timer = null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Chunker output does NOT map 1:1 to synthesis requests. Text queues here
|
|
228
|
-
* and the pump merges everything pending into one request whenever a
|
|
229
|
-
* pipeline slot is free — so the request count tracks how often the model
|
|
230
|
-
* out-paces the audio, not how many sentences it wrote. A short answer that
|
|
231
|
-
* arrives before the first pump tick is exactly one request.
|
|
232
|
-
*/
|
|
233
|
-
private queueTexts(chunks: readonly string[]): void {
|
|
234
|
-
for (const chunk of chunks) {
|
|
235
|
-
if (chunk.trim()) this.pendingTexts.push(chunk);
|
|
236
|
-
}
|
|
237
|
-
if (this.pendingTexts.length > 0) this.schedulePump();
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Deferred one tick so text delivered in the same synchronous burst (fast
|
|
242
|
-
* deltas, or a turn that completes instantly) coalesces into a single
|
|
243
|
-
* request instead of firing per sentence boundary.
|
|
244
|
-
*/
|
|
245
|
-
private schedulePump(): void {
|
|
246
|
-
if (this.pumpScheduled) return;
|
|
247
|
-
this.pumpScheduled = true;
|
|
248
|
-
queueMicrotask(() => {
|
|
249
|
-
this.pumpScheduled = false;
|
|
250
|
-
this.pump();
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private pump(): void {
|
|
255
|
-
while (this.activeTurnId && this.pendingTexts.length > 0 && this.pipelineDepth < SYNTHESIS_PIPELINE_WINDOW) {
|
|
256
|
-
this.dispatchChunk(this.takeMergedText());
|
|
257
|
-
}
|
|
258
|
-
this.maybeReleaseTurn();
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Merge everything pending into one request, capped at the per-request text limit. */
|
|
262
|
-
private takeMergedText(): string {
|
|
263
|
-
let merged = '';
|
|
264
|
-
while (this.pendingTexts.length > 0) {
|
|
265
|
-
const next = this.pendingTexts[0]!;
|
|
266
|
-
if (!merged && next.length > SYNTHESIS_MERGE_MAX_CHARS) {
|
|
267
|
-
// A single oversized entry (e.g. a large end-of-turn flush): split at
|
|
268
|
-
// a word boundary under the per-request cap; the rest stays queued.
|
|
269
|
-
const cut = findSplitIndex(next, SYNTHESIS_MERGE_MAX_CHARS);
|
|
270
|
-
this.pendingTexts[0] = next.slice(cut).trim();
|
|
271
|
-
return next.slice(0, cut).trim();
|
|
272
|
-
}
|
|
273
|
-
if (merged && merged.length + 1 + next.length > SYNTHESIS_MERGE_MAX_CHARS) break;
|
|
274
|
-
merged = merged ? `${merged} ${next}` : next;
|
|
275
|
-
this.pendingTexts.shift();
|
|
276
|
-
}
|
|
277
|
-
return merged;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
private dispatchChunk(text: string): void {
|
|
281
|
-
const turnId = this.activeTurnId;
|
|
282
|
-
if (!turnId || !text.trim()) return;
|
|
283
|
-
const sequence = ++this.chunkSequence;
|
|
284
|
-
const generation = this.pipelineGeneration;
|
|
285
|
-
this.pipelineDepth++;
|
|
286
|
-
const abortController = new AbortController();
|
|
287
|
-
this.abortControllers.add(abortController);
|
|
288
|
-
const resultPromise = this.synthesizeWithRetry(text, turnId, sequence, abortController.signal)
|
|
289
|
-
.then((result) => ({ ok: true as const, result }))
|
|
290
|
-
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
291
|
-
|
|
292
|
-
this.playbackChain = this.playbackChain.then(async () => {
|
|
293
|
-
try {
|
|
294
|
-
if (abortController.signal.aborted) {
|
|
295
|
-
this.abortControllers.delete(abortController);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
const result = await resultPromise;
|
|
299
|
-
this.abortControllers.delete(abortController);
|
|
300
|
-
// Re-check after the await: an abort that landed while synthesis was
|
|
301
|
-
// in flight (deliberate stop or exit) makes the rejection expected —
|
|
302
|
-
// it must not be reported, and it must not hard-stop a sink that may
|
|
303
|
-
// still be draining the previous chunk.
|
|
304
|
-
if (abortController.signal.aborted) return;
|
|
305
|
-
if (!result.ok) {
|
|
306
|
-
// Retries are exhausted (or the failure was not transient). Skip
|
|
307
|
-
// just this chunk and keep speaking the rest of the turn — a gap in
|
|
308
|
-
// speech beats losing the whole response.
|
|
309
|
-
this.reportSkippedChunk(result.error);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
await this.player.play(result.result.chunks, {
|
|
313
|
-
format: String(result.result.format ?? 'mp3'),
|
|
314
|
-
signal: abortController.signal,
|
|
315
|
-
});
|
|
316
|
-
} finally {
|
|
317
|
-
this.releasePipelineSlot(generation);
|
|
318
|
-
}
|
|
319
|
-
}).catch((error: unknown) => {
|
|
320
|
-
this.abortControllers.delete(abortController);
|
|
321
|
-
this.reportError(error);
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private releasePipelineSlot(generation: number): void {
|
|
326
|
-
if (generation !== this.pipelineGeneration) return;
|
|
327
|
-
this.pipelineDepth = Math.max(0, this.pipelineDepth - 1);
|
|
328
|
-
if (this.pendingTexts.length > 0) {
|
|
329
|
-
this.schedulePump();
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
this.maybeReleaseTurn();
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
private maybeReleaseTurn(): void {
|
|
336
|
-
if (!this.completedTurnId || this.completedTurnId !== this.activeTurnId) return;
|
|
337
|
-
if (this.pendingTexts.length > 0 || this.pipelineDepth > 0 || this.pumpScheduled) return;
|
|
338
|
-
this.activeTurnId = null;
|
|
339
|
-
this.completedTurnId = null;
|
|
340
|
-
this.chunker = null;
|
|
341
|
-
this.abortControllers.clear();
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
private async synthesizeWithRetry(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
345
|
-
for (let attempt = 0; ; attempt++) {
|
|
346
|
-
try {
|
|
347
|
-
return await this.synthesize(text, turnId, sequence, signal);
|
|
348
|
-
} catch (error) {
|
|
349
|
-
const retryable = attempt < SYNTHESIS_RETRY_DELAYS_MS.length
|
|
350
|
-
&& !signal.aborted
|
|
351
|
-
&& isTransientSynthesisError(error);
|
|
352
|
-
if (!retryable) throw error;
|
|
353
|
-
await this.delay(SYNTHESIS_RETRY_DELAYS_MS[attempt]!, signal);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Abortable backoff sleep — an abort clears the timer and rejects, so a stop mid-backoff leaves nothing running. */
|
|
359
|
-
private delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
360
|
-
return new Promise((resolve, reject) => {
|
|
361
|
-
if (signal.aborted) {
|
|
362
|
-
reject(new Error('Synthesis retry cancelled'));
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const timer = this.setTimeoutImpl(() => {
|
|
366
|
-
signal.removeEventListener('abort', onAbort);
|
|
367
|
-
resolve();
|
|
368
|
-
}, ms);
|
|
369
|
-
const onAbort = () => {
|
|
370
|
-
this.clearTimeoutImpl(timer);
|
|
371
|
-
reject(new Error('Synthesis retry cancelled'));
|
|
372
|
-
};
|
|
373
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
378
|
-
return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
|
|
379
|
-
text,
|
|
380
|
-
voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
|
|
381
|
-
format: 'mp3',
|
|
382
|
-
speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
|
|
383
|
-
signal,
|
|
384
|
-
metadata: {
|
|
385
|
-
source: 'goodvibes-agent',
|
|
386
|
-
feature: 'live-tts',
|
|
387
|
-
turnId,
|
|
388
|
-
sequence,
|
|
389
|
-
},
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* One synthesis request failed after its retries. Report once per turn and
|
|
395
|
-
* keep going — the rest of the response still plays.
|
|
396
|
-
*/
|
|
397
|
-
private reportSkippedChunk(error: unknown): void {
|
|
398
|
-
if (this.errorReportedForTurn) return;
|
|
399
|
-
this.errorReportedForTurn = true;
|
|
400
|
-
this.notify?.(`[TTS] Skipping part of the spoken response — synthesis kept failing (${summarizeError(error)}). Playback continues with the rest.`);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
private reportError(error: unknown): void {
|
|
404
|
-
if (this.errorReportedForTurn) return;
|
|
405
|
-
this.errorReportedForTurn = true;
|
|
406
|
-
this.activeTurnId = null;
|
|
407
|
-
this.chunker = null;
|
|
408
|
-
this.stopTimer();
|
|
409
|
-
this.resetPipeline();
|
|
410
|
-
for (const controller of this.abortControllers) controller.abort();
|
|
411
|
-
this.abortControllers.clear();
|
|
412
|
-
this.player.stop();
|
|
413
|
-
this.playbackChain = Promise.resolve();
|
|
414
|
-
this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Transient = worth a bounded retry: rate/concurrency limits (HTTP 429),
|
|
420
|
-
* transient server errors (5xx), and network-level drops. The SDK's voice
|
|
421
|
-
* providers throw plain Error strings with the HTTP status embedded in the
|
|
422
|
-
* message, so classification is by message content.
|
|
423
|
-
*/
|
|
424
|
-
function isTransientSynthesisError(error: unknown): boolean {
|
|
425
|
-
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
426
|
-
if (message.includes('429') || message.includes('rate limit') || message.includes('rate_limit')
|
|
427
|
-
|| message.includes('too many requests') || message.includes('concurrent')) return true;
|
|
428
|
-
if (/http 5\d\d/.test(message)) return true;
|
|
429
|
-
return message.includes('fetch failed') || message.includes('network')
|
|
430
|
-
|| message.includes('timed out') || message.includes('timeout')
|
|
431
|
-
|| message.includes('econnreset') || message.includes('socket');
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** Split point at or under `limit`, preferring the last word boundary. */
|
|
435
|
-
function findSplitIndex(text: string, limit: number): number {
|
|
436
|
-
const space = text.lastIndexOf(' ', limit);
|
|
437
|
-
return space > 0 ? space : limit;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
|
|
441
|
-
const value = String(configManager.get(key) ?? '').trim();
|
|
442
|
-
return value || undefined;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* readOptionalConfigNumber — reads a numeric config value by key.
|
|
447
|
-
*
|
|
448
|
-
* Accepts a string key and casts it, returning undefined when the value is
|
|
449
|
-
* absent, zero, or not a finite positive number.
|
|
450
|
-
*/
|
|
451
|
-
function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
|
|
452
|
-
const raw = configManager.get(key as ConfigKey);
|
|
453
|
-
const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
454
|
-
return isFinite(value) && value > 0 ? value : undefined;
|
|
455
|
-
}
|