@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.
@@ -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(registry: MemoryRegistry, action: AgentLocalRegistryAction, args: AgentLocalRegistryToolArgs): Promise<string> {
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
- const records = registry.getAll();
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
- const records = registry.search({ query, limit: 10 });
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 = registry.searchSemantic({ query, limit: 10 });
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 = registry.get(requireId(args));
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 registry.add({
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 = registry.update(requireId(args), {
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 = registry.review(requireId(args), {
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 = registry.review(requireId(args), {
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 (!registry.delete(id)) return `Unknown Agent-local memory ${id}`;
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.5.8';
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,203 +0,0 @@
1
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
- import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
3
- import type { TurnEvent } from '@/runtime/index.ts';
4
- import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice';
5
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
- import { TtsTextChunker } from './text-chunker.ts';
7
- import type { StreamingAudioPlayer } from './player.ts';
8
-
9
- export interface SpokenTurnControllerOptions {
10
- readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
11
- readonly configManager: Pick<ConfigManager, 'get'>;
12
- readonly player: StreamingAudioPlayer;
13
- readonly notify?: (message: string) => void;
14
- readonly now?: () => number;
15
- readonly setInterval?: typeof setInterval;
16
- readonly clearInterval?: typeof clearInterval;
17
- }
18
-
19
- export class SpokenTurnController {
20
- private pendingPrompt: string | null = null;
21
- private activeTurnId: string | null = null;
22
- private chunker: TtsTextChunker | null = null;
23
- private chunkSequence = 0;
24
- private playbackChain: Promise<void> = Promise.resolve();
25
- private readonly abortControllers = new Set<AbortController>();
26
- private timer: ReturnType<typeof setInterval> | null = null;
27
- private errorReportedForTurn = false;
28
- private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
29
- private readonly configManager: Pick<ConfigManager, 'get'>;
30
- private readonly player: StreamingAudioPlayer;
31
- private readonly notify?: (message: string) => void;
32
- private readonly now: () => number;
33
- private readonly setIntervalImpl: typeof setInterval;
34
- private readonly clearIntervalImpl: typeof clearInterval;
35
-
36
- constructor(options: SpokenTurnControllerOptions) {
37
- this.voiceService = options.voiceService;
38
- this.configManager = options.configManager;
39
- this.player = options.player;
40
- this.notify = options.notify;
41
- this.now = options.now ?? (() => Date.now());
42
- this.setIntervalImpl = options.setInterval ?? setInterval;
43
- this.clearIntervalImpl = options.clearInterval ?? clearInterval;
44
- }
45
-
46
- submitNextTurn(prompt: string): boolean {
47
- const normalized = prompt.trim();
48
- if (!normalized) return false;
49
- this.stop();
50
- if (!this.player.available) {
51
- this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
52
- return false;
53
- }
54
- this.pendingPrompt = normalized;
55
- return true;
56
- }
57
-
58
- stop(message?: string): void {
59
- this.pendingPrompt = null;
60
- this.activeTurnId = null;
61
- this.chunker?.reset();
62
- this.chunker = null;
63
- this.stopTimer();
64
- for (const controller of this.abortControllers) controller.abort();
65
- this.abortControllers.clear();
66
- this.player.stop();
67
- this.playbackChain = Promise.resolve();
68
- this.errorReportedForTurn = false;
69
- if (message) this.notify?.(`[TTS] ${message}`);
70
- }
71
-
72
- handleTurnEvent(event: TurnEvent): void {
73
- if (event.type === 'TURN_SUBMITTED') {
74
- this.maybeStartTurn(event.turnId, event.prompt);
75
- return;
76
- }
77
- if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
78
-
79
- if (event.type === 'STREAM_DELTA') {
80
- this.enqueueChunks(this.chunker?.push(event.content) ?? []);
81
- return;
82
- }
83
- if (event.type === 'STREAM_END') {
84
- return;
85
- }
86
- if (event.type === 'TURN_COMPLETED') {
87
- this.finishTurn(event.turnId);
88
- return;
89
- }
90
- if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
91
- this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
92
- }
93
- }
94
-
95
- private maybeStartTurn(turnId: string, prompt: string): void {
96
- if (!this.pendingPrompt) return;
97
- if (prompt.trim() !== this.pendingPrompt) return;
98
- this.pendingPrompt = null;
99
- this.activeTurnId = turnId;
100
- this.chunkSequence = 0;
101
- this.errorReportedForTurn = false;
102
- this.chunker = new TtsTextChunker({ now: this.now });
103
- this.playbackChain = Promise.resolve();
104
- this.startTimer();
105
- this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
106
- }
107
-
108
- private finishTurn(turnId: string): void {
109
- if (turnId !== this.activeTurnId) return;
110
- this.enqueueChunks(this.chunker?.flushAll() ?? []);
111
- this.stopTimer();
112
- const chain = this.playbackChain;
113
- chain.finally(() => {
114
- if (this.activeTurnId !== turnId) return;
115
- this.activeTurnId = null;
116
- this.chunker = null;
117
- this.abortControllers.clear();
118
- }).catch(() => {
119
- // Errors are already reported in the queued task.
120
- });
121
- }
122
-
123
- private startTimer(): void {
124
- this.stopTimer();
125
- this.timer = this.setIntervalImpl(() => {
126
- if (!this.activeTurnId || !this.chunker) return;
127
- this.enqueueChunks(this.chunker.flushDue());
128
- }, 250);
129
- }
130
-
131
- private stopTimer(): void {
132
- if (!this.timer) return;
133
- this.clearIntervalImpl(this.timer);
134
- this.timer = null;
135
- }
136
-
137
- private enqueueChunks(chunks: readonly string[]): void {
138
- for (const chunk of chunks) {
139
- this.enqueueChunk(chunk);
140
- }
141
- }
142
-
143
- private enqueueChunk(text: string): void {
144
- const turnId = this.activeTurnId;
145
- if (!turnId || !text.trim()) return;
146
- const sequence = ++this.chunkSequence;
147
- const abortController = new AbortController();
148
- this.abortControllers.add(abortController);
149
- const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
150
- .then((result) => ({ ok: true as const, result }))
151
- .catch((error: unknown) => ({ ok: false as const, error }));
152
-
153
- this.playbackChain = this.playbackChain.then(async () => {
154
- if (abortController.signal.aborted) return;
155
- const result = await resultPromise;
156
- this.abortControllers.delete(abortController);
157
- if (!result.ok) {
158
- this.reportError(result.error);
159
- return;
160
- }
161
- await this.player.play(result.result.chunks, {
162
- format: String(result.result.format ?? 'mp3'),
163
- signal: abortController.signal,
164
- });
165
- }).catch((error: unknown) => {
166
- this.abortControllers.delete(abortController);
167
- this.reportError(error);
168
- });
169
- }
170
-
171
- private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
172
- return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
173
- text,
174
- voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
175
- format: 'mp3',
176
- signal,
177
- metadata: {
178
- source: 'goodvibes-agent',
179
- feature: 'live-tts',
180
- turnId,
181
- sequence,
182
- },
183
- });
184
- }
185
-
186
- private reportError(error: unknown): void {
187
- if (this.errorReportedForTurn) return;
188
- this.errorReportedForTurn = true;
189
- this.activeTurnId = null;
190
- this.chunker = null;
191
- this.stopTimer();
192
- for (const controller of this.abortControllers) controller.abort();
193
- this.abortControllers.clear();
194
- this.player.stop();
195
- this.playbackChain = Promise.resolve();
196
- this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
197
- }
198
- }
199
-
200
- function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
201
- const value = String(configManager.get(key) ?? '').trim();
202
- return value || undefined;
203
- }
@@ -1,110 +0,0 @@
1
- export interface TtsTextChunkerOptions {
2
- readonly minBoundaryChars?: number;
3
- readonly maxChunkChars?: number;
4
- readonly maxLatencyMs?: number;
5
- readonly now?: () => number;
6
- }
7
-
8
- export class TtsTextChunker {
9
- private buffer = '';
10
- private firstBufferedAt: number | null = null;
11
- private readonly minBoundaryChars: number;
12
- private readonly maxChunkChars: number;
13
- private readonly maxLatencyMs: number;
14
- private readonly now: () => number;
15
-
16
- constructor(options: TtsTextChunkerOptions = {}) {
17
- this.minBoundaryChars = options.minBoundaryChars ?? 24;
18
- this.maxChunkChars = options.maxChunkChars ?? 320;
19
- this.maxLatencyMs = options.maxLatencyMs ?? 1_000;
20
- this.now = options.now ?? (() => Date.now());
21
- }
22
-
23
- push(delta: string): string[] {
24
- if (!delta) return [];
25
- if (this.firstBufferedAt === null) this.firstBufferedAt = this.now();
26
- this.buffer += delta;
27
- return this.drainReady(false);
28
- }
29
-
30
- flushDue(): string[] {
31
- if (!this.buffer.trim() || this.firstBufferedAt === null) return [];
32
- if (this.now() - this.firstBufferedAt < this.maxLatencyMs) return [];
33
- return this.drainReady(true);
34
- }
35
-
36
- flushAll(): string[] {
37
- if (!this.buffer.trim()) {
38
- this.buffer = '';
39
- this.firstBufferedAt = null;
40
- return [];
41
- }
42
- return [this.takeChunk(this.buffer.length)].filter(Boolean);
43
- }
44
-
45
- reset(): void {
46
- this.buffer = '';
47
- this.firstBufferedAt = null;
48
- }
49
-
50
- private drainReady(forceLatencyFlush: boolean): string[] {
51
- const chunks: string[] = [];
52
- while (this.buffer.trim()) {
53
- const boundary = this.findBoundary(forceLatencyFlush);
54
- if (boundary <= 0) break;
55
- const chunk = this.takeChunk(boundary);
56
- if (chunk) chunks.push(chunk);
57
- forceLatencyFlush = false;
58
- }
59
- return chunks;
60
- }
61
-
62
- private findBoundary(forceLatencyFlush: boolean): number {
63
- const latestSentence = this.findLatestSentenceBoundary();
64
- if (latestSentence >= this.minBoundaryChars) return latestSentence;
65
-
66
- if (this.buffer.length >= this.maxChunkChars) {
67
- return this.findWordBoundaryBefore(this.maxChunkChars) || this.maxChunkChars;
68
- }
69
-
70
- if (forceLatencyFlush) {
71
- return this.buffer.length;
72
- }
73
-
74
- return -1;
75
- }
76
-
77
- private findLatestSentenceBoundary(): number {
78
- let best = -1;
79
- for (let i = 0; i < this.buffer.length; i++) {
80
- const char = this.buffer[i];
81
- if (char !== '.' && char !== '!' && char !== '?' && char !== ';' && char !== ':' && char !== '\n') {
82
- continue;
83
- }
84
- const next = this.buffer[i + 1];
85
- if (i === this.buffer.length - 1 || next === undefined || /\s/.test(next)) {
86
- best = i + 1;
87
- }
88
- }
89
- return best;
90
- }
91
-
92
- private findWordBoundaryBefore(index: number): number {
93
- const max = Math.min(index, this.buffer.length);
94
- for (let i = max; i > 0; i--) {
95
- if (/\s/.test(this.buffer[i - 1] ?? '')) return i;
96
- }
97
- return -1;
98
- }
99
-
100
- private takeChunk(end: number): string {
101
- const raw = this.buffer.slice(0, end);
102
- this.buffer = this.buffer.slice(end);
103
- this.firstBufferedAt = this.buffer.trim() ? this.now() : null;
104
- return normalizeSpeechText(raw);
105
- }
106
- }
107
-
108
- export function normalizeSpeechText(text: string): string {
109
- return text.replace(/\s+/g, ' ').trim();
110
- }