@pellux/goodvibes-agent 1.5.9 → 1.6.1

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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * memory-command-wire.ts
3
+ *
4
+ * Split out of memory-command.ts to stay under the 800-line architecture cap.
5
+ * Owns the CLI's wire-first path onto the memory spine (SDK 1.2.0 full-detach
6
+ * catalog) — see the CLI ruling below for why this exists and what it
7
+ * deliberately still does NOT cover.
8
+ */
9
+ import type { MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state';
10
+ import type { LocalMemoryStore, MemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
11
+ import { MemorySpineClient, createLocalMemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
12
+ import { assertNoSecretLikeMemoryText } from '../agent/memory-safety.ts';
13
+ import { formatAgentRecordReviewState } from '../agent/record-labels.ts';
14
+ import { createSpineConnectionResolver, createSpineRestProbe } from '../runtime/session-spine-rest-transport.ts';
15
+ import { createMemorySpineRestTransport } from '../runtime/memory-spine-rest-transport.ts';
16
+ import type { CliCommandOutput } from './types.ts';
17
+ import type { CliCommandRuntime } from './management.ts';
18
+ import {
19
+ VALID_REVIEW_STATES,
20
+ csvOption,
21
+ failure,
22
+ filterFromOptions,
23
+ hasFlag,
24
+ isReviewState,
25
+ optionValue,
26
+ optionalScope,
27
+ parseConfidence,
28
+ parseOptions,
29
+ provenanceFromOptions,
30
+ readBundle,
31
+ renderRecord,
32
+ renderRecordList,
33
+ requireClass,
34
+ requireScope,
35
+ resolvePath,
36
+ success,
37
+ writeBundle,
38
+ type MemoryListData,
39
+ type MemorySearchData,
40
+ } from './memory-command.ts';
41
+
42
+ /**
43
+ * CLI RULING (memory-spine adoption, SDK 1.2.0 full-detach catalog).
44
+ *
45
+ * The CLI is a one-shot process invoked by `goodvibes-agent memory ...` with no
46
+ * lasting daemon context — it doesn't get to sit through a boot-time reachability
47
+ * probe like the interactive runtime does. But the daemon's canonical store is
48
+ * single-writer: if a daemon IS running and owns that store, this process opening
49
+ * the same sqlite file underneath it and calling `store.save()` at the end (see
50
+ * `withMemory` in memory-command.ts, unconditional in its `finally`) would race the
51
+ * daemon's own writes and silently drop whichever side saved last. Staying
52
+ * "local-direct always" would be safe ONLY if no daemon could ever be running
53
+ * concurrently — which is not true for this product (Connected Host is the normal
54
+ * mode, not the exception).
55
+ *
56
+ * So the honest resolution: for every subcommand that has a wire-covered
57
+ * equivalent on `MemoryAccess` (the five 1.1.0 core verbs plus the 1.2.0 extended
58
+ * verbs list/update/link/linksFor/searchSemantic/exportBundle/importBundle — see
59
+ * memory-spine-rest-transport.ts), the CLI probes for a reachable daemon FIRST and,
60
+ * when one answers, runs the ENTIRE command over the wire and never opens the local
61
+ * store file at all. Only when the probe finds no daemon does it fall back to the
62
+ * existing direct-local path.
63
+ *
64
+ * 1.2.0 closes most of the prior gap: `show` (get + linksFor), `link`, `export`,
65
+ * `import`, and the `search --semantic`/`--vector` path, plus `promote` (which is
66
+ * not a distinct wire verb — it is `update({ scope })`, per the SDK's full-detach
67
+ * ruling) now all run over the wire when a daemon is adopted.
68
+ *
69
+ * A real, stated gap remains: `queue` (reviewQueue) and `vector` (vectorStats /
70
+ * doctor / rebuild) have no route on this CLI's wire transport by deliberate
71
+ * choice — vector-index diagnostics are maintenance a store performs on its OWN
72
+ * index, so they stay local-direct honestly rather than reporting a daemon's index
73
+ * health as if it were this process's own. Those two subcommands still run
74
+ * local-direct even while a daemon is adopted; that is a known, named scope limit,
75
+ * not a silent oversight.
76
+ */
77
+ const WIRE_STORE_LABEL = 'wire:connected-daemon';
78
+ const CLI_PROBE_TIMEOUT_MS = 800;
79
+
80
+ const WIRE_ELIGIBLE_SUBCOMMANDS = new Set([
81
+ 'list', 'ls',
82
+ 'add', 'create',
83
+ 'show', 'get',
84
+ 'review',
85
+ 'stale',
86
+ 'contradict', 'contradicted',
87
+ 'promote',
88
+ 'link',
89
+ 'delete', 'remove', 'rm',
90
+ 'export', 'handoff-export', 'share',
91
+ 'import', 'handoff-import',
92
+ ]);
93
+
94
+ /** `search`/`find` covers both the literal path (honestSearch) and, since 1.2.0, the `--semantic`/`--vector` path (searchSemantic) — both are wire-covered verbs. */
95
+ function isWireEligibleMemorySubcommand(normalized: string): boolean {
96
+ return WIRE_ELIGIBLE_SUBCOMMANDS.has(normalized) || normalized === 'search' || normalized === 'find';
97
+ }
98
+
99
+ /**
100
+ * A `LocalMemoryStore` stub that must never actually run. `MemorySpineClient`
101
+ * routes EVERY op to the transport once constructed with one attached (mode is
102
+ * 'client' immediately — see the SDK's one-writer enforcement ruling in
103
+ * client.ts), so this stub exists only to satisfy the constructor's required
104
+ * `local` argument without opening the CLI's own copy of the store file — the
105
+ * exact single-writer race the whole wire-first path exists to avoid. A call
106
+ * that somehow reached it would be a bug in that routing guarantee, so it fails
107
+ * loudly rather than silently reading/writing a file this process must not touch.
108
+ */
109
+ function neverReachedLocalMemoryStore(): LocalMemoryStore {
110
+ const unreachable = (): never => {
111
+ throw new Error('memory spine: CLI wire mode reached the local store stub — this must never happen while a transport is attached.');
112
+ };
113
+ return {
114
+ add: unreachable,
115
+ honestSearch: unreachable,
116
+ get: unreachable,
117
+ review: unreachable,
118
+ delete: unreachable,
119
+ search: unreachable,
120
+ searchSemantic: unreachable,
121
+ update: unreachable,
122
+ link: unreachable,
123
+ linksFor: unreachable,
124
+ reviewQueue: unreachable,
125
+ exportBundle: unreachable,
126
+ importBundle: unreachable,
127
+ vectorStats: unreachable,
128
+ doctor: unreachable,
129
+ };
130
+ }
131
+
132
+ /** Probes for a reachable connected daemon and, only when one answers, returns the wire MemoryAccess. Returns null (never throws) so the caller can fall back to local. */
133
+ async function resolveWireMemoryAccess(runtime: CliCommandRuntime): Promise<MemoryAccess | null> {
134
+ const resolveConnection = createSpineConnectionResolver(runtime.configManager, runtime.homeDirectory);
135
+ const probe = createSpineRestProbe({ resolveConnection, probeTimeoutMs: CLI_PROBE_TIMEOUT_MS });
136
+ const reachable = await probe();
137
+ if (!reachable) return null;
138
+ const transport = createMemorySpineRestTransport({ resolveConnection });
139
+ // MemorySpineClient (not the raw transport) is the MemoryAccess: the raw
140
+ // REST transport's type permits missing extended verbs (MemoryTransport =
141
+ // MemoryCoreAccess & Partial<MemoryExtendedAccess>) even though this specific
142
+ // transport implements every one the CLI calls; routing through the client
143
+ // gets full MemoryAccess typing plus its honest-rejection behavior for any
144
+ // verb a future older daemon doesn't support.
145
+ return new MemorySpineClient({ local: createLocalMemoryAccess(neverReachedLocalMemoryStore()), transport });
146
+ }
147
+
148
+ async function handleListWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
149
+ const options = parseOptions(args);
150
+ const positionalClass = options.positionals[0];
151
+ const filter = filterFromOptions(options, 50);
152
+ if (positionalClass !== undefined) filter.cls = requireClass(positionalClass);
153
+ const { records } = await memorySpine.honestSearch(filter);
154
+ const data: MemoryListData = { path: WIRE_STORE_LABEL, records, filter };
155
+ return success(runtime, 'agent.memory.list', data, renderRecordList('Agent memory', WIRE_STORE_LABEL, records));
156
+ }
157
+
158
+ async function handleSearchWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
159
+ const options = parseOptions(args);
160
+ const query = options.positionals.join(' ').trim();
161
+ if (!query) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory search <query> [--semantic] [--cls <class>] [--scope <scope>] [--limit <n>]', 2);
162
+ const filter = filterFromOptions(options, 20);
163
+ filter.query = query;
164
+ const semantic = hasFlag(options, 'semantic') || hasFlag(options, 'vector');
165
+ if (semantic) filter.semantic = true;
166
+ if (semantic) {
167
+ // 1.2.0: searchSemantic is a wire-covered extended verb (memory.records.search-semantic).
168
+ const semanticResults = await memorySpine.searchSemantic(filter);
169
+ const records = semanticResults.map((entry) => entry.record);
170
+ const data: MemorySearchData = { path: WIRE_STORE_LABEL, records, filter, semantic: true, semanticResults };
171
+ return success(runtime, 'agent.memory.search', data, renderRecordList(`Agent memory matching "${query}"`, WIRE_STORE_LABEL, records, semanticResults));
172
+ }
173
+ const { records } = await memorySpine.honestSearch(filter);
174
+ const data: MemorySearchData = { path: WIRE_STORE_LABEL, records, filter, semantic: false, semanticResults: [] };
175
+ return success(runtime, 'agent.memory.search', data, renderRecordList(`Agent memory matching "${query}"`, WIRE_STORE_LABEL, records));
176
+ }
177
+
178
+ async function handleShowWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
179
+ const id = args[0];
180
+ if (!id) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory show <id>', 2);
181
+ const record = await memorySpine.get(id);
182
+ if (!record) return failure(runtime, 'memory_not_found', `Memory record not found ${id}`, 1);
183
+ const links = await memorySpine.linksFor(id);
184
+ return success(runtime, 'agent.memory.show', { record, links }, renderRecord(record, links));
185
+ }
186
+
187
+ async function handlePromoteWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
188
+ const options = parseOptions(args);
189
+ const [id, scopeRaw] = options.positionals;
190
+ if (!id || !scopeRaw) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory promote <id> <session|project|team> --yes', 2);
191
+ if (!hasFlag(options, 'yes')) return failure(runtime, 'confirmation_required', `Refusing to promote memory record ${id} without --yes.`, 2);
192
+ // "Promote" is not a distinct wire verb — it is a scope edit, per the SDK's
193
+ // full-detach ruling (a scope promotion is update({ scope })).
194
+ //
195
+ // `update` now returns null ONLY for a genuine record-miss (a
196
+ // MEMORY_RECORD_NOT_FOUND 404); a daemon that does not serve the update verb
197
+ // (an older daemon, a route-not-found 404) makes it THROW an honest
198
+ // "verb unavailable" error that surfaces via handleMemoryCommand's errorOutput —
199
+ // it is no longer folded to null here and mislabelled as "record not found".
200
+ const record = await memorySpine.update(id, { scope: requireScope(scopeRaw) });
201
+ if (!record) return failure(runtime, 'memory_not_found', `Memory record not found ${id}`, 1);
202
+ return success(runtime, 'agent.memory.promote', record, [
203
+ 'Agent memory promoted',
204
+ ` id ${record.id}`,
205
+ ` scope ${record.scope}`,
206
+ ].join('\n'));
207
+ }
208
+
209
+ async function handleLinkWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
210
+ const options = parseOptions(args);
211
+ const [fromId, toId, relation] = options.positionals;
212
+ if (!fromId || !toId || !relation) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory link <fromId> <toId> <relation> --yes', 2);
213
+ if (!hasFlag(options, 'yes')) return failure(runtime, 'confirmation_required', `Refusing to link memory records ${fromId} and ${toId} without --yes.`, 2);
214
+ const link = await memorySpine.link(fromId, toId, relation);
215
+ if (!link) return failure(runtime, 'memory_link_failed', 'Memory link failed; check that both records exist.', 1);
216
+ return success(runtime, 'agent.memory.link', link, [
217
+ 'Agent memory linked',
218
+ ` from ${fromId}`,
219
+ ` to ${toId}`,
220
+ ` relation ${relation}`,
221
+ ].join('\n'));
222
+ }
223
+
224
+ async function handleExportWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
225
+ const options = parseOptions(args);
226
+ const pathArg = options.positionals[0];
227
+ if (!pathArg) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory export <path> [--scope <scope>] [--cls <class>] --yes', 2);
228
+ if (!hasFlag(options, 'yes')) return failure(runtime, 'confirmation_required', `Refusing to export Agent memory bundle to ${pathArg} without --yes.`, 2);
229
+ const filter = filterFromOptions(options, 200);
230
+ const path = resolvePath(runtime, pathArg);
231
+ const bundle = await memorySpine.exportBundle(filter);
232
+ writeBundle(path, bundle);
233
+ return success(runtime, 'agent.memory.export', { path, bundle }, [
234
+ 'Agent memory exported',
235
+ ` records ${bundle.recordCount}`,
236
+ ` links ${bundle.linkCount}`,
237
+ ` path ${path}`,
238
+ ].join('\n'));
239
+ }
240
+
241
+ async function handleImportWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
242
+ const options = parseOptions(args);
243
+ const pathArg = options.positionals[0];
244
+ if (!pathArg) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory import <path> --yes', 2);
245
+ if (!hasFlag(options, 'yes')) return failure(runtime, 'confirmation_required', `Refusing to import Agent memory bundle from ${pathArg} without --yes.`, 2);
246
+ const path = resolvePath(runtime, pathArg);
247
+ const bundle = readBundle(path);
248
+ const result = await memorySpine.importBundle(bundle);
249
+ return success(runtime, 'agent.memory.import', { path, result }, [
250
+ `Agent memory imported: ${result.importedRecords} record${result.importedRecords === 1 ? '' : 's'}`,
251
+ ` records: ${result.importedRecords}`,
252
+ ` links: ${result.importedLinks}`,
253
+ ` skipped: ${result.skippedRecords}`,
254
+ ].join('\n'));
255
+ }
256
+
257
+ async function handleAddWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
258
+ const options = parseOptions(args);
259
+ const [classRaw, ...summaryParts] = options.positionals;
260
+ const cls = requireClass(classRaw);
261
+ const summary = summaryParts.join(' ').trim();
262
+ if (!summary) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory add <class> <summary> [--scope <scope>] [--detail <text>] [--tags a,b]', 2);
263
+ const detail = optionValue(options, 'detail');
264
+ const tags = csvOption(options, 'tags');
265
+ assertNoSecretLikeMemoryText([summary, detail ?? '', ...(tags ?? [])]);
266
+ const reviewState = optionValue(options, 'review-state');
267
+ if (reviewState !== undefined && !isReviewState(reviewState)) {
268
+ return failure(runtime, 'invalid_memory_command', `Invalid review state "${reviewState}". Valid values ${VALID_REVIEW_STATES.join(', ')}`, 2);
269
+ }
270
+ const record = await memorySpine.add({
271
+ scope: optionalScope(optionValue(options, 'scope')) ?? 'project',
272
+ cls,
273
+ summary,
274
+ detail,
275
+ tags: tags === undefined ? undefined : [...tags],
276
+ provenance: [...provenanceFromOptions(options)],
277
+ review: {
278
+ state: reviewState,
279
+ confidence: parseConfidence(optionValue(options, 'confidence')),
280
+ reviewedBy: optionValue(options, 'by'),
281
+ },
282
+ });
283
+ return success(runtime, 'agent.memory.add', record, [
284
+ 'Agent memory added',
285
+ ` id ${record.id}`,
286
+ ].join('\n'));
287
+ }
288
+
289
+ async function handleReviewWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
290
+ const options = parseOptions(args);
291
+ const [id, stateRaw] = options.positionals;
292
+ if (!id || !stateRaw || !isReviewState(stateRaw)) {
293
+ return failure(runtime, 'invalid_memory_command', `Usage: goodvibes-agent memory review <id> <${VALID_REVIEW_STATES.join('|')}> [--confidence <0-100>]`, 2);
294
+ }
295
+ const record = await memorySpine.updateReview(id, {
296
+ state: stateRaw,
297
+ confidence: parseConfidence(optionValue(options, 'confidence')),
298
+ reviewedBy: optionValue(options, 'by') ?? 'operator',
299
+ staleReason: optionValue(options, 'reason'),
300
+ });
301
+ if (!record) return failure(runtime, 'memory_not_found', `Memory record not found ${id}`, 1);
302
+ return success(runtime, 'agent.memory.review', record, [
303
+ 'Agent memory reviewed',
304
+ ` id ${record.id}`,
305
+ ` review ${formatAgentRecordReviewState(record.reviewState)}`,
306
+ ` confidence ${record.confidence}%`,
307
+ ].join('\n'));
308
+ }
309
+
310
+ async function handleReviewShortcutWire(
311
+ runtime: CliCommandRuntime,
312
+ memorySpine: MemoryAccess,
313
+ state: Extract<MemoryReviewState, 'stale' | 'contradicted'>,
314
+ args: readonly string[],
315
+ ): Promise<CliCommandOutput> {
316
+ const [id, ...reasonParts] = args;
317
+ if (!id || reasonParts.length === 0) {
318
+ return failure(runtime, 'invalid_memory_command', `Usage: goodvibes-agent memory ${state === 'stale' ? 'stale' : 'contradict'} <id> <reason>`, 2);
319
+ }
320
+ const record = await memorySpine.updateReview(id, {
321
+ state,
322
+ reviewedBy: 'operator',
323
+ staleReason: reasonParts.join(' '),
324
+ });
325
+ if (!record) return failure(runtime, 'memory_not_found', `Memory record not found ${id}`, 1);
326
+ return success(runtime, `agent.memory.${state}`, record, [
327
+ `Agent memory marked ${state}`,
328
+ ` id ${record.id}`,
329
+ ].join('\n'));
330
+ }
331
+
332
+ async function handleDeleteWire(runtime: CliCommandRuntime, memorySpine: MemoryAccess, args: readonly string[]): Promise<CliCommandOutput> {
333
+ const options = parseOptions(args);
334
+ const id = options.positionals[0];
335
+ if (!id) return failure(runtime, 'invalid_memory_command', 'Usage: goodvibes-agent memory delete <id> --yes', 2);
336
+ if (!hasFlag(options, 'yes')) return failure(runtime, 'confirmation_required', `Refusing to delete memory record ${id} without --yes.`, 2);
337
+ if (!(await memorySpine.delete(id))) return failure(runtime, 'memory_not_found', `Memory record not found ${id}`, 1);
338
+ return success(runtime, 'agent.memory.delete', { id }, [
339
+ `Agent memory deleted: ${id}`,
340
+ ` id ${id}`,
341
+ ].join('\n'));
342
+ }
343
+
344
+ /**
345
+ * Runs a wire-eligible subcommand over the memory spine when a daemon is reachable.
346
+ * Returns null (never a failure output) when the subcommand has no wire path, or
347
+ * when the probe finds no daemon — either way the caller falls through to the
348
+ * existing local-direct path unchanged. Once a wire call is actually in flight, a
349
+ * failure surfaces honestly (thrown, caught by handleMemoryCommand's try/catch) —
350
+ * it never silently retries against the local file after starting a wire attempt.
351
+ */
352
+ export async function tryWireMemoryCommand(
353
+ runtime: CliCommandRuntime,
354
+ normalized: string,
355
+ rest: readonly string[],
356
+ ): Promise<CliCommandOutput | null> {
357
+ if (!isWireEligibleMemorySubcommand(normalized)) return null;
358
+ const memorySpine = await resolveWireMemoryAccess(runtime);
359
+ if (!memorySpine) return null;
360
+ if (normalized === 'list' || normalized === 'ls') return handleListWire(runtime, memorySpine, rest);
361
+ if (normalized === 'search' || normalized === 'find') return handleSearchWire(runtime, memorySpine, rest);
362
+ if (normalized === 'add' || normalized === 'create') return handleAddWire(runtime, memorySpine, rest);
363
+ if (normalized === 'show' || normalized === 'get') return handleShowWire(runtime, memorySpine, rest);
364
+ if (normalized === 'review') return handleReviewWire(runtime, memorySpine, rest);
365
+ if (normalized === 'stale') return handleReviewShortcutWire(runtime, memorySpine, 'stale', rest);
366
+ if (normalized === 'contradict' || normalized === 'contradicted') return handleReviewShortcutWire(runtime, memorySpine, 'contradicted', rest);
367
+ if (normalized === 'promote') return handlePromoteWire(runtime, memorySpine, rest);
368
+ if (normalized === 'link') return handleLinkWire(runtime, memorySpine, rest);
369
+ if (normalized === 'delete' || normalized === 'remove' || normalized === 'rm') return handleDeleteWire(runtime, memorySpine, rest);
370
+ if (normalized === 'export' || normalized === 'handoff-export' || normalized === 'share') return handleExportWire(runtime, memorySpine, rest);
371
+ if (normalized === 'import' || normalized === 'handoff-import') return handleImportWire(runtime, memorySpine, rest);
372
+ return null;
373
+ }
@@ -20,10 +20,13 @@ import { assertNoSecretLikeMemoryText } from '../agent/memory-safety.ts';
20
20
  import { formatAgentRecordReference, formatAgentRecordReviewState } from '../agent/record-labels.ts';
21
21
  import type { CliCommandOutput } from './types.ts';
22
22
  import type { CliCommandRuntime } from './management.ts';
23
+ // Split out to stay under the architecture line cap; see its file header for the
24
+ // CLI ruling on when a memory subcommand goes over the wire vs local-direct.
25
+ import { tryWireMemoryCommand } from './memory-command-wire.ts';
23
26
 
24
27
  const VALID_CLASSES: readonly MemoryClass[] = ['decision', 'constraint', 'incident', 'pattern', 'fact', 'risk', 'runbook', 'architecture', 'ownership'];
25
28
  const VALID_SCOPES: readonly MemoryScope[] = ['session', 'project', 'team'];
26
- const VALID_REVIEW_STATES: readonly MemoryReviewState[] = ['fresh', 'reviewed', 'stale', 'contradicted'];
29
+ export const VALID_REVIEW_STATES: readonly MemoryReviewState[] = ['fresh', 'reviewed', 'stale', 'contradicted'];
27
30
  const VALID_PROVENANCE_KINDS: readonly ProvenanceLinkKind[] = ['session', 'turn', 'task', 'event', 'file'];
28
31
  const VALUE_OPTIONS = new Set([
29
32
  'by',
@@ -53,7 +56,7 @@ interface CommandFailure {
53
56
  readonly error: string;
54
57
  }
55
58
 
56
- interface ParsedOptions {
59
+ export interface ParsedOptions {
57
60
  readonly values: ReadonlyMap<string, string>;
58
61
  readonly flags: ReadonlySet<string>;
59
62
  readonly positionals: readonly string[];
@@ -65,13 +68,13 @@ interface MemoryContext {
65
68
  readonly path: string;
66
69
  }
67
70
 
68
- interface MemoryListData {
71
+ export interface MemoryListData {
69
72
  readonly path: string;
70
73
  readonly records: readonly MemoryRecord[];
71
74
  readonly filter: MemorySearchFilter;
72
75
  }
73
76
 
74
- interface MemorySearchData extends MemoryListData {
77
+ export interface MemorySearchData extends MemoryListData {
75
78
  readonly semantic: boolean;
76
79
  readonly semanticResults: readonly MemorySemanticSearchResult[];
77
80
  }
@@ -80,12 +83,12 @@ function jsonOrText(runtime: CliCommandRuntime, value: unknown, text: string): s
80
83
  return runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(value, null, 2) : text;
81
84
  }
82
85
 
83
- function success<TData>(runtime: CliCommandRuntime, kind: string, data: TData, text: string): CliCommandOutput {
86
+ export function success<TData>(runtime: CliCommandRuntime, kind: string, data: TData, text: string): CliCommandOutput {
84
87
  const value: CommandSuccess<TData> = { ok: true, kind, data };
85
88
  return { output: jsonOrText(runtime, value, text), exitCode: 0 };
86
89
  }
87
90
 
88
- function failure(runtime: CliCommandRuntime, kind: string, error: string, exitCode: number): CliCommandOutput {
91
+ export function failure(runtime: CliCommandRuntime, kind: string, error: string, exitCode: number): CliCommandOutput {
89
92
  const value: CommandFailure = { ok: false, kind, error };
90
93
  return {
91
94
  output: runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(value, null, 2) : error,
@@ -93,7 +96,7 @@ function failure(runtime: CliCommandRuntime, kind: string, error: string, exitCo
93
96
  };
94
97
  }
95
98
 
96
- function parseOptions(args: readonly string[]): ParsedOptions {
99
+ export function parseOptions(args: readonly string[]): ParsedOptions {
97
100
  const values = new Map<string, string>();
98
101
  const flags = new Set<string>();
99
102
  const positionals: string[] = [];
@@ -124,18 +127,18 @@ function parseOptions(args: readonly string[]): ParsedOptions {
124
127
  return { values, flags, positionals };
125
128
  }
126
129
 
127
- function optionValue(options: ParsedOptions, name: string): string | undefined {
130
+ export function optionValue(options: ParsedOptions, name: string): string | undefined {
128
131
  const value = options.values.get(name);
129
132
  if (value === undefined) return undefined;
130
133
  const trimmed = value.trim();
131
134
  return trimmed.length > 0 ? trimmed : undefined;
132
135
  }
133
136
 
134
- function hasFlag(options: ParsedOptions, name: string): boolean {
137
+ export function hasFlag(options: ParsedOptions, name: string): boolean {
135
138
  return options.flags.has(name);
136
139
  }
137
140
 
138
- function csvOption(options: ParsedOptions, name: string): readonly string[] | undefined {
141
+ export function csvOption(options: ParsedOptions, name: string): readonly string[] | undefined {
139
142
  const value = optionValue(options, name);
140
143
  if (value === undefined) return undefined;
141
144
  return value.split(',').map((entry) => entry.trim()).filter(Boolean);
@@ -148,7 +151,7 @@ function parseLimit(value: string | undefined, fallback: number): number {
148
151
  return Math.min(parsed, 200);
149
152
  }
150
153
 
151
- function parseConfidence(value: string | undefined): number | undefined {
154
+ export function parseConfidence(value: string | undefined): number | undefined {
152
155
  if (value === undefined) return undefined;
153
156
  const parsed = Number.parseInt(value, 10);
154
157
  if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) throw new Error('--confidence must be an integer from 0 to 100.');
@@ -163,7 +166,7 @@ function isMemoryScope(value: string): value is MemoryScope {
163
166
  return VALID_SCOPES.includes(value as MemoryScope);
164
167
  }
165
168
 
166
- function isReviewState(value: string): value is MemoryReviewState {
169
+ export function isReviewState(value: string): value is MemoryReviewState {
167
170
  return VALID_REVIEW_STATES.includes(value as MemoryReviewState);
168
171
  }
169
172
 
@@ -171,17 +174,17 @@ function isProvenanceKind(value: string): value is ProvenanceLinkKind {
171
174
  return VALID_PROVENANCE_KINDS.includes(value as ProvenanceLinkKind);
172
175
  }
173
176
 
174
- function requireClass(value: string | undefined): MemoryClass {
177
+ export function requireClass(value: string | undefined): MemoryClass {
175
178
  if (!value || !isMemoryClass(value)) throw new Error(`Invalid memory class "${value ?? ''}". Valid values ${VALID_CLASSES.join(', ')}`);
176
179
  return value;
177
180
  }
178
181
 
179
- function requireScope(value: string | undefined): MemoryScope {
182
+ export function requireScope(value: string | undefined): MemoryScope {
180
183
  if (!value || !isMemoryScope(value)) throw new Error(`Invalid memory scope "${value ?? ''}". Valid values ${VALID_SCOPES.join(', ')}`);
181
184
  return value;
182
185
  }
183
186
 
184
- function optionalScope(value: string | undefined): MemoryScope | undefined {
187
+ export function optionalScope(value: string | undefined): MemoryScope | undefined {
185
188
  if (value === undefined) return undefined;
186
189
  return requireScope(value);
187
190
  }
@@ -226,7 +229,7 @@ function renderRecordLine(record: MemoryRecord, semanticEntry?: MemorySemanticSe
226
229
  return ` ${record.id} ${record.scope}/${record.cls} ${formatAgentRecordReviewState(record.reviewState)} ${record.confidence}%${score}${tags} ${record.summary}`;
227
230
  }
228
231
 
229
- function renderRecordList(title: string, path: string, records: readonly MemoryRecord[], semanticResults: readonly MemorySemanticSearchResult[] = []): string {
232
+ export function renderRecordList(title: string, path: string, records: readonly MemoryRecord[], semanticResults: readonly MemorySemanticSearchResult[] = []): string {
230
233
  if (records.length === 0) {
231
234
  return [
232
235
  title,
@@ -242,7 +245,7 @@ function renderRecordList(title: string, path: string, records: readonly MemoryR
242
245
  ].join('\n');
243
246
  }
244
247
 
245
- function renderRecord(record: MemoryRecord, links: readonly MemoryLink[]): string {
248
+ export function renderRecord(record: MemoryRecord, links: readonly MemoryLink[]): string {
246
249
  return [
247
250
  `Memory ${record.id}`,
248
251
  ` scope: ${record.scope}`,
@@ -264,7 +267,7 @@ function renderRecord(record: MemoryRecord, links: readonly MemoryLink[]): strin
264
267
  ].filter((line): line is string => Boolean(line)).join('\n');
265
268
  }
266
269
 
267
- function filterFromOptions(options: ParsedOptions, defaultLimit: number): MemorySearchFilter {
270
+ export function filterFromOptions(options: ParsedOptions, defaultLimit: number): MemorySearchFilter {
268
271
  const filter: MemorySearchFilter = {
269
272
  limit: parseLimit(optionValue(options, 'limit'), defaultLimit),
270
273
  };
@@ -281,7 +284,7 @@ function addProvenance(provenance: ProvenanceLink[], kind: ProvenanceLinkKind, r
281
284
  if (ref !== undefined && ref.trim().length > 0) provenance.push({ kind, ref: ref.trim() });
282
285
  }
283
286
 
284
- function provenanceFromOptions(options: ParsedOptions): readonly ProvenanceLink[] {
287
+ export function provenanceFromOptions(options: ParsedOptions): readonly ProvenanceLink[] {
285
288
  const provenance: ProvenanceLink[] = [];
286
289
  addProvenance(provenance, 'session', optionValue(options, 'session'));
287
290
  addProvenance(provenance, 'turn', optionValue(options, 'turn'));
@@ -369,7 +372,7 @@ function isMemoryBundle(value: unknown): value is MemoryBundle {
369
372
  && value.links.every(isMemoryLink);
370
373
  }
371
374
 
372
- function readBundle(path: string): MemoryBundle {
375
+ export function readBundle(path: string): MemoryBundle {
373
376
  const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'));
374
377
  if (!isMemoryBundle(parsed)) throw new Error('Invalid Agent memory bundle.');
375
378
  for (const record of parsed.records) {
@@ -383,11 +386,11 @@ function readBundle(path: string): MemoryBundle {
383
386
  return parsed;
384
387
  }
385
388
 
386
- function resolvePath(runtime: CliCommandRuntime, path: string): string {
389
+ export function resolvePath(runtime: CliCommandRuntime, path: string): string {
387
390
  return resolve(runtime.workingDirectory, path);
388
391
  }
389
392
 
390
- function writeBundle(path: string, bundle: MemoryBundle): void {
393
+ export function writeBundle(path: string, bundle: MemoryBundle): void {
391
394
  mkdirSync(dirname(path), { recursive: true });
392
395
  writeFileSync(path, `${JSON.stringify(bundle, null, 2)}\n`);
393
396
  }
@@ -644,6 +647,12 @@ export async function handleMemoryCommand(runtime: CliCommandRuntime): Promise<C
644
647
  const [sub = 'list', ...rest] = runtime.cli.commandArgs;
645
648
  const normalized = sub.toLowerCase();
646
649
  if (normalized === 'handoff-inspect' || normalized === 'inspect') return handleInspect(runtime, rest);
650
+ // Wire-first: try the memory spine when a daemon is reachable and the
651
+ // subcommand has a wire-covered equivalent — see the CLI ruling in
652
+ // memory-command-wire.ts. Falls through (null) to the unchanged local-direct
653
+ // path below for everything else, or when no daemon answers.
654
+ const wireResult = await tryWireMemoryCommand(runtime, normalized, rest);
655
+ if (wireResult) return wireResult;
647
656
  return await withMemory(runtime, async (context) => {
648
657
  if (normalized === 'list' || normalized === 'ls') return handleList(runtime, context, rest);
649
658
  if (normalized === 'search' || normalized === 'find') return handleSearch(runtime, context, rest);
@@ -275,7 +275,7 @@ export async function initializeBootstrapCore(
275
275
  registerAgentChannelSendTool(toolRegistry, services.channelDeliveryRouter, { shellPaths: services.shellPaths });
276
276
  registerAgentKnowledgeTool(toolRegistry, services.shellPaths, configManager);
277
277
  registerAgentLearningConsolidationTool(toolRegistry, services.shellPaths, services.memoryRegistry);
278
- registerAgentLocalRegistryTool(toolRegistry, services.shellPaths, services.memoryRegistry);
278
+ registerAgentLocalRegistryTool(toolRegistry, services.shellPaths, services.memoryRegistry, services.memorySpineClient);
279
279
  registerAgentMediaGenerateTool(toolRegistry, services.mediaProviders, services.artifactStore);
280
280
  registerAgentResearchRunsTool(toolRegistry, services.shellPaths);
281
281
  registerAgentResearchSourcesTool(toolRegistry, services.shellPaths);
@@ -368,7 +368,7 @@ export async function initializeBootstrapCore(
368
368
  // file (which would create near-duplicate persona records). After this, the VIBE
369
369
  // prompt is a PROJECTION of those records (buildVibeProjectionPrompt).
370
370
  try {
371
- const importedPersona = await importVibeFilesIntoMemoryOnce(services.memoryRegistry, services.shellPaths);
371
+ const importedPersona = await importVibeFilesIntoMemoryOnce(services.memorySpineClient, services.shellPaths);
372
372
  if (importedPersona > 0) {
373
373
  logger.info('agent VIBE.md persona migration', { records: importedPersona });
374
374
  }