@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/docs/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GoodVibes Agent Docs
2
2
 
3
- These are the package-facing docs for the GoodVibes Agent `1.5.x` release line.
3
+ These are the package-facing docs for the GoodVibes Agent `1.6.x` release line.
4
4
 
5
5
  ## Current Docs
6
6
 
@@ -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.5.8",
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.0.0",
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.memoryRegistry.getAll();
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: memoryRanking.scored ? undefined : (memoryRanking.degradedReason ?? undefined),
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),
@@ -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, MemoryRegistry, MemoryScope } from '@pellux/goodvibes-sdk/platform/state';
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
- memoryRegistry: MemoryRegistry,
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 memoryRegistry.add(opts);
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
- memoryRegistry: MemoryRegistry,
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 memoryRegistry.add(opts);
333
+ await memorySpine.add(opts);
330
334
  created += 1;
331
335
  }
332
336
  marker.migrated[key] = hash;
@@ -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 = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
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
- proc.stdin.end();
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', '--cache=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: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
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
- stop(message?: string): void;
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
- player: new LocalStreamingAudioPlayer(),
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
  }