@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.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/package/main.js +11456 -4998
- package/docs/README.md +1 -1
- package/docs/voice-and-live-tts.md +47 -0
- package/package.json +2 -2
- package/src/agent/memory-prompt.ts +12 -1
- package/src/agent/prompt-context-receipts.ts +71 -6
- package/src/agent/vibe-file.ts +9 -5
- package/src/audio/spoken-turn-wiring.ts +3 -2
- package/src/cli/memory-command-wire.ts +373 -0
- package/src/cli/memory-command.ts +31 -22
- package/src/runtime/bootstrap-core.ts +2 -2
- package/src/runtime/bootstrap.ts +68 -0
- package/src/runtime/memory-spine-adoption.ts +50 -0
- package/src/runtime/memory-spine-rest-transport.ts +327 -0
- package/src/runtime/services.ts +33 -0
- package/src/tools/agent-local-registry-memory.ts +42 -11
- package/src/tools/agent-local-registry-tool.ts +5 -4
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -455
- package/src/audio/text-chunker.ts +0 -110
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/services.ts
CHANGED
|
@@ -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,
|