@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/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.9",
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;
@@ -1,9 +1,9 @@
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';
4
5
  import type { StreamingAudioPlayer } from './player.ts';
5
6
  import { LocalStreamingAudioPlayer } from './player.ts';
6
- import { SpokenTurnController } from './spoken-turn-controller.ts';
7
7
 
8
8
  export interface SpokenTurnRuntime {
9
9
  readonly unsubs: readonly (() => void)[];
@@ -31,8 +31,9 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
31
31
  const controller = new SpokenTurnController({
32
32
  voiceService: options.voiceService,
33
33
  configManager: options.configManager,
34
- player,
34
+ sink: player,
35
35
  notify: options.notify,
36
+ source: 'goodvibes-agent',
36
37
  });
37
38
 
38
39
  const turns = options.events.turns;