@pellux/goodvibes-agent 1.5.8 → 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 +14 -0
- package/README.md +1 -1
- package/dist/package/main.js +11404 -4834
- 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/player.ts +91 -5
- package/src/audio/spoken-turn-wiring.ts +15 -3
- package/src/cli/memory-command-wire.ts +373 -0
- package/src/cli/memory-command.ts +31 -22
- package/src/main.ts +24 -29
- 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/runtime/unhandled-rejection-guard.ts +41 -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 -203
- package/src/audio/text-chunker.ts +0 -110
package/docs/README.md
CHANGED
|
@@ -53,3 +53,50 @@ Leaving `tts.voice` empty lets the provider choose its default voice.
|
|
|
53
53
|
Spoken responses are conversation output. They are not automatically written to Agent Knowledge, local memory, default knowledge, or any other product segment.
|
|
54
54
|
|
|
55
55
|
If a spoken result should become durable, store it through an explicit Agent memory command or an Agent Knowledge ingestion path.
|
|
56
|
+
|
|
57
|
+
## Platform Voice-Config Cohesion
|
|
58
|
+
|
|
59
|
+
The `tts.*` config keys (`tts.provider`, `tts.voice`, `tts.speed`, `tts.llmProvider`,
|
|
60
|
+
`tts.llmModel`) are defined once, in the shared GoodVibes SDK config schema, and read
|
|
61
|
+
identically by every surface — Agent, TUI, and the daemon. Agent does not define its
|
|
62
|
+
own voice-config schema and does not read tts.* through any path other than the
|
|
63
|
+
standard `ConfigManager.get`/`set` API that every other Agent setting uses. Changing
|
|
64
|
+
`tts.provider` through `/config tts.provider` or `settings action:"set"` changes the
|
|
65
|
+
exact same key a TUI user would change through its own `/config` surface — the
|
|
66
|
+
key name, type, and default are one contract, not two independently-maintained ones.
|
|
67
|
+
`src/test/audio/voice-config-cohesion.test.ts` is the regression guard: it fails if
|
|
68
|
+
the Agent ever reads a tts.* key that isn't in the shared schema, or if a tts.*
|
|
69
|
+
reader stops importing `ConfigManager` from the shared SDK package.
|
|
70
|
+
|
|
71
|
+
What "shared" does **not** mean here: each surface still persists its *values* to its
|
|
72
|
+
own settings file (Agent's under its own surface root, TUI's under its own) — that is
|
|
73
|
+
the existing, general-purpose per-surface config storage model, not something voice-
|
|
74
|
+
specific, and changing it is out of scope for this ruling. "Shared" means the
|
|
75
|
+
schema/contract (the key names, types, and defaults) is one definition used by every
|
|
76
|
+
surface, so the same key always means the same thing and takes the same kind of
|
|
77
|
+
value everywhere — a user (or an operator script) setting `tts.voice` on one surface
|
|
78
|
+
is setting the same conceptual value the other surfaces would read under that name,
|
|
79
|
+
even though each surface keeps its own copy of the setting today.
|
|
80
|
+
|
|
81
|
+
Two related rulings, made for this parity pass:
|
|
82
|
+
|
|
83
|
+
- **Local synthesis stays local.** Agent synthesizes speech directly against the
|
|
84
|
+
configured provider (through the shared `VoiceService`/`VoiceProviderRegistry`) via
|
|
85
|
+
a local `mpv`/`ffplay` subprocess, rather than routing playback through the daemon's
|
|
86
|
+
`voice.tts`/`voice.tts.stream` HTTP routes. This is deliberate, not an oversight:
|
|
87
|
+
Agent is a terminal tool that must keep working when there is no daemon running
|
|
88
|
+
(offline use), so voice output cannot depend on a daemon round-trip. The daemon's
|
|
89
|
+
voice routes exist for network consumers (the web UI); Agent's local-first design
|
|
90
|
+
is the reason it doesn't use them, and the config it reads to pick a provider is
|
|
91
|
+
still the one shared contract described above.
|
|
92
|
+
- **Mic/STT input is an honest no-op in Agent, not a gap.** Agent has no
|
|
93
|
+
`getUserMedia`-equivalent microphone capture and does not call `voice.stt` or
|
|
94
|
+
`voice.realtime.session`. This is by design: Agent is a terminal application with
|
|
95
|
+
keyboard/text as its primary input surface, and the web UI is the platform's
|
|
96
|
+
intended owner of mic-based voice input (browser `getUserMedia`/`MediaRecorder`).
|
|
97
|
+
Agent already reports this honestly rather than implying a capability that doesn't
|
|
98
|
+
exist — see the wake-word/always-listening `not-published` posture described above,
|
|
99
|
+
which applies to mic input broadly, not only wake-word. If a future certified,
|
|
100
|
+
permission-scoped mic contract is published for terminal surfaces, this ruling is
|
|
101
|
+
the one to revisit; until then, building a partial mic flow in Agent would be
|
|
102
|
+
worse than not building one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "GoodVibes personal operator assistant TUI with a proactive Agent product brain, isolated Agent Knowledge, local profiles, routines, skills, personas, and explicit build delegation.",
|
|
6
6
|
"type": "module",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@pellux/goodvibes-sdk": "1.
|
|
95
|
+
"@pellux/goodvibes-sdk": "1.3.1",
|
|
96
96
|
"sql.js": "^1.14.1",
|
|
97
97
|
"sqlite-vec": "^0.1.9",
|
|
98
98
|
"zustand": "^5.0.12",
|
|
@@ -149,11 +149,22 @@ export interface BuildReviewedMemoryPromptOptions {
|
|
|
149
149
|
/** The current turn's raw text (the seam this comes from: TURN_SUBMITTED's `prompt`).
|
|
150
150
|
* Used only to RANK the already-eligible set — never to admit an otherwise-ineligible record. */
|
|
151
151
|
readonly turnText?: string | null;
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetched record set to use instead of `memoryRegistry.getAll()` — e.g. a
|
|
154
|
+
* memory-spine recall snapshot's records (SDK 1.2.0 sync-recall seam; see
|
|
155
|
+
* prompt-context-receipts.ts's `resolveMemoryRecords`). `memoryRegistry` is still
|
|
156
|
+
* used for the per-turn semantic ranking query (`rankMemoryForTurn`'s
|
|
157
|
+
* `vectorStats`/`searchSemantic` calls stay local-direct regardless of where the
|
|
158
|
+
* record set came from) — only the raw eligible set that ranking runs OVER is
|
|
159
|
+
* swappable, so the injected prompt text and any receipt describing it are always
|
|
160
|
+
* derived from the exact same record set instead of two independent reads.
|
|
161
|
+
*/
|
|
162
|
+
readonly records?: readonly MemoryRecord[];
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
export function buildReviewedMemoryPrompt(memoryRegistry: MemoryRegistry, options: BuildReviewedMemoryPromptOptions = {}): string | null {
|
|
155
166
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
156
|
-
const eligible = memoryRegistry.getAll().filter(isPromptActiveMemory);
|
|
167
|
+
const eligible = (options.records ?? memoryRegistry.getAll()).filter(isPromptActiveMemory);
|
|
157
168
|
const ranking = rankMemoryForTurn(memoryRegistry, eligible, options.turnText);
|
|
158
169
|
const records = ranking.records.slice(0, Math.max(0, limit));
|
|
159
170
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
4
|
+
import type { MemoryRecord, MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
|
|
5
|
+
import type { MemoryRecallSnapshot } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
5
6
|
import { getTierForContextWindow, getTierPromptSupplement } from '@pellux/goodvibes-sdk/platform/providers';
|
|
6
7
|
import type { ShellPathService } from '@/runtime/index.ts';
|
|
7
8
|
import { buildReviewedMemoryPrompt, describeMemoryPromptEligibility, isPromptActiveMemory, rankMemoryForTurn, relevanceBand } from './memory-prompt.ts';
|
|
@@ -77,6 +78,26 @@ export interface RuntimePromptCompositionInput {
|
|
|
77
78
|
* already-eligible memory set by relevance to this turn — see rankMemoryForTurn.
|
|
78
79
|
* Null/undefined when there is no active turn (e.g. a follow-up composition). */
|
|
79
80
|
readonly turnText?: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* The memory spine's cached recall snapshot (SDK 1.2.0 sync-recall seam), when the
|
|
83
|
+
* caller has one to offer.
|
|
84
|
+
*
|
|
85
|
+
* SYNC-RECALL SEAM (memory-spine full-detach adoption, SDK 1.2.0): this function is
|
|
86
|
+
* called synchronously from the SDK's `Orchestrator.getSystemPrompt` callback, which
|
|
87
|
+
* the SDK defines as non-async, while a wire client's memory reads are async. Rather
|
|
88
|
+
* than reading `memoryRegistry.getAll()` directly (a frozen local snapshot that would
|
|
89
|
+
* silently miss whatever the daemon wrote after this process last opened its own
|
|
90
|
+
* copy — the exact failure the 1.1.0-era `memorySpineMode` degrade note used to flag),
|
|
91
|
+
* the caller drives an ASYNC pre-turn hook (`MemorySpineClient.refreshRecallSnapshot`)
|
|
92
|
+
* and passes the resulting freshness-stamped `MemoryRecallSnapshot` in here. When
|
|
93
|
+
* present, its `records` (captured with `{ recall: false }` — an unfiltered browse
|
|
94
|
+
* set, matching the old `getAll()` semantics exactly) replace the direct registry
|
|
95
|
+
* read, and its own honest `stale`/`mode`/`note` fields drive the 'memory' receipt
|
|
96
|
+
* segment's note instead of the old blanket "client mode is always degraded" note.
|
|
97
|
+
* `undefined` falls back to reading `memoryRegistry` directly (e.g. a caller that
|
|
98
|
+
* has not wired the spine, or an existing test) — the pre-1.2.0 behavior, unchanged.
|
|
99
|
+
*/
|
|
100
|
+
readonly memoryRecallSnapshot?: MemoryRecallSnapshot;
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
const RECEIPT_STORE_LIMIT = 200;
|
|
@@ -104,6 +125,29 @@ function joinPromptParts(...parts: Array<string | null | undefined>): string {
|
|
|
104
125
|
return parts.map((part) => part?.trim()).filter((part): part is string => Boolean(part)).join('\n\n');
|
|
105
126
|
}
|
|
106
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The honest freshness note for the 'memory' receipt segment, sourced from the
|
|
130
|
+
* memory spine's own recall snapshot (see RuntimePromptCompositionInput.
|
|
131
|
+
* memoryRecallSnapshot). Surfaced only when there is something worth flagging —
|
|
132
|
+
* the snapshot came from a wire-adopted daemon (worth knowing regardless of
|
|
133
|
+
* freshness, since the wire's own copy is what was captured, not this process's),
|
|
134
|
+
* or the snapshot is stale (including "never yet captured", which the snapshot
|
|
135
|
+
* itself always reports as stale). `undefined` for the boring common case (local
|
|
136
|
+
* mode, freshly captured) — matching the pre-1.2.0 note's "nothing to degrade"
|
|
137
|
+
* behavior — or when the caller passed no snapshot at all.
|
|
138
|
+
*/
|
|
139
|
+
function memoryRecallSnapshotNote(snapshot: MemoryRecallSnapshot | undefined): string | undefined {
|
|
140
|
+
if (!snapshot) return undefined;
|
|
141
|
+
if (snapshot.mode !== 'client' && !snapshot.stale) return undefined;
|
|
142
|
+
return snapshot.note;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Combines up to two optional short notes with a single separator; undefined when both are absent. */
|
|
146
|
+
function combineNotes(first: string | null | undefined, second: string | null | undefined): string | undefined {
|
|
147
|
+
const parts = [first, second].map((part) => part?.trim()).filter((part): part is string => Boolean(part));
|
|
148
|
+
return parts.length > 0 ? parts.join(' — also: ') : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
107
151
|
function modelLabel(model: unknown): string {
|
|
108
152
|
if (typeof model === 'string' && model.trim()) return model.trim();
|
|
109
153
|
if (typeof model === 'object' && model !== null) {
|
|
@@ -138,6 +182,18 @@ function receiptSegment(input: Omit<PromptContextReceiptSegment, 'approxTokens'>
|
|
|
138
182
|
};
|
|
139
183
|
}
|
|
140
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Read via the memory spine's cached recall snapshot when the caller has one (SDK
|
|
187
|
+
* 1.2.0 sync-recall seam — see RuntimePromptCompositionInput.memoryRecallSnapshot);
|
|
188
|
+
* fall back to the direct registry read otherwise (unchanged pre-1.2.0 behavior).
|
|
189
|
+
* Shared by the prompt text build and the receipt segment build so both derive
|
|
190
|
+
* from the exact same record set — the receipt must describe what was actually
|
|
191
|
+
* injected, never a different snapshot than the prompt text itself used.
|
|
192
|
+
*/
|
|
193
|
+
function resolveMemoryRecords(input: RuntimePromptCompositionInput): readonly MemoryRecord[] {
|
|
194
|
+
return input.memoryRecallSnapshot ? input.memoryRecallSnapshot.records : input.memoryRegistry.getAll();
|
|
195
|
+
}
|
|
196
|
+
|
|
141
197
|
function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput): readonly PromptContextReceiptSegment[] {
|
|
142
198
|
const vibe = discoverVibeFiles(input.shellPaths);
|
|
143
199
|
// The VIBE prompt is a PROJECTION of persona/constraint records, not a
|
|
@@ -145,13 +201,13 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
|
|
|
145
201
|
const vibePrompt = buildVibeProjectionPrompt(input.memoryRegistry) ?? '';
|
|
146
202
|
const projectContext = discoverProjectContextFiles(input.shellPaths);
|
|
147
203
|
const projectContextPrompt = buildProjectContextPrompt(input.shellPaths) ?? '';
|
|
148
|
-
const memoryRecords = input
|
|
204
|
+
const memoryRecords = resolveMemoryRecords(input);
|
|
149
205
|
const eligibleMemory = memoryRecords.filter(isPromptActiveMemory);
|
|
150
206
|
const memoryRanking = rankMemoryForTurn(input.memoryRegistry, eligibleMemory, input.turnText);
|
|
151
207
|
const activeMemory = memoryRanking.records.slice(0, 10);
|
|
152
208
|
const activeMemoryIds = new Set(activeMemory.map((record) => record.id));
|
|
153
209
|
const suppressedMemory = memoryRecords.filter((record) => !activeMemoryIds.has(record.id));
|
|
154
|
-
const memoryPrompt = buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText }) ?? '';
|
|
210
|
+
const memoryPrompt = buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText, records: memoryRecords }) ?? '';
|
|
155
211
|
const routineSnapshot = AgentRoutineRegistry.fromShellPaths(input.shellPaths).snapshot();
|
|
156
212
|
const activeRoutines = routineSnapshot.enabledRoutines.filter((routine) => routine.reviewState === 'reviewed' && evaluateAgentRoutineReadiness(routine).ready);
|
|
157
213
|
const suppressedRoutines = routineSnapshot.enabledRoutines.filter((routine) => !activeRoutines.some((active) => active.id === routine.id));
|
|
@@ -237,8 +293,17 @@ function buildRuntimePromptReceiptSegments(input: RuntimePromptCompositionInput)
|
|
|
237
293
|
// Honest degrade note: when per-turn relevance scoring did not
|
|
238
294
|
// run — no active-turn text, semantic index unavailable, or no vector match —
|
|
239
295
|
// say so instead of silently presenting the fallback confidence/recency order as
|
|
240
|
-
// if it were a relevance ranking.
|
|
241
|
-
note
|
|
296
|
+
// if it were a relevance ranking. Combined with the memory spine's own
|
|
297
|
+
// freshness note (see memoryRecallSnapshotNote) when the recall snapshot is
|
|
298
|
+
// wire-sourced or stale — the ranking itself still runs against
|
|
299
|
+
// `input.memoryRegistry` directly (rankMemoryForTurn's per-turn semantic query
|
|
300
|
+
// needs a live vectorStats/searchSemantic call, which stays local-direct by the
|
|
301
|
+
// same vector-diagnostics ruling used elsewhere in the 1.2.0 adoption), so this
|
|
302
|
+
// note is about the RECORD SET's freshness, not the ranking.
|
|
303
|
+
note: combineNotes(
|
|
304
|
+
memoryRanking.scored ? undefined : memoryRanking.degradedReason,
|
|
305
|
+
memoryRecallSnapshotNote(input.memoryRecallSnapshot),
|
|
306
|
+
),
|
|
242
307
|
selected: activeMemory.map((record) => ({
|
|
243
308
|
id: record.id,
|
|
244
309
|
scope: record.scope,
|
|
@@ -346,7 +411,7 @@ export function composeRuntimePromptWithReceipt(input: RuntimePromptCompositionI
|
|
|
346
411
|
buildVibeProjectionPrompt(input.memoryRegistry),
|
|
347
412
|
buildProjectContextPrompt(input.shellPaths),
|
|
348
413
|
input.operatorPolicy,
|
|
349
|
-
buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText }),
|
|
414
|
+
buildReviewedMemoryPrompt(input.memoryRegistry, { turnText: input.turnText, records: resolveMemoryRecords(input) }),
|
|
350
415
|
buildEnabledRoutinesPrompt(input.shellPaths),
|
|
351
416
|
buildEnabledSkillsPrompt(input.shellPaths),
|
|
352
417
|
buildActivePersonaPrompt(input.shellPaths),
|
package/src/agent/vibe-file.ts
CHANGED
|
@@ -10,7 +10,11 @@ import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-f
|
|
|
10
10
|
// the same '## VIBE.md' block from those records (caveat preserved); the file is
|
|
11
11
|
// demoted to an import/export FORMAT folded in via vibeBodyToConstraintOptions.
|
|
12
12
|
import { renderVibeProjection, vibeBodyToConstraintOptions } from '@pellux/goodvibes-sdk/platform/state';
|
|
13
|
-
import type { MemoryRecord,
|
|
13
|
+
import type { MemoryRecord, MemoryScope } from '@pellux/goodvibes-sdk/platform/state';
|
|
14
|
+
// Writes go through the memory-spine's MemoryAccess surface (add), not the raw
|
|
15
|
+
// MemoryRegistry, so this import folds correctly whether the agent is local or has
|
|
16
|
+
// adopted a daemon — see services.ts's memorySpineClient.
|
|
17
|
+
import type { MemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
|
|
14
18
|
|
|
15
19
|
export type AgentVibeScope = 'project' | 'global';
|
|
16
20
|
|
|
@@ -235,7 +239,7 @@ export function buildVibeProjectionPrompt(memoryRecords: { getAll(): readonly Me
|
|
|
235
239
|
* this as a one-time migration, not every boot (mirrors the memory fold precedent).
|
|
236
240
|
*/
|
|
237
241
|
export async function importVibeFilesIntoMemory(
|
|
238
|
-
|
|
242
|
+
memorySpine: MemoryAccess,
|
|
239
243
|
shellPaths: AgentVibePaths,
|
|
240
244
|
): Promise<number> {
|
|
241
245
|
const snapshot = discoverVibeFiles(shellPaths);
|
|
@@ -249,7 +253,7 @@ export async function importVibeFilesIntoMemory(
|
|
|
249
253
|
sourceRef: file.path,
|
|
250
254
|
});
|
|
251
255
|
for (const opts of options) {
|
|
252
|
-
await
|
|
256
|
+
await memorySpine.add(opts);
|
|
253
257
|
created += 1;
|
|
254
258
|
}
|
|
255
259
|
}
|
|
@@ -306,7 +310,7 @@ function hashVibeBody(body: string): string {
|
|
|
306
310
|
* Returns the number of records created this run (0 when everything is already migrated).
|
|
307
311
|
*/
|
|
308
312
|
export async function importVibeFilesIntoMemoryOnce(
|
|
309
|
-
|
|
313
|
+
memorySpine: MemoryAccess,
|
|
310
314
|
shellPaths: AgentVibePaths,
|
|
311
315
|
): Promise<number> {
|
|
312
316
|
const markerPath = vibeImportMarkerPath(shellPaths);
|
|
@@ -326,7 +330,7 @@ export async function importVibeFilesIntoMemoryOnce(
|
|
|
326
330
|
sourceRef: file.path,
|
|
327
331
|
});
|
|
328
332
|
for (const opts of options) {
|
|
329
|
-
await
|
|
333
|
+
await memorySpine.add(opts);
|
|
330
334
|
created += 1;
|
|
331
335
|
}
|
|
332
336
|
marker.migrated[key] = hash;
|
package/src/audio/player.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface StreamingAudioPlayer {
|
|
|
15
15
|
readonly available: boolean;
|
|
16
16
|
play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
|
|
17
17
|
stop(): void;
|
|
18
|
+
/**
|
|
19
|
+
* Resolves once the currently playing sink has finished naturally (its
|
|
20
|
+
* process closed) or after `timeoutMs`, whichever comes first; resolves
|
|
21
|
+
* immediately when nothing is playing. The exit path uses this to let the
|
|
22
|
+
* audio the user is already hearing finish inside a short bounded window
|
|
23
|
+
* instead of killing it mid-drain. stop() remains the instant cut.
|
|
24
|
+
*/
|
|
25
|
+
waitForDrain(timeoutMs: number): Promise<void>;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export interface StreamingAudioPlaybackOptions {
|
|
@@ -25,6 +33,8 @@ export interface StreamingAudioPlaybackOptions {
|
|
|
25
33
|
interface SpawnProcess {
|
|
26
34
|
readonly stdin: Writable;
|
|
27
35
|
once(event: 'close', listener: () => void): this;
|
|
36
|
+
once(event: 'spawn', listener: () => void): this;
|
|
37
|
+
once(event: 'error', listener: (error: unknown) => void): this;
|
|
28
38
|
kill(signal?: NodeJS.Signals | number): boolean;
|
|
29
39
|
}
|
|
30
40
|
type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
|
|
@@ -32,6 +42,13 @@ type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnPr
|
|
|
32
42
|
export interface LocalStreamingAudioPlayerOptions {
|
|
33
43
|
readonly env?: NodeJS.ProcessEnv;
|
|
34
44
|
readonly spawnProcess?: SpawnProcessFactory;
|
|
45
|
+
/**
|
|
46
|
+
* Injected player command — the test seam companion to `spawnProcess`.
|
|
47
|
+
* When set, the constructor uses it verbatim instead of scanning PATH, so
|
|
48
|
+
* deterministic fake-sink tests never depend on mpv/ffplay being installed
|
|
49
|
+
* on the machine running them. Pass null to model "no player found".
|
|
50
|
+
*/
|
|
51
|
+
readonly command?: StreamingAudioPlayerCommand | null;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
@@ -40,7 +57,9 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
|
40
57
|
private readonly spawnProcess: SpawnProcessFactory;
|
|
41
58
|
|
|
42
59
|
constructor(options: LocalStreamingAudioPlayerOptions = {}) {
|
|
43
|
-
this.command =
|
|
60
|
+
this.command = options.command !== undefined
|
|
61
|
+
? options.command
|
|
62
|
+
: resolveStreamingAudioPlayerCommand(options.env ?? process.env);
|
|
44
63
|
this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
45
64
|
}
|
|
46
65
|
|
|
@@ -67,12 +86,27 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
|
67
86
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
68
87
|
|
|
69
88
|
try {
|
|
89
|
+
// Head survival: hold the first audio byte until the sink has actually
|
|
90
|
+
// started (its 'spawn' event) instead of writing into a process that has
|
|
91
|
+
// not exec'd yet. Writing before the player is up is the spawn race that
|
|
92
|
+
// clipped the beginning of playback. A spawn failure rejects here so the
|
|
93
|
+
// caller can report it honestly rather than swallowing a dead player.
|
|
94
|
+
await awaitReady(proc, options.signal);
|
|
95
|
+
if (options.signal?.aborted) return;
|
|
96
|
+
|
|
70
97
|
for await (const chunk of chunks) {
|
|
71
98
|
if (options.signal?.aborted) break;
|
|
72
99
|
if (chunk.data.byteLength === 0) continue;
|
|
73
100
|
await writeStdin(proc, chunk.data);
|
|
74
101
|
}
|
|
75
|
-
|
|
102
|
+
|
|
103
|
+
// An intentional interrupt (turn cancel / quit chord / /tts stop) has
|
|
104
|
+
// already torn the process down via `abort` and must cut immediately —
|
|
105
|
+
// do not wait on a graceful drain. A natural end-of-speech, by contrast,
|
|
106
|
+
// closes stdin and waits for the sink to play out every buffered sample
|
|
107
|
+
// so the tail of the response is never truncated.
|
|
108
|
+
if (options.signal?.aborted) return;
|
|
109
|
+
try { proc.stdin.end(); } catch { /* ignore */ }
|
|
76
110
|
await waitForExit(proc);
|
|
77
111
|
} finally {
|
|
78
112
|
options.signal?.removeEventListener('abort', abort);
|
|
@@ -87,14 +121,33 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
|
87
121
|
try { proc.stdin.destroy(); } catch { /* ignore */ }
|
|
88
122
|
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
89
123
|
}
|
|
124
|
+
|
|
125
|
+
waitForDrain(timeoutMs: number): Promise<void> {
|
|
126
|
+
const proc = this.activeProcess;
|
|
127
|
+
if (!proc) return Promise.resolve();
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
const settle = () => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve();
|
|
135
|
+
};
|
|
136
|
+
const timer = setTimeout(settle, timeoutMs);
|
|
137
|
+
proc.once('close', settle);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
90
140
|
}
|
|
91
141
|
|
|
92
142
|
export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
|
|
93
143
|
const mpv = findExecutable('mpv', env);
|
|
94
144
|
if (mpv) {
|
|
145
|
+
// No --cache=no: mpv's read-ahead cache buffers the incoming pipe so the
|
|
146
|
+
// opening audio survives device-open latency and network jitter instead of
|
|
147
|
+
// underrunning while the output device is still spinning up.
|
|
95
148
|
return {
|
|
96
149
|
command: mpv,
|
|
97
|
-
args: ['--no-terminal', '--really-quiet', '--force-window=no', '
|
|
150
|
+
args: ['--no-terminal', '--really-quiet', '--force-window=no', '-'],
|
|
98
151
|
label: 'mpv',
|
|
99
152
|
};
|
|
100
153
|
}
|
|
@@ -102,18 +155,51 @@ export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = proc
|
|
|
102
155
|
if (ffplay) {
|
|
103
156
|
return {
|
|
104
157
|
command: ffplay,
|
|
105
|
-
args:
|
|
158
|
+
args: FFPLAY_BASE_ARGS,
|
|
106
159
|
label: 'ffplay',
|
|
107
160
|
};
|
|
108
161
|
}
|
|
109
162
|
return null;
|
|
110
163
|
}
|
|
111
164
|
|
|
165
|
+
// ffplay -autoexit quits as soon as its input ends, before the audio output
|
|
166
|
+
// buffer has drained — that clips the tail of the response. `apad` appends a
|
|
167
|
+
// short run of silence so the real audio is fully played out and only the
|
|
168
|
+
// trailing silence gets trimmed.
|
|
169
|
+
const FFPLAY_APAD = ['-af', 'apad=pad_dur=0.3'] as const;
|
|
170
|
+
const FFPLAY_BASE_ARGS = ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-i', 'pipe:0'] as const;
|
|
171
|
+
|
|
112
172
|
function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
|
|
113
173
|
if (command.label !== 'ffplay' || !format) return command.args;
|
|
114
174
|
const normalized = format.trim().toLowerCase();
|
|
115
175
|
if (!normalized || normalized.includes('/')) return command.args;
|
|
116
|
-
return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
|
|
176
|
+
return ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-f', normalized, '-i', 'pipe:0'];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* awaitReady — resolves once the spawned player has actually started (its
|
|
181
|
+
* 'spawn' event), rejects if it fails to start ('error'), and resolves early
|
|
182
|
+
* if the caller aborts during startup so an intentional interrupt is never
|
|
183
|
+
* blocked. This is the readiness gate that keeps the first audio byte from
|
|
184
|
+
* being written into a not-yet-running sink.
|
|
185
|
+
*/
|
|
186
|
+
function awaitReady(proc: SpawnProcess, signal?: AbortSignal): Promise<void> {
|
|
187
|
+
if (signal?.aborted) return Promise.resolve();
|
|
188
|
+
return new Promise<void>((resolve, reject) => {
|
|
189
|
+
let settled = false;
|
|
190
|
+
const settle = (action: () => void) => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
signal?.removeEventListener('abort', onAbort);
|
|
194
|
+
action();
|
|
195
|
+
};
|
|
196
|
+
const onSpawn = () => settle(resolve);
|
|
197
|
+
const onError = (error: unknown) => settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
198
|
+
const onAbort = () => settle(resolve);
|
|
199
|
+
proc.once('spawn', onSpawn);
|
|
200
|
+
proc.once('error', onError);
|
|
201
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
202
|
+
});
|
|
117
203
|
}
|
|
118
204
|
|
|
119
205
|
function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
2
|
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
3
3
|
import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
|
|
4
|
+
import { SpokenTurnController } from '@pellux/goodvibes-sdk/platform/voice';
|
|
5
|
+
import type { StreamingAudioPlayer } from './player.ts';
|
|
4
6
|
import { LocalStreamingAudioPlayer } from './player.ts';
|
|
5
|
-
import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
6
7
|
|
|
7
8
|
export interface SpokenTurnRuntime {
|
|
8
9
|
readonly unsubs: readonly (() => void)[];
|
|
9
10
|
submitNextTurn(prompt: string): boolean;
|
|
10
|
-
|
|
11
|
+
/** Returns whether speech was actually active (see controller.stop). */
|
|
12
|
+
stop(message?: string): boolean;
|
|
13
|
+
/** Exit-path stop: lets the audio already playing drain, bounded (see controller.stopForExit). */
|
|
14
|
+
stopForExit(drainTimeoutMs?: number): Promise<void>;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export interface WireSpokenTurnRuntimeOptions {
|
|
@@ -15,14 +19,21 @@ export interface WireSpokenTurnRuntimeOptions {
|
|
|
15
19
|
readonly configManager: ConfigManager;
|
|
16
20
|
readonly events: UiRuntimeEvents;
|
|
17
21
|
readonly notify: (message: string) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Optional player factory — injected in tests to avoid spawning real
|
|
24
|
+
* subprocesses. Defaults to LocalStreamingAudioPlayer.
|
|
25
|
+
*/
|
|
26
|
+
readonly playerFactory?: () => StreamingAudioPlayer;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
|
|
30
|
+
const player = options.playerFactory ? options.playerFactory() : new LocalStreamingAudioPlayer();
|
|
21
31
|
const controller = new SpokenTurnController({
|
|
22
32
|
voiceService: options.voiceService,
|
|
23
33
|
configManager: options.configManager,
|
|
24
|
-
|
|
34
|
+
sink: player,
|
|
25
35
|
notify: options.notify,
|
|
36
|
+
source: 'goodvibes-agent',
|
|
26
37
|
});
|
|
27
38
|
|
|
28
39
|
const turns = options.events.turns;
|
|
@@ -40,5 +51,6 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
|
|
|
40
51
|
unsubs,
|
|
41
52
|
submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
|
|
42
53
|
stop: (message) => controller.stop(message),
|
|
54
|
+
stopForExit: (drainTimeoutMs) => controller.stopForExit(drainTimeoutMs),
|
|
43
55
|
};
|
|
44
56
|
}
|