@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.
@@ -40,6 +40,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
40
40
  import { startMcpConfigAutoReload } from '../mcp/runtime-reload.ts';
41
41
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
42
42
  import { foldLegacySpineStore } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
43
+ import { reconcileMemorySpineAdoption } from './memory-spine-adoption.ts';
43
44
  import { AgentPromptContextReceiptStore, composeRuntimePromptWithReceipt } from '../agent/prompt-context-receipts.ts';
44
45
  import { registerAgentAuditTool } from '../tools/agent-audit-tool.ts';
45
46
  import { registerAgentAutonomyTool } from '../tools/agent-autonomy-tool.ts';
@@ -214,6 +215,16 @@ export async function bootstrapRuntime(
214
215
  runtimeBus.on<Extract<TurnEvent, { type: 'TURN_SUBMITTED' }>>('TURN_SUBMITTED', (event) => {
215
216
  activePromptTurnId = event.payload.turnId;
216
217
  activePromptTurnText = event.payload.prompt;
218
+ // Per-turn recall refresh (SDK 1.2.0 sync-recall seam): the ASYNC
219
+ // pre-turn hook. getSystemPrompt below is SYNCHRONOUS and cannot await
220
+ // this, so it is fired here (not awaited) and read back via the cached
221
+ // recallSnapshot() a moment later when the orchestrator actually builds
222
+ // the prompt. A miss (this turn's prompt reads last turn's snapshot, or
223
+ // the still-empty boot snapshot on a very first turn) is surfaced
224
+ // honestly by the snapshot's own staleness note, never silently hidden.
225
+ services.memorySpineClient.refreshRecallSnapshot(undefined, { recall: false }).catch((error: unknown) => {
226
+ logger.debug('Per-turn memory recall snapshot refresh failed', { error: summarizeError(error) });
227
+ });
217
228
  }),
218
229
  runtimeBus.on<Extract<TurnEvent, { type: 'TURN_COMPLETED' }>>('TURN_COMPLETED', (event) => {
219
230
  promptContextReceipts.recordTurnOutcome({
@@ -291,6 +302,7 @@ export async function bootstrapRuntime(
291
302
  shellPaths: services.shellPaths,
292
303
  memoryRegistry: services.memoryRegistry,
293
304
  turnText: activePromptTurnText,
305
+ memoryRecallSnapshot: services.memorySpineClient.recallSnapshot(),
294
306
  });
295
307
  promptContextReceipts.record(composed.receipt);
296
308
  return composed.prompt;
@@ -523,6 +535,55 @@ export async function bootstrapRuntime(
523
535
  logger.debug('Deferred session-spine startup failed', { error: summarizeError(error) });
524
536
  },
525
537
  });
538
+ // Memory spine (SDK 1.1.0): the daemon-owned canonical memory store is
539
+ // single-writer, so the agent must make an explicit adopted/not-adopted decision
540
+ // rather than trying the wire per call. Reuses the SAME reachability signal as the
541
+ // session-spine fold above (services.sessionSpineClient.probeReachability(), one
542
+ // daemon, one connected-host token) instead of inventing a second probe. On a
543
+ // reachable daemon, activate the spine for CLIENT mode — every wire-covered memory
544
+ // op now routes over HTTP and the local store is never written again. Embedded/
545
+ // offline is unaffected: the agent must keep working with no daemon running, and a
546
+ // failed/absent probe simply leaves the client in its constructed LOCAL mode.
547
+ //
548
+ // A daemon can also appear or disappear AFTER boot, so this keeps checking for the
549
+ // whole process lifetime on the same cadence as the runtime heartbeat: adopt late
550
+ // if one shows up, and hand back to local (deactivate) the moment a PREVIOUSLY
551
+ // adopted daemon stops answering — never guess and keep routing to a dead wire.
552
+ let memorySpineHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
553
+ const reconcileMemorySpine = (): Promise<void> => reconcileMemorySpineAdoption({
554
+ memorySpineClient: services.memorySpineClient,
555
+ transport: services.memorySpineTransport,
556
+ probeReachability: () => services.sessionSpineClient.probeReachability(),
557
+ });
558
+ deferredStartup.schedule({
559
+ label: 'memory-spine',
560
+ run: async () => {
561
+ await reconcileMemorySpine();
562
+ // Prime the recall snapshot (SDK 1.2.0 sync-recall seam) so the FIRST
563
+ // system prompt built after boot already has a real snapshot to read
564
+ // instead of the honest-but-empty "not yet captured" one — see
565
+ // recallSnapshot's doc comment on prompt-context-receipts.ts's
566
+ // memoryRecallSnapshot field. { recall: false } captures an unfiltered
567
+ // browse set: the receipt's own eligibility/suppression logic (not the
568
+ // snapshot) decides what is prompt-active, so this must mirror the old
569
+ // memoryRegistry.getAll() read exactly, not the recall-floor-filtered set.
570
+ await services.memorySpineClient.refreshRecallSnapshot(undefined, { recall: false }).catch((error: unknown) => {
571
+ logger.debug('Initial memory recall snapshot refresh failed', { error: summarizeError(error) });
572
+ });
573
+ if (configManager.get('watchers.enabled')) {
574
+ const intervalMs = Number(configManager.get('watchers.heartbeatIntervalMs') ?? 30_000);
575
+ memorySpineHeartbeatTimer = setInterval(() => {
576
+ reconcileMemorySpine().catch((error: unknown) => {
577
+ logger.debug('Memory-spine reachability recheck failed', { error: summarizeError(error) });
578
+ });
579
+ }, intervalMs);
580
+ memorySpineHeartbeatTimer.unref?.();
581
+ }
582
+ },
583
+ onError: (error) => {
584
+ logger.debug('Deferred memory-spine startup failed', { error: summarizeError(error) });
585
+ },
586
+ });
526
587
 
527
588
  const toolCount = toolRegistry.list().length;
528
589
  conversation.splashOptions = {
@@ -660,6 +721,13 @@ export async function bootstrapRuntime(
660
721
  // the heartbeat timer. Tolerates a racing daemon stop; never blocks teardown.
661
722
  services.sessionSpineClient.close(runtime.sessionId);
662
723
  services.sessionSpineClient.dispose();
724
+ // Stop the memory-spine reachability recheck timer (see the 'memory-spine'
725
+ // deferred startup task above). No wire close call needed — unlike sessions,
726
+ // memory ops are request/response, not a registered/heartbeat-tracked record.
727
+ if (memorySpineHeartbeatTimer !== null) {
728
+ clearInterval(memorySpineHeartbeatTimer);
729
+ memorySpineHeartbeatTimer = null;
730
+ }
663
731
  // Clear bootstrap-owned subscriptions
664
732
  bootstrapUnsubs.forEach(fn => fn());
665
733
  bootstrapUnsubs.length = 0;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * memory-spine-adoption.ts
3
+ *
4
+ * The single policy for deciding whether the agent's memory spine should be
5
+ * routing over the wire (CLIENT mode, an adopted daemon owns the store) or
6
+ * reading/writing its own local store (LOCAL mode) — given the SAME
7
+ * daemon-reachability signal already used by the session spine
8
+ * (services.sessionSpineClient.probeReachability()), per Mike's direction to reuse
9
+ * the agent's existing daemon-adoption signal rather than invent a second one.
10
+ *
11
+ * Extracted out of bootstrap.ts so this decision is unit-testable in isolation and
12
+ * so the boot-time check and the periodic recheck (a daemon can appear or
13
+ * disappear after boot) run through exactly ONE code path, not two copies that
14
+ * could drift apart.
15
+ */
16
+ import type { MemorySpineClient, MemoryTransport } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
17
+
18
+ export interface MemorySpineAdoptionOptions {
19
+ /** Only the three methods this policy needs — narrow on purpose so a test double is trivial to write. */
20
+ readonly memorySpineClient: Pick<MemorySpineClient, 'active' | 'activate' | 'deactivate'>;
21
+ readonly transport: MemoryTransport;
22
+ /** The existing daemon-adoption signal — reuse services.sessionSpineClient.probeReachability() in production. */
23
+ readonly probeReachability: () => Promise<'unknown' | 'online' | 'offline'>;
24
+ readonly deactivateReason?: string;
25
+ }
26
+
27
+ /**
28
+ * Runs one reachability check and adopts/releases the daemon accordingly:
29
+ * - reachable AND not yet active -> activate(transport) (adopt the daemon; every
30
+ * wire-covered memory op now routes over HTTP and the local store is never
31
+ * written again, for as long as this holds).
32
+ * - NOT reachable AND currently active -> deactivate(reason) (hand back to owned
33
+ * local access — a sustained daemon loss, never guessed from a single call
34
+ * failure elsewhere; see memory-spine/client.ts's honest-failure contract).
35
+ * - otherwise: no-op, already in the correct mode.
36
+ *
37
+ * A probe rejection propagates to the caller (it does not swallow errors) — the
38
+ * caller decides how to log a failed reachability check (see bootstrap.ts's
39
+ * onError handling on the deferred startup task and the interval tick).
40
+ */
41
+ export async function reconcileMemorySpineAdoption(options: MemorySpineAdoptionOptions): Promise<void> {
42
+ const reachable = (await options.probeReachability()) === 'online';
43
+ if (reachable && !options.memorySpineClient.active) {
44
+ options.memorySpineClient.activate(options.transport);
45
+ return;
46
+ }
47
+ if (!reachable && options.memorySpineClient.active) {
48
+ options.memorySpineClient.deactivate(options.deactivateReason ?? 'daemon unreachable on periodic reachability check');
49
+ }
50
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * memory-spine-rest-transport.ts
3
+ *
4
+ * The agent's REST transport adapter for the SDK's memory spine
5
+ * (`@pellux/goodvibes-sdk/platform/runtime/memory-spine`). The daemon's canonical
6
+ * memory store is single-writer: once this agent process has adopted a daemon, every
7
+ * memory operation must go over the wire and the agent must never open its own copy
8
+ * of the store file. This file is the thin REST mirror that realizes that wire —
9
+ * hand-rolled rather than a typed daemon client, for the same version-tolerance
10
+ * reason as session-spine-rest-transport.ts (the agent may run against an older
11
+ * pinned SDK/daemon pair that predates one of these routes).
12
+ *
13
+ * HONEST-FAILURE CONTRACT. Unlike the session mirror (fire-and-forget, degrades to
14
+ * an offline queue), a memory read/write returns data the caller actually uses. So
15
+ * every method here either resolves with the real record/result or REJECTS the
16
+ * promise — it never invents a placeholder success and never silently falls back to
17
+ * a local copy. That fallback decision belongs to the caller (MemorySpineClient),
18
+ * which is deliberately built so its wire branch never touches the local store.
19
+ *
20
+ * Reuses the connection resolver and reachability probe already built for the
21
+ * session spine (session-spine-rest-transport.ts) — same daemon, same connected-host
22
+ * token, one resolver for both spines.
23
+ */
24
+
25
+ import type {
26
+ MemoryAddOptions,
27
+ MemoryBundle,
28
+ MemoryLink,
29
+ MemoryRecord,
30
+ MemorySearchFilter,
31
+ MemorySemanticSearchResult,
32
+ } from '@pellux/goodvibes-sdk/platform/state';
33
+ import type { HonestMemorySearchOptions, HonestMemorySearchResult } from '@pellux/goodvibes-sdk/platform/state';
34
+ import { foldMemoryWireExtendedError } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
35
+ import type { MemoryTransport, MemoryUpdatePatch } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
36
+ import type { SessionRegistrationConnection } from '../agent/session-registration.ts';
37
+
38
+ // MemoryReviewPatch is not part of the public `platform/state` barrel export (only
39
+ // the SDK's internal memory-store module has it), so its shape is derived from the
40
+ // MemoryTransport method signature itself rather than importing an unexported type.
41
+ type MemoryReviewPatch = Parameters<MemoryTransport['updateReview']>[1];
42
+
43
+ // MemoryImportResult is likewise internal-only; derived from importBundle's return
44
+ // type rather than importing an unexported type from the store module. importBundle
45
+ // is an EXTENDED (optional-on-the-type) verb, so NonNullable strips the `| undefined`
46
+ // before ReturnType can apply.
47
+ type MemoryImportResult = Awaited<ReturnType<NonNullable<MemoryTransport['importBundle']>>>;
48
+
49
+ const DEFAULT_MEMORY_WIRE_TIMEOUT_MS = 2_000;
50
+
51
+ export interface MemorySpineRestTransportOptions {
52
+ readonly resolveConnection: () => SessionRegistrationConnection;
53
+ readonly timeoutMs?: number;
54
+ }
55
+
56
+ type JsonRecord = Record<string, unknown>;
57
+
58
+ function isRecord(value: unknown): value is JsonRecord {
59
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
60
+ }
61
+
62
+ async function readJsonBody(response: Response): Promise<unknown> {
63
+ const text = await response.text();
64
+ if (!text.trim()) return {};
65
+ try {
66
+ return JSON.parse(text) as unknown;
67
+ } catch {
68
+ return text;
69
+ }
70
+ }
71
+
72
+ function errorDetail(body: unknown): string {
73
+ return isRecord(body) && typeof body.error === 'string' ? body.error : '';
74
+ }
75
+
76
+ /** A thrown wire error that carries the HTTP status and the daemon's structured body code. */
77
+ type WireHttpError = Error & { status?: number; code?: string };
78
+
79
+ /**
80
+ * The wire `code` the daemon sets on a 404 whose body means the addressed RECORD
81
+ * does not exist (the route ran; the store had no such id) is now discriminated by
82
+ * the SDK's own `foldMemoryWireExtendedError` (platform/runtime/memory-spine), which
83
+ * classifies a caught wire error from its `.status`/`.code` (stamped onto the error
84
+ * by {@link wireFetch} below) exactly the way this file used to inline by hand. That
85
+ * hand-rolled copy is retired in favor of the shared helper — see
86
+ * wire-verb-availability.ts in the SDK for the record-missing vs. method-unavailable
87
+ * vs. other classification this delegates to.
88
+ *
89
+ * PIN NOTE: `foldMemoryWireExtendedError` ships on the SDK's main branch but is not
90
+ * yet in a published npm release (the pinned devDependency is 1.2.0, which predates
91
+ * this export). This file was adopted and proven against the unreleased SDK via the
92
+ * local sdk-dev overlay; `bun run typecheck` will not pass against the CURRENTLY
93
+ * PINNED 1.2.0 until the SDK publishes a release containing this export and the
94
+ * pin in package.json is bumped to it. That bump is a separate, later step.
95
+ */
96
+
97
+ /** Fold for a NULLABLE record-scoped verb: record-miss → null; version-skew → honest reject; else rethrow. */
98
+ function foldNullableMemoryWire404(verb: string, error: unknown): null {
99
+ foldMemoryWireExtendedError(verb, error);
100
+ // foldMemoryWireExtendedError throws for 'method-unavailable' and 'other'; it only
101
+ // falls through here for a genuine record-miss, which this nullable verb folds to null.
102
+ return null;
103
+ }
104
+
105
+ /** Fold for a NON-NULLABLE verb: version-skew → honest reject; else rethrow (incl. a record-missing 404). */
106
+ function rethrowMemoryWire404(verb: string, error: unknown): never {
107
+ foldMemoryWireExtendedError(verb, error);
108
+ // Falls through only for a genuine record-miss; a non-nullable verb has no null to
109
+ // return, so it rethrows the original error rather than inventing a placeholder.
110
+ throw error;
111
+ }
112
+
113
+ /**
114
+ * The one fetch wrapper every memory-wire call goes through. Throws (never
115
+ * returns an ok:false envelope) on a missing token, a non-2xx response, or a
116
+ * network/timeout failure — the honest-failure contract above.
117
+ */
118
+ async function wireFetch(
119
+ connection: SessionRegistrationConnection,
120
+ path: string,
121
+ init: { readonly method: 'GET' | 'POST' | 'DELETE'; readonly body?: JsonRecord },
122
+ timeoutMs: number,
123
+ ): Promise<unknown> {
124
+ if (!connection.token) {
125
+ throw new Error(connection.tokenPath
126
+ ? `memory spine: no connected-host operator token found at ${connection.tokenPath}`
127
+ : 'memory spine: no connected-host operator token found');
128
+ }
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
131
+ try {
132
+ const response = await fetch(`${connection.baseUrl}${path}`, {
133
+ method: init.method,
134
+ headers: {
135
+ authorization: `Bearer ${connection.token}`,
136
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
137
+ },
138
+ ...(init.body ? { body: JSON.stringify(init.body) } : {}),
139
+ signal: controller.signal,
140
+ });
141
+ const body = await readJsonBody(response);
142
+ if (!response.ok) {
143
+ const detail = errorDetail(body);
144
+ // Carry the status AND the structured body code onto the thrown error so the
145
+ // memory-wire 404 discriminator can tell a record-missing 404 (which folds to
146
+ // null) apart from a route-not-found 404 from an older daemon (an honest
147
+ // "verb unavailable" reject) — never on the bare status alone.
148
+ const error = new Error(`memory spine: HTTP ${response.status} on ${path}${detail ? `: ${detail}` : ''}`) as WireHttpError;
149
+ error.status = response.status;
150
+ const code = isRecord(body) && typeof body.code === 'string' ? body.code : undefined;
151
+ if (code !== undefined) error.code = code;
152
+ throw error;
153
+ }
154
+ return body;
155
+ } finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+
160
+ function requireRecord(body: unknown, path: string): MemoryRecord {
161
+ if (isRecord(body) && isRecord(body.record)) return body.record as unknown as MemoryRecord;
162
+ throw new Error(`memory spine: malformed response from ${path} (missing record)`);
163
+ }
164
+
165
+ /**
166
+ * Builds the wire `MemoryTransport` the agent injects into `MemorySpineClient` once
167
+ * it has confirmed a daemon is adopted. Mirrors the daemon-owned `memory.records.*`
168
+ * routes exactly (see method-catalog-runtime.ts in the SDK):
169
+ *
170
+ * CORE (1.1.0): POST /api/memory/records, POST /api/memory/records/search,
171
+ * GET/DELETE /api/memory/records/{id}, POST /api/memory/records/{id}/review.
172
+ *
173
+ * EXTENDED (1.2.0 full-detach catalog): POST /api/memory/records/list,
174
+ * POST /api/memory/records/search-semantic, POST /api/memory/records/{id}/update,
175
+ * GET/POST /api/memory/records/{id}/links, POST /api/memory/records/export,
176
+ * POST /api/memory/records/import. Deliberately NOT implemented here —
177
+ * reviewQueue and vectorStats/doctor — see the CLI ruling in
178
+ * memory-command-wire.ts for why those stay local-direct rather than wired.
179
+ */
180
+ export function createMemorySpineRestTransport(options: MemorySpineRestTransportOptions): MemoryTransport {
181
+ const timeoutMs = options.timeoutMs ?? DEFAULT_MEMORY_WIRE_TIMEOUT_MS;
182
+
183
+ return {
184
+ async add(opts: MemoryAddOptions): Promise<MemoryRecord> {
185
+ const connection = options.resolveConnection();
186
+ const body = await wireFetch(connection, '/api/memory/records', { method: 'POST', body: opts as unknown as JsonRecord }, timeoutMs);
187
+ return requireRecord(body, '/api/memory/records');
188
+ },
189
+
190
+ async honestSearch(filter: MemorySearchFilter, searchOptions?: HonestMemorySearchOptions): Promise<HonestMemorySearchResult> {
191
+ const connection = options.resolveConnection();
192
+ const requestBody: JsonRecord = { ...filter, ...(searchOptions?.recall ? { recall: true } : {}) };
193
+ const body = await wireFetch(connection, '/api/memory/records/search', { method: 'POST', body: requestBody }, timeoutMs);
194
+ if (!isRecord(body) || !Array.isArray(body.records)) {
195
+ throw new Error('memory spine: malformed response from /api/memory/records/search (missing records)');
196
+ }
197
+ return body as unknown as HonestMemorySearchResult;
198
+ },
199
+
200
+ async get(id: string): Promise<MemoryRecord | null> {
201
+ const connection = options.resolveConnection();
202
+ try {
203
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(id)}`, { method: 'GET' }, timeoutMs);
204
+ return requireRecord(body, '/api/memory/records/{id}');
205
+ } catch (error) {
206
+ // A record-missing 404 (MEMORY_RECORD_NOT_FOUND) is an honest "not found" and
207
+ // folds to null exactly like the local store's `get()`. A route-not-found 404
208
+ // (older daemon) rejects honestly; every other failure still rejects.
209
+ return foldNullableMemoryWire404('get', error);
210
+ }
211
+ },
212
+
213
+ async updateReview(id: string, patch: MemoryReviewPatch): Promise<MemoryRecord | null> {
214
+ const connection = options.resolveConnection();
215
+ try {
216
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(id)}/review`, { method: 'POST', body: patch as unknown as JsonRecord }, timeoutMs);
217
+ return requireRecord(body, '/api/memory/records/{id}/review');
218
+ } catch (error) {
219
+ return foldNullableMemoryWire404('updateReview', error);
220
+ }
221
+ },
222
+
223
+ async delete(id: string): Promise<boolean> {
224
+ const connection = options.resolveConnection();
225
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(id)}`, { method: 'DELETE' }, timeoutMs);
226
+ // The route always answers 200 with an honest { id, deleted } boolean (never a
227
+ // 200 that pretends a phantom row was removed) — see integration-routes.ts.
228
+ return isRecord(body) && body.deleted === true;
229
+ },
230
+
231
+ // ── Extended verbs (1.2.0 full-detach catalog) ──────────────────────────
232
+
233
+ async list(filter?: MemorySearchFilter): Promise<readonly MemoryRecord[]> {
234
+ const connection = options.resolveConnection();
235
+ try {
236
+ const body = await wireFetch(connection, '/api/memory/records/list', { method: 'POST', body: (filter ?? {}) as unknown as JsonRecord }, timeoutMs);
237
+ if (!isRecord(body) || !Array.isArray(body.records)) {
238
+ throw new Error('memory spine: malformed response from /api/memory/records/list (missing records)');
239
+ }
240
+ return body.records as unknown as readonly MemoryRecord[];
241
+ } catch (error) {
242
+ return rethrowMemoryWire404('list', error);
243
+ }
244
+ },
245
+
246
+ async searchSemantic(filter?: MemorySearchFilter): Promise<readonly MemorySemanticSearchResult[]> {
247
+ const connection = options.resolveConnection();
248
+ try {
249
+ const body = await wireFetch(connection, '/api/memory/records/search-semantic', { method: 'POST', body: (filter ?? {}) as unknown as JsonRecord }, timeoutMs);
250
+ if (!isRecord(body) || !Array.isArray(body.results)) {
251
+ throw new Error('memory spine: malformed response from /api/memory/records/search-semantic (missing results)');
252
+ }
253
+ // distance is nullable on the wire (Infinity, a no-vector-match fallback,
254
+ // serializes to null) — an honest signal the row was ranked lexically, not
255
+ // by vector; forwarded through verbatim, not coerced.
256
+ return body.results as unknown as readonly MemorySemanticSearchResult[];
257
+ } catch (error) {
258
+ return rethrowMemoryWire404('searchSemantic', error);
259
+ }
260
+ },
261
+
262
+ async update(id: string, patch: MemoryUpdatePatch): Promise<MemoryRecord | null> {
263
+ const connection = options.resolveConnection();
264
+ try {
265
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(id)}/update`, { method: 'POST', body: patch as unknown as JsonRecord }, timeoutMs);
266
+ return requireRecord(body, '/api/memory/records/{id}/update');
267
+ } catch (error) {
268
+ return foldNullableMemoryWire404('update', error);
269
+ }
270
+ },
271
+
272
+ async link(fromId: string, toId: string, relation: string): Promise<MemoryLink | null> {
273
+ const connection = options.resolveConnection();
274
+ try {
275
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(fromId)}/links`, { method: 'POST', body: { toId, relation } }, timeoutMs);
276
+ if (!isRecord(body) || !isRecord(body.link)) {
277
+ throw new Error('memory spine: malformed response from /api/memory/records/{id}/links (missing link)');
278
+ }
279
+ return body.link as unknown as MemoryLink;
280
+ } catch (error) {
281
+ // A record-missing 404 means an endpoint id does not exist — an honest "link
282
+ // not made", folded to null like the local registry's link(). A route-not-found
283
+ // 404 (older daemon) rejects honestly.
284
+ return foldNullableMemoryWire404('link', error);
285
+ }
286
+ },
287
+
288
+ async linksFor(id: string): Promise<readonly MemoryLink[]> {
289
+ const connection = options.resolveConnection();
290
+ try {
291
+ const body = await wireFetch(connection, `/api/memory/records/${encodeURIComponent(id)}/links`, { method: 'GET' }, timeoutMs);
292
+ if (!isRecord(body) || !Array.isArray(body.links)) {
293
+ throw new Error('memory spine: malformed response from /api/memory/records/{id}/links (missing links)');
294
+ }
295
+ return body.links as unknown as readonly MemoryLink[];
296
+ } catch (error) {
297
+ return rethrowMemoryWire404('linksFor', error);
298
+ }
299
+ },
300
+
301
+ async exportBundle(filter?: MemorySearchFilter): Promise<MemoryBundle> {
302
+ const connection = options.resolveConnection();
303
+ try {
304
+ const body = await wireFetch(connection, '/api/memory/records/export', { method: 'POST', body: (filter ?? {}) as unknown as JsonRecord }, timeoutMs);
305
+ if (!isRecord(body) || !isRecord(body.bundle)) {
306
+ throw new Error('memory spine: malformed response from /api/memory/records/export (missing bundle)');
307
+ }
308
+ return body.bundle as unknown as MemoryBundle;
309
+ } catch (error) {
310
+ return rethrowMemoryWire404('exportBundle', error);
311
+ }
312
+ },
313
+
314
+ async importBundle(bundle: MemoryBundle): Promise<MemoryImportResult> {
315
+ const connection = options.resolveConnection();
316
+ try {
317
+ const body = await wireFetch(connection, '/api/memory/records/import', { method: 'POST', body: { bundle } as unknown as JsonRecord }, timeoutMs);
318
+ if (!isRecord(body) || !isRecord(body.result)) {
319
+ throw new Error('memory spine: malformed response from /api/memory/records/import (missing result)');
320
+ }
321
+ return body.result as unknown as MemoryImportResult;
322
+ } catch (error) {
323
+ return rethrowMemoryWire404('importBundle', error);
324
+ }
325
+ },
326
+ };
327
+ }
@@ -18,6 +18,9 @@ import {
18
18
  createSpineRestProbe,
19
19
  createSpineRestTransport,
20
20
  } from './session-spine-rest-transport.ts';
21
+ import { MemorySpineClient, createLocalMemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
22
+ import type { MemoryTransport } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
23
+ import { createMemorySpineRestTransport } from './memory-spine-rest-transport.ts';
21
24
  import { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
22
25
  import { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
23
26
  import {
@@ -429,6 +432,20 @@ export interface RuntimeServices extends SdkRuntimeServices {
429
432
  readonly workPlanStore: WorkPlanStore;
430
433
  readonly memoryStore: MemoryStore;
431
434
  readonly memoryRegistry: MemoryRegistry;
435
+ /**
436
+ * The consumer half of the daemon-served memory spine (SDK 1.1.0). Constructed
437
+ * in LOCAL mode always (wrapping `memoryRegistry` directly); a deferred boot task
438
+ * (see bootstrap.ts, mirroring the session-spine reachability probe) activates it
439
+ * with the REST wire transport when the agent confirms an adopted daemon, and
440
+ * deactivates it back to local on confirmed daemon loss. Every consumer that can
441
+ * express its memory op through the five wire-covered methods (add/honestSearch/
442
+ * get/updateReview/delete) should route through THIS client, never `memoryRegistry`
443
+ * directly, so it automatically stops touching the local store file the moment a
444
+ * daemon is adopted (the single-writer invariant — see memory-spine/client.ts).
445
+ */
446
+ readonly memorySpineClient: MemorySpineClient;
447
+ /** The wire transport handed to `memorySpineClient.activate()` on daemon adoption; exposed so bootstrap can activate/reuse it without rebuilding the connection resolver. */
448
+ readonly memorySpineTransport: MemoryTransport;
432
449
  readonly serviceRegistry: ServiceRegistry;
433
450
  readonly secretsManager: SecretsManager;
434
451
  readonly subscriptionManager: SubscriptionManager;
@@ -648,6 +665,20 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
648
665
  embeddingRegistry: memoryEmbeddingRegistry,
649
666
  });
650
667
  const memoryRegistry = new MemoryRegistry(memoryStore);
668
+ // Consumer half of the memory spine (SDK 1.1.0). Always constructed in LOCAL mode
669
+ // (embedded/offline is a hard requirement — the agent must work with no daemon
670
+ // running). `spineResolveConnection` above is the SAME connected-host connection
671
+ // (host/port/token) already built for the session spine; one daemon, one resolver.
672
+ // Activation to CLIENT mode (routing every op over the wire, for the whole process
673
+ // lifetime, never touching this local store again) happens in a deferred boot task
674
+ // — see bootstrap.ts's 'memory-spine' schedule — once that task confirms a daemon
675
+ // is actually adopted, reusing sessionSpineClient.probeReachability() as the
676
+ // existing daemon-adoption signal rather than inventing a second one.
677
+ const memorySpineClient = new MemorySpineClient({
678
+ local: createLocalMemoryAccess(memoryRegistry),
679
+ log: logger,
680
+ });
681
+ const memorySpineTransport = createMemorySpineRestTransport({ resolveConnection: spineResolveConnection });
651
682
  const deliveryManager = new AutomationDeliveryManager({
652
683
  configManager,
653
684
  serviceRegistry,
@@ -955,6 +986,8 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
955
986
  workPlanStore,
956
987
  memoryStore,
957
988
  memoryRegistry,
989
+ memorySpineClient,
990
+ memorySpineTransport,
958
991
  serviceRegistry,
959
992
  secretsManager,
960
993
  subscriptionManager,