@pellux/goodvibes-tui 1.9.2 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/knowledge-graphql.graphql +5 -0
  4. package/docs/foundation-artifacts/knowledge-store.sql +33 -0
  5. package/docs/foundation-artifacts/operator-contract.json +4026 -1376
  6. package/package.json +2 -2
  7. package/src/audio/spoken-turn-wiring.ts +7 -2
  8. package/src/cli/management-utils.ts +4 -0
  9. package/src/export/cost-utils.ts +9 -1
  10. package/src/input/command-registry.ts +11 -0
  11. package/src/input/commands/control-room-runtime.ts +12 -5
  12. package/src/input/commands/incident-runtime.ts +2 -2
  13. package/src/input/commands/memory.ts +11 -11
  14. package/src/input/commands/recall-bundle.ts +8 -8
  15. package/src/input/commands/recall-capture.ts +3 -3
  16. package/src/input/commands/recall-query.ts +41 -15
  17. package/src/input/commands/recall-review.ts +10 -10
  18. package/src/input/settings-modal-data.ts +54 -0
  19. package/src/main.ts +3 -2
  20. package/src/panels/builtin/shared.ts +3 -3
  21. package/src/panels/cost-tracker-panel.ts +2 -2
  22. package/src/panels/modals/memory-modal.ts +77 -16
  23. package/src/runtime/bootstrap-command-context.ts +4 -0
  24. package/src/runtime/bootstrap-command-parts.ts +4 -1
  25. package/src/runtime/bootstrap-shell.ts +15 -4
  26. package/src/runtime/bootstrap.ts +18 -18
  27. package/src/runtime/memory-spine-transport.ts +289 -0
  28. package/src/runtime/orchestrator-core-services.ts +27 -2
  29. package/src/runtime/services.ts +13 -14
  30. package/src/version.ts +1 -1
  31. package/src/audio/spoken-turn-controller.ts +0 -271
  32. package/src/audio/text-chunker.ts +0 -110
@@ -22,7 +22,7 @@ import {
22
22
  DEFAULT_PANEL_PALETTE,
23
23
  type PanelWorkspaceSection,
24
24
  } from './polish.ts';
25
- import { calcSessionCost, isModelPriced, computeBudgetBreach, readBudgetAlertUsd, type BudgetAlertConfigAccess } from '../export/cost-utils.ts';
25
+ import { calcSessionCost, isModelPriced, computeBudgetBreach, readBudgetAlertUsd, BUDGET_ALERT_USD_CONFIG_KEY, type BudgetAlertConfigAccess } from '../export/cost-utils.ts';
26
26
  import { abbreviateCount } from '../utils/format-number.ts';
27
27
  import { isTextBackspace } from '../input/delete-key-policy.ts';
28
28
 
@@ -332,7 +332,7 @@ export class CostTrackerPanel extends BasePanel {
332
332
  public setBudgetThreshold(usd: number): void {
333
333
  if (!Number.isFinite(usd) || usd < 0) return;
334
334
  this.budgetThreshold = usd;
335
- this.configAccess?.set('behavior.budgetAlertUsd', usd);
335
+ this.configAccess?.set(BUDGET_ALERT_USD_CONFIG_KEY, usd);
336
336
  this.markDirty();
337
337
  }
338
338
 
@@ -17,6 +17,16 @@ import type {
17
17
  // panel's selected-record scope/class/tags/provenance detail is folded into
18
18
  // each row label. `memoryRegistry` absent → the retired MemoryPanel's
19
19
  // "not configured" copy renders as an honest degraded state.
20
+ //
21
+ // Read path (memory-spine adoption): reads go through the spine client's
22
+ // `honestSearch` — the MemoryAccess shape — not the raw registry, so a session
23
+ // that has adopted an external daemon reads the SAME wire-served records a
24
+ // wire failure is deliberately surfaced (never a silently stale local copy).
25
+ // `buildView()` stays synchronous/pure per the ConfigModalSurface contract;
26
+ // `refresh()` is async and calls the `requestRender` callback `onOpen` hands
27
+ // it when the data lands. The Review Queue ranking is recomputed client-side
28
+ // from the same honestSearch batch (see `rankForReview` below) rather than
29
+ // through a second wire call — `reviewQueue` is not part of MemoryAccess.
20
30
  // ---------------------------------------------------------------------------
21
31
 
22
32
  /** Minimal read shape of a `MemoryRecord` this modal renders. */
@@ -33,16 +43,42 @@ interface MemoryRecordLike {
33
43
  readonly reviewedAt?: number | undefined;
34
44
  readonly reviewedBy?: string | undefined;
35
45
  readonly createdAt: number;
46
+ readonly updatedAt?: number | undefined;
36
47
  readonly provenance: readonly { readonly kind: string; readonly ref: string }[];
37
48
  }
38
49
 
39
50
  export interface MemoryModalDeps {
40
51
  readonly memoryRegistry?: {
41
- search(filter?: { limit?: number }): readonly MemoryRecordLike[];
42
- reviewQueue(limit?: number): readonly MemoryRecordLike[];
52
+ honestSearch(filter?: { limit?: number }): Promise<{
53
+ readonly records: readonly MemoryRecordLike[];
54
+ readonly indexUnavailableReason?: string | null | undefined;
55
+ }>;
43
56
  };
44
57
  }
45
58
 
59
+ /**
60
+ * Client-side port of the SDK's MemoryStore.reviewQueue ranking
61
+ * (memory-store-helpers.ts reviewQueueScore/isReviewCandidate): all four
62
+ * review states are candidates, scored by state + inverse confidence (flagged
63
+ * states penalized), tie-broken by recency. Presentation ordering only — not
64
+ * a wire call, so exact parity with the server's own tie-breaking on ids it
65
+ * has never seen is not load-bearing.
66
+ */
67
+ function rankForReview(records: readonly MemoryRecordLike[], limit: number): MemoryRecordLike[] {
68
+ const score = (r: MemoryRecordLike): number => {
69
+ let s = 0;
70
+ if (r.reviewState === 'fresh') s += 40;
71
+ if (r.reviewState === 'stale') s += 20;
72
+ if (r.reviewState === 'contradicted') s += 10;
73
+ s += Math.max(0, 100 - r.confidence);
74
+ if (r.reviewState === 'stale' || r.reviewState === 'contradicted') s -= 20;
75
+ return s;
76
+ };
77
+ return [...records]
78
+ .sort((a, b) => score(b) - score(a) || (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) || b.createdAt - a.createdAt)
79
+ .slice(0, limit);
80
+ }
81
+
46
82
  // Mirrors registerKnowledgePanels's withUnconfiguredFallback copy for the
47
83
  // retired MemoryPanel (src/panels/builtin/knowledge.ts) verbatim.
48
84
  const NOT_CONFIGURED_TITLE = 'Memory registry not configured for this session.';
@@ -57,6 +93,9 @@ class MemoryModalSurface implements ConfigModalSurface {
57
93
  readonly title = 'Memory';
58
94
  private allRecords: MemoryRecordLike[] = [];
59
95
  private reviewRecords: MemoryRecordLike[] = [];
96
+ /** Honest note on the last read: a wire failure (client mode) or the index-unavailable fallback reason — never silently dropped. */
97
+ private loadNote: string | null = null;
98
+ private requestRender: (() => void) | null = null;
60
99
 
61
100
  constructor(private readonly deps: MemoryModalDeps) {}
62
101
 
@@ -71,12 +110,27 @@ class MemoryModalSurface implements ConfigModalSurface {
71
110
  { key: 'r', id: 'refresh', label: 'refresh' },
72
111
  ];
73
112
 
74
- onOpen(): void { this.refresh(); }
113
+ onOpen(requestRender: () => void): void {
114
+ this.requestRender = requestRender;
115
+ void this.refresh();
116
+ }
117
+
118
+ onClose(): void {
119
+ this.requestRender = null;
120
+ }
75
121
 
76
- private refresh(): void {
77
- if (!this.deps.memoryRegistry) { this.allRecords = []; this.reviewRecords = []; return; }
78
- this.allRecords = [...this.deps.memoryRegistry.search({ limit: 100 })];
79
- this.reviewRecords = [...this.deps.memoryRegistry.reviewQueue(24)];
122
+ private async refresh(): Promise<void> {
123
+ if (!this.deps.memoryRegistry) { this.allRecords = []; this.reviewRecords = []; this.loadNote = null; return; }
124
+ try {
125
+ const result = await this.deps.memoryRegistry.honestSearch({ limit: 100 });
126
+ this.allRecords = [...result.records];
127
+ this.reviewRecords = rankForReview(result.records, 24);
128
+ this.loadNote = result.indexUnavailableReason ?? null;
129
+ } catch (error) {
130
+ // Client-mode wire failure: surfaced plainly, never masked by the last-known list.
131
+ this.loadNote = `Failed to reach memory over the wire: ${error instanceof Error ? error.message : String(error)}`;
132
+ }
133
+ this.requestRender?.();
80
134
  }
81
135
 
82
136
  private recordFrom(id: string): MemoryRecordLike | undefined {
@@ -121,11 +175,15 @@ class MemoryModalSurface implements ConfigModalSurface {
121
175
  tabs: [{ id: 'all', label: 'All Records', rows: [] }],
122
176
  };
123
177
  }
124
- return { title: 'Memory', tabs: [this.allTab(), this.reviewTab()] };
178
+ return {
179
+ title: 'Memory',
180
+ ...(this.loadNote ? { degraded: this.loadNote } : {}),
181
+ tabs: [this.allTab(), this.reviewTab()],
182
+ };
125
183
  }
126
184
 
127
185
  onAction(id: string, ctx: ConfigModalActionContext): void {
128
- if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloaded memory records.'); return; }
186
+ if (id === 'refresh') { void this.refresh(); ctx.setStatus('Reloading memory records...'); return; }
129
187
  const record = ctx.row ? this.recordFrom(ctx.row.id) : undefined;
130
188
  if (!record) return;
131
189
  const review = (state: string, confidence: number, reason?: string): void => {
@@ -150,9 +208,12 @@ export function createMemoryModalSurface(deps: MemoryModalDeps): ConfigModalSurf
150
208
 
151
209
  /**
152
210
  * Deterministic golden fixture: fixed memory records with frozen createdAt
153
- * timestamps — no live registry, no wall-clock, no random ids.
211
+ * timestamps — no live registry, no wall-clock, no random ids. Promise-backed
212
+ * (see ecosystem-modals-golden.test.ts): `honestSearch` is async even for this
213
+ * in-memory fixture (matching the real MemoryAccess shape), so the factory
214
+ * pre-awaits the initial `onOpen` refresh before handing back the surface.
154
215
  */
155
- export function memoryModalGoldenSurface(): ConfigModalSurface {
216
+ export async function memoryModalGoldenSurface(): Promise<ConfigModalSurface> {
156
217
  const FIXED_CREATED_AT = 1735689600000; // 2025-01-01T00:00:00.000Z
157
218
  const records: readonly MemoryRecordLike[] = [
158
219
  {
@@ -171,10 +232,10 @@ export function memoryModalGoldenSurface(): ConfigModalSurface {
171
232
  createdAt: FIXED_CREATED_AT + 86400000, provenance: [],
172
233
  },
173
234
  ];
174
- return createMemoryModalSurface({
175
- memoryRegistry: {
176
- search: () => records,
177
- reviewQueue: () => records.filter((record) => record.reviewState !== 'reviewed'),
178
- },
235
+ const surface = createMemoryModalSurface({
236
+ memoryRegistry: { honestSearch: async () => ({ records }) },
179
237
  });
238
+ surface.onOpen?.(() => {});
239
+ await new Promise((resolve) => setTimeout(resolve, 0));
240
+ return surface;
180
241
  }
@@ -2,6 +2,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
3
3
  import type { ConversationManager } from '../core/conversation';
4
4
  import type { KnowledgeApi } from '@pellux/goodvibes-sdk/platform/knowledge';
5
+ import type { MemorySpineClient } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
5
6
  import type { HookApi } from '@pellux/goodvibes-sdk/platform/hooks';
6
7
  import type { McpApi } from '@pellux/goodvibes-sdk/platform/mcp';
7
8
  import type { PanelManager } from '../panels/panel-manager.ts';
@@ -107,6 +108,7 @@ export type CreateBootstrapCommandContextOptions = {
107
108
  operatorClient?: OperatorClient;
108
109
  peerClient?: PeerClient;
109
110
  knowledgeApi?: KnowledgeApi;
111
+ memorySpine?: MemorySpineClient;
110
112
  hookApi?: HookApi;
111
113
  mcpApi?: McpApi;
112
114
  opsApi?: OpsApi;
@@ -195,6 +197,7 @@ export function createBootstrapCommandContext(
195
197
  operatorClient,
196
198
  peerClient,
197
199
  knowledgeApi,
200
+ memorySpine,
198
201
  hookApi,
199
202
  mcpApi,
200
203
  opsApi,
@@ -279,6 +282,7 @@ export function createBootstrapCommandContext(
279
282
  peerClient,
280
283
  providerApi,
281
284
  knowledgeApi,
285
+ memorySpine,
282
286
  hookApi,
283
287
  mcpApi,
284
288
  opsApi,
@@ -3,6 +3,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import type { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
4
4
  import type { ConversationManager } from '../core/conversation';
5
5
  import type { KnowledgeApi } from '@pellux/goodvibes-sdk/platform/knowledge';
6
+ import type { MemorySpineClient } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
6
7
  import type { HookApi } from '@pellux/goodvibes-sdk/platform/hooks';
7
8
  import type { McpApi } from '@pellux/goodvibes-sdk/platform/mcp';
8
9
  import type { PanelManager, PanelDeepLinkTarget } from '../panels/panel-manager.ts';
@@ -133,6 +134,7 @@ export interface BootstrapCommandSectionOptions {
133
134
  readonly peerClient?: PeerClient;
134
135
  readonly providerApi?: ProviderApi;
135
136
  readonly knowledgeApi?: KnowledgeApi;
137
+ readonly memorySpine?: MemorySpineClient;
136
138
  readonly hookApi?: HookApi;
137
139
  readonly mcpApi?: McpApi;
138
140
  readonly opsApi?: OpsApi;
@@ -421,7 +423,7 @@ export function createBootstrapCommandExtensionsSection(
421
423
  export function createBootstrapCommandClientsSection(
422
424
  options: Pick<
423
425
  BootstrapCommandSectionOptions,
424
- 'operatorClient' | 'peerClient' | 'providerApi' | 'knowledgeApi' | 'hookApi' | 'mcpApi' | 'opsApi' | 'directTransport'
426
+ 'operatorClient' | 'peerClient' | 'providerApi' | 'knowledgeApi' | 'memorySpine' | 'hookApi' | 'mcpApi' | 'opsApi' | 'directTransport'
425
427
  >,
426
428
  ): BootstrapCommandClientSection {
427
429
  return {
@@ -429,6 +431,7 @@ export function createBootstrapCommandClientsSection(
429
431
  peer: options.peerClient,
430
432
  providerApi: options.providerApi,
431
433
  knowledgeApi: options.knowledgeApi,
434
+ memorySpine: options.memorySpine,
432
435
  hookApi: options.hookApi,
433
436
  mcpApi: options.mcpApi,
434
437
  opsApi: options.opsApi,
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { readBudgetAlertUsd, BUDGET_ALERT_USD_DEFAULT } from '../export/cost-utils.ts';
3
+ import { refreshMemoryRecallSnapshot } from './orchestrator-core-services.ts';
3
4
  import { sumConversationUsage, type ConversationManager } from '../core/conversation';
4
5
  import type { Orchestrator } from '../core/orchestrator';
5
6
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
@@ -189,7 +190,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
189
190
  componentHealthMonitor: services.componentHealthMonitor,
190
191
  worktreeRegistry: services.worktreeRegistry,
191
192
  sandboxSessionRegistry: services.sandboxSessionRegistry,
192
- memoryRegistry: services.memoryRegistry,
193
+ // Memory modal reads via the spine client, not the raw registry (see builtin/shared.ts).
194
+ memoryRegistry: services.memorySpine,
193
195
  uiServices,
194
196
  pluginManager: services.pluginManager,
195
197
  hookDispatcher: services.hookDispatcher,
@@ -299,6 +301,10 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
299
301
  operatorClient: directTransport.operator,
300
302
  peerClient: directTransport.peer,
301
303
  knowledgeApi,
304
+ // /recall's browse/link/queue/export/import subcommands and the per-turn
305
+ // knowledge-injection read route through the spine client, not the raw
306
+ // local registry, so they fully detach when a daemon is adopted.
307
+ memorySpine: services.memorySpine,
302
308
  hookApi,
303
309
  mcpApi,
304
310
  opsApi,
@@ -309,9 +315,14 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
309
315
  loadSystemPrompt: () => loadBootstrapSystemPrompt(configManager),
310
316
  activatePlan: (_planId, task) => {
311
317
  setTimeout(() => {
312
- orchestrator.handleUserInput(task).catch((err) => {
313
- logger.debug('activatePlan handler failed', { error: summarizeError(err) });
314
- });
318
+ void (async () => {
319
+ // Refresh the recall snapshot before this plan-driven turn — see
320
+ // the matching comment in main.ts's submitInput.
321
+ await refreshMemoryRecallSnapshot(services);
322
+ orchestrator.handleUserInput(task).catch((err) => {
323
+ logger.debug('activatePlan handler failed', { error: summarizeError(err) });
324
+ });
325
+ })();
315
326
  }, 50);
316
327
  },
317
328
  completeModelSelectionSideEffect,
@@ -39,6 +39,7 @@ import { startExternalServices, type ExternalServicesHandle, type HostServiceSta
39
39
  import { createHttpTransport } from '@/runtime/index.ts';
40
40
  import { foldLegacySpineStore, deriveSpineFooterStatus } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
41
41
  import { createTuiSpineTransport, type SpineSessionsClient } from './session-spine-transport.ts';
42
+ import { syncMemorySpineToHostStatus, type MemorySpineActiveRef } from './memory-spine-transport.ts';
42
43
  import { pruneStaleOperatorTokens } from '@pellux/goodvibes-sdk/platform/pairing';
43
44
  import { resolveDaemonCompanionToken, workspaceOperatorTokenCandidates } from './operator-token-cleanup.ts';
44
45
  import type { UiRuntimeServices } from './ui-services.ts';
@@ -46,7 +47,7 @@ import { createDeferredStartupCoordinator } from '@/runtime/index.ts';
46
47
  import { initializeBootstrapCore } from './bootstrap-core.ts';
47
48
  import { createBootstrapShell } from './bootstrap-shell.ts';
48
49
  import { announceResumeState } from './resume-notice.ts';
49
- import { buildSharedOrchestratorCoreServices } from './orchestrator-core-services.ts';
50
+ import { buildSharedOrchestratorCoreServices, refreshMemoryRecallSnapshot } from './orchestrator-core-services.ts';
50
51
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
51
52
  import { DaemonServer } from '@pellux/goodvibes-sdk/platform/daemon';
52
53
  import { HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
@@ -238,9 +239,9 @@ export async function bootstrapRuntime(
238
239
  },
239
240
  });
240
241
  conversationFollowUpRef.value = (item) => orchestrator.enqueueConversationFollowUp(item);
241
- // Wire orchestratorHandleUserInputRef so COMPANION_MESSAGE_RECEIVED fires a real LLM turn.
242
+ // Wire orchestratorHandleUserInputRef so COMPANION_MESSAGE_RECEIVED fires a real LLM turn (after a pre-turn recall-snapshot refresh; see main.ts's submitInput).
242
243
  orchestratorHandleUserInputRef.value = (text: string, options?: OrchestratorUserInputOptions) => {
243
- orchestrator.handleUserInput(text, undefined, options).catch((err: unknown) => {
244
+ void refreshMemoryRecallSnapshot(services).then(() => orchestrator.handleUserInput(text, undefined, options)).catch((err: unknown) => {
244
245
  logger.debug('companion handleUserInput safety catch', { error: String(err) });
245
246
  });
246
247
  };
@@ -300,13 +301,10 @@ export async function bootstrapRuntime(
300
301
  wrfcPersistence.rehydrate();
301
302
  const commandRegistry = shell.commandRegistry;
302
303
  const commandContext = shell.commandContext;
303
- // Boot resume notice (UX-D item 1): after rehydrate() so chain history is
304
- // ready, before the operator can type anything. Fire-and-forget the
305
- // checkpoint-count lookup is async (WorkspaceCheckpointManager.list() awaits
306
- // its own init()), and this must not block the rest of bootstrap the way
307
- // main.ts's `void workspaceCheckpointManager.init().catch(() => {})`
308
- // deliberately doesn't either. Local file I/O only; resolves well before a
309
- // human can react to the first rendered frame.
304
+ // Boot resume notice (UX-D item 1): after rehydrate() so chain history is ready, before
305
+ // the operator can type anything. Fire-and-forget, same as main.ts's non-blocking
306
+ // `void workspaceCheckpointManager.init().catch(() => {})` — local file I/O only,
307
+ // resolves well before a human can react to the first rendered frame.
310
308
  void announceResumeState({
311
309
  workingDirectory: services.workingDirectory,
312
310
  homeDirectory: services.homeDirectory,
@@ -390,7 +388,13 @@ export async function bootstrapRuntime(
390
388
  // 'unavailable') also stays local-only and honest. Only 'external' (a separately-
391
389
  // running daemon this TUI adopted) activates the wire mirror.
392
390
  let spineActiveForBaseUrl: string | null = null;
391
+ // Same adoption signal drives the memory spine (WO memory-adopt) — see
392
+ // memory-spine-transport.ts. 'embedded' (this process hosts its own daemon)
393
+ // stays local: the daemon's canonical store IS this process's own
394
+ // memoryRegistry, so there is no wire hop to make.
395
+ const memorySpineActiveRef: MemorySpineActiveRef = { value: null };
393
396
  const syncSessionSpineToHostStatus = (daemonStatus: HostServiceStatus, sharedDaemonToken: string): void => {
397
+ syncMemorySpineToHostStatus(services.memorySpine, daemonStatus.mode, daemonStatus.baseUrl, sharedDaemonToken, memorySpineActiveRef, logger);
394
398
  if (daemonStatus.mode !== 'external') {
395
399
  if (spineActiveForBaseUrl !== null) {
396
400
  sessionSpine.deactivate(`daemon mode changed to '${daemonStatus.mode}'`);
@@ -450,14 +454,10 @@ export async function bootstrapRuntime(
450
454
  httpListenerPortInUse: hostServiceIsBlocked(httpListenerStatus),
451
455
  daemonStatus,
452
456
  httpListenerStatus,
453
- // Honest session-spine posture, independent of daemonRunning —
454
- // 'external'-adopted-but-currently-unreachable degrades to 'offline' here
455
- // even though daemonRunning might still read true from a stale handle.
456
- // sessionSpine.status() alone is ACTIVITY-gated (only updates on a
457
- // register/heartbeat/close), so after the daemon dies mid-idle it would
458
- // keep reading 'online'. Derive the footer status from the union cache's
459
- // 5s liveness probe too — one signal, no new timer — so offline surfaces
460
- // within one refresh interval of the daemon dying.
457
+ // Honest session-spine posture, independent of daemonRunning — degrades to 'offline'
458
+ // when adopted-but-unreachable even though daemonRunning might still read a stale
459
+ // handle as true; sessionSpine.status() alone is activity-gated, so it's derived
460
+ // together with the union cache's 5s liveness probe (one signal, no new timer).
461
461
  sessionSpineActive: sessionSpine.active,
462
462
  sessionSpineStatus: deriveSpineFooterStatus(sessionSpine.status(), sessionUnionCache.crossSurfaceView),
463
463
  };
@@ -0,0 +1,289 @@
1
+ /**
2
+ * memory-spine-transport.ts
3
+ *
4
+ * The TUI memory-spine cutover: the thin wire adapter that lets the TUI's
5
+ * bootstrap activate the SDK's
6
+ * `@pellux/goodvibes-sdk/platform/runtime/memory-spine` `MemorySpineClient`
7
+ * over the daemon's memory.records.* HTTP routes, mirroring
8
+ * session-spine-transport.ts's pattern for the session spine.
9
+ *
10
+ * The SDK does not (yet) ship a typed operator client for these routes (unlike
11
+ * `HttpTransport.operator.sessions`), so this adapter builds a small REST
12
+ * client directly against the known routes — exactly what the SDK's own
13
+ * `MemoryTransport` doc comment describes as the expected consumer shape.
14
+ *
15
+ * FULL DETACH (SDK 1.2.0). Beyond the five 1.1.0 CORE routes (add, search,
16
+ * get, review, delete) this now implements the full 1.2.0 EXTENDED catalog
17
+ * (list, search-semantic, update, links.list, links.add, export, import,
18
+ * vector, doctor, review-queue) — see
19
+ * docs/decisions/2026-07-06-memory-wire-full-detach.md in the SDK repo. This
20
+ * is what lets `/recall`'s browse/link/queue/export/import subcommands and
21
+ * the per-turn knowledge-injection bulk read fully detach from the local
22
+ * store file when a daemon is adopted: every one of those ops now has a wire
23
+ * equivalent, so this transport implements every EXTENDED verb rather than
24
+ * leaving some optional.
25
+ *
26
+ * HONESTY. Unlike the session mirror (fire-and-forget, folded into a soft
27
+ * 'offline' result), memory reads/writes return data the caller depends on —
28
+ * a transport failure here is NOT swallowed. It propagates as a rejected
29
+ * promise, matching `MemorySpineClient`'s documented contract: a wire client
30
+ * must never silently fall back to a divergent local copy in place of a real
31
+ * failure.
32
+ *
33
+ * A 404 is disambiguated by its RESPONSE CODE (`classifyMemoryWireError`, from
34
+ * the SDK's `platform/runtime/memory-spine` — this transport no longer inlines
35
+ * its own copy of the discriminator), never by the bare status. A record-missing
36
+ * 404 carries `MEMORY_RECORD_NOT_FOUND`: on a nullable verb
37
+ * (`get`/`updateReview`/`update`/`link`) it maps to the documented `null` "not
38
+ * found"; on a non-nullable verb (`linksFor` etc.) it is not representable as
39
+ * null so it propagates as a thrown error. ANY OTHER 404 — a route-not-found
40
+ * from an older daemon that never registered this route, or a bare legacy 404
41
+ * with no code — is treated as "this daemon does not serve this verb" and
42
+ * rejects with a stated reason on EVERY verb, never a silent `null`. That is
43
+ * what lets `/recall` surface an honest degraded message on a version-skewed
44
+ * daemon instead of reporting an existing record as gone.
45
+ */
46
+ import { buildUrl, createJsonRequestInit, requestJsonRaw } from '@pellux/goodvibes-sdk/transport-http';
47
+ import {
48
+ classifyMemoryWireError,
49
+ memoryVerbUnavailableError,
50
+ type MemoryAccess,
51
+ type MemoryTransport,
52
+ type MemoryUpdatePatch,
53
+ } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
54
+ import type {
55
+ HonestMemorySearchOptions,
56
+ HonestMemorySearchResult,
57
+ MemoryAddOptions,
58
+ MemoryBundle,
59
+ MemoryDoctorReport,
60
+ MemoryLink,
61
+ MemoryRecord,
62
+ MemoryScope,
63
+ MemorySearchFilter,
64
+ MemorySemanticSearchResult,
65
+ } from '@pellux/goodvibes-sdk/platform/state';
66
+ import type { MemoryVectorStats } from '@pellux/goodvibes-sdk/platform/state';
67
+
68
+ /** Derived from `MemoryAccess` itself rather than imported by name — `MemoryReviewPatch` is not re-exported from the SDK's public `platform/state` entry point. */
69
+ type MemoryReviewPatch = Parameters<MemoryAccess['updateReview']>[1];
70
+ /** Same reasoning — `MemoryImportResult` is not re-exported from a public SDK entry point. */
71
+ type MemoryImportResult = Awaited<ReturnType<MemoryAccess['importBundle']>>;
72
+
73
+ /**
74
+ * The 404 discriminator and the "daemon does not serve this verb" rejection are
75
+ * now the SDK's own `classifyMemoryWireError` / `memoryVerbUnavailableError`
76
+ * (`@pellux/goodvibes-sdk/platform/runtime/memory-spine`) rather than a copy
77
+ * inlined in this transport — this surface's SDK pin now ships them as a
78
+ * shared constant/discriminator, so the two thin folds below just delegate.
79
+ */
80
+
81
+ /** Fold for a NULLABLE record-scoped verb: record-miss → null; version-skew → honest reject; else rethrow. */
82
+ function foldNullableMemoryWire404(verb: string, error: unknown): null {
83
+ const kind = classifyMemoryWireError(error);
84
+ if (kind === 'record-missing') return null;
85
+ if (kind === 'method-unavailable') throw memoryVerbUnavailableError(verb, error);
86
+ throw error;
87
+ }
88
+
89
+ /** Fold for a NON-NULLABLE (collection or record-required) verb: version-skew → honest reject; else rethrow. */
90
+ function rethrowMemoryWire404(verb: string, error: unknown): never {
91
+ if (classifyMemoryWireError(error) === 'method-unavailable') throw memoryVerbUnavailableError(verb, error);
92
+ throw error;
93
+ }
94
+
95
+ export interface MemorySpineWireOptions {
96
+ readonly baseUrl: string;
97
+ readonly authToken: string | null;
98
+ readonly fetchImpl?: typeof fetch;
99
+ }
100
+
101
+ /**
102
+ * Build the `MemorySpineClient` wire transport: a thin REST adapter over the
103
+ * adopted daemon's full memory.records.* route catalog (five CORE routes plus
104
+ * the ten 1.2.0 EXTENDED routes). `bootstrap.ts` activates a client built from
105
+ * this the same way it adopts the session spine.
106
+ */
107
+ export function createTuiMemorySpineTransport(options: MemorySpineWireOptions): MemoryTransport {
108
+ const fetchImpl = options.fetchImpl ?? fetch;
109
+ const token = options.authToken;
110
+ const url = (path: string): string => buildUrl(options.baseUrl, path);
111
+ return {
112
+ add: async (opts: MemoryAddOptions): Promise<MemoryRecord> => {
113
+ const response = await requestJsonRaw<{ record: MemoryRecord }>(
114
+ fetchImpl, url('/api/memory/records'), createJsonRequestInit(token, opts, 'POST'),
115
+ );
116
+ return response.record;
117
+ },
118
+ honestSearch: async (filter?: MemorySearchFilter, searchOptions?: HonestMemorySearchOptions): Promise<HonestMemorySearchResult> => {
119
+ const body = { ...(filter ?? {}), ...(searchOptions?.recall !== undefined ? { recall: searchOptions.recall } : {}) };
120
+ return await requestJsonRaw<HonestMemorySearchResult>(
121
+ fetchImpl, url('/api/memory/records/search'), createJsonRequestInit(token, body, 'POST'),
122
+ );
123
+ },
124
+ get: async (id: string): Promise<MemoryRecord | null> => {
125
+ try {
126
+ const response = await requestJsonRaw<{ record: MemoryRecord }>(
127
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(id)}`), createJsonRequestInit(token),
128
+ );
129
+ return response.record;
130
+ } catch (error) {
131
+ return foldNullableMemoryWire404('get', error);
132
+ }
133
+ },
134
+ updateReview: async (id: string, patch: MemoryReviewPatch): Promise<MemoryRecord | null> => {
135
+ try {
136
+ const response = await requestJsonRaw<{ record: MemoryRecord }>(
137
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(id)}/review`), createJsonRequestInit(token, patch, 'POST'),
138
+ );
139
+ return response.record;
140
+ } catch (error) {
141
+ return foldNullableMemoryWire404('updateReview', error);
142
+ }
143
+ },
144
+ delete: async (id: string): Promise<boolean> => {
145
+ const response = await requestJsonRaw<{ id: string; deleted: boolean }>(
146
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(id)}`), createJsonRequestInit(token, undefined, 'DELETE'),
147
+ );
148
+ return response.deleted;
149
+ },
150
+
151
+ // ── Extended verbs (1.2.0 full-detach) ──────────────────────────────────
152
+
153
+ list: async (filter?: MemorySearchFilter): Promise<readonly MemoryRecord[]> => {
154
+ try {
155
+ const response = await requestJsonRaw<{ records: readonly MemoryRecord[] }>(
156
+ fetchImpl, url('/api/memory/records/list'), createJsonRequestInit(token, filter ?? {}, 'POST'),
157
+ );
158
+ return response.records;
159
+ } catch (error) {
160
+ return rethrowMemoryWire404('list', error);
161
+ }
162
+ },
163
+ searchSemantic: async (filter?: MemorySearchFilter): Promise<readonly MemorySemanticSearchResult[]> => {
164
+ try {
165
+ const response = await requestJsonRaw<{ results: readonly MemorySemanticSearchResult[] }>(
166
+ fetchImpl, url('/api/memory/records/search-semantic'), createJsonRequestInit(token, filter ?? {}, 'POST'),
167
+ );
168
+ return response.results;
169
+ } catch (error) {
170
+ return rethrowMemoryWire404('searchSemantic', error);
171
+ }
172
+ },
173
+ update: async (id: string, patch: MemoryUpdatePatch): Promise<MemoryRecord | null> => {
174
+ try {
175
+ const response = await requestJsonRaw<{ record: MemoryRecord }>(
176
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(id)}/update`), createJsonRequestInit(token, patch, 'POST'),
177
+ );
178
+ return response.record;
179
+ } catch (error) {
180
+ return foldNullableMemoryWire404('update', error);
181
+ }
182
+ },
183
+ link: async (fromId: string, toId: string, relation: string): Promise<MemoryLink | null> => {
184
+ try {
185
+ const response = await requestJsonRaw<{ link: MemoryLink }>(
186
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(fromId)}/links`), createJsonRequestInit(token, { toId, relation }, 'POST'),
187
+ );
188
+ return response.link;
189
+ } catch (error) {
190
+ return foldNullableMemoryWire404('link', error);
191
+ }
192
+ },
193
+ linksFor: async (id: string): Promise<readonly MemoryLink[]> => {
194
+ try {
195
+ const response = await requestJsonRaw<{ links: readonly MemoryLink[] }>(
196
+ fetchImpl, url(`/api/memory/records/${encodeURIComponent(id)}/links`), createJsonRequestInit(token),
197
+ );
198
+ return response.links;
199
+ } catch (error) {
200
+ return rethrowMemoryWire404('linksFor', error);
201
+ }
202
+ },
203
+ reviewQueue: async (limit?: number, scope?: MemoryScope): Promise<readonly MemoryRecord[]> => {
204
+ const params = new URLSearchParams();
205
+ if (limit !== undefined) params.set('limit', String(limit));
206
+ if (scope !== undefined) params.set('scope', scope);
207
+ const query = params.toString();
208
+ try {
209
+ const response = await requestJsonRaw<{ records: readonly MemoryRecord[] }>(
210
+ fetchImpl, url(`/api/memory/review-queue${query ? `?${query}` : ''}`), createJsonRequestInit(token),
211
+ );
212
+ return response.records;
213
+ } catch (error) {
214
+ return rethrowMemoryWire404('reviewQueue', error);
215
+ }
216
+ },
217
+ exportBundle: async (filter?: MemorySearchFilter): Promise<MemoryBundle> => {
218
+ try {
219
+ const response = await requestJsonRaw<{ bundle: MemoryBundle }>(
220
+ fetchImpl, url('/api/memory/records/export'), createJsonRequestInit(token, filter ?? {}, 'POST'),
221
+ );
222
+ return response.bundle;
223
+ } catch (error) {
224
+ return rethrowMemoryWire404('exportBundle', error);
225
+ }
226
+ },
227
+ importBundle: async (bundle: MemoryBundle): Promise<MemoryImportResult> => {
228
+ try {
229
+ const response = await requestJsonRaw<{ result: MemoryImportResult }>(
230
+ fetchImpl, url('/api/memory/records/import'), createJsonRequestInit(token, { bundle }, 'POST'),
231
+ );
232
+ return response.result;
233
+ } catch (error) {
234
+ return rethrowMemoryWire404('importBundle', error);
235
+ }
236
+ },
237
+ vectorStats: async (): Promise<MemoryVectorStats> => {
238
+ try {
239
+ const response = await requestJsonRaw<{ vector: MemoryVectorStats }>(
240
+ fetchImpl, url('/api/memory/vector'), createJsonRequestInit(token),
241
+ );
242
+ return response.vector;
243
+ } catch (error) {
244
+ return rethrowMemoryWire404('vectorStats', error);
245
+ }
246
+ },
247
+ doctor: async (): Promise<MemoryDoctorReport> => {
248
+ try {
249
+ return await requestJsonRaw<MemoryDoctorReport>(
250
+ fetchImpl, url('/api/memory/doctor'), createJsonRequestInit(token),
251
+ );
252
+ } catch (error) {
253
+ return rethrowMemoryWire404('doctor', error);
254
+ }
255
+ },
256
+ };
257
+ }
258
+
259
+ /** Mutable one-slot ref tracking which adopted daemon's baseUrl the memory spine is currently wired to (mirrors bootstrap.ts's `spineActiveForBaseUrl`). */
260
+ export interface MemorySpineActiveRef { value: string | null }
261
+
262
+ /**
263
+ * Sync the memory spine to the current daemon-adoption status — the SAME
264
+ * signal bootstrap.ts's `syncSessionSpineToHostStatus` uses. 'external' (an
265
+ * adopted, separately-running daemon) activates the wire transport; every
266
+ * other mode ('embedded' — this process IS the daemon host, so its own store
267
+ * is already canonical — plus 'disabled'/'blocked'/'incompatible'/'unavailable')
268
+ * deactivates back to local access.
269
+ */
270
+ export function syncMemorySpineToHostStatus(
271
+ memorySpine: { activate: (transport: MemoryTransport) => void; deactivate: (reason: string) => void },
272
+ daemonMode: string,
273
+ daemonBaseUrl: string,
274
+ sharedDaemonToken: string,
275
+ activeRef: MemorySpineActiveRef,
276
+ log: { info: (message: string) => void },
277
+ ): void {
278
+ if (daemonMode !== 'external') {
279
+ if (activeRef.value !== null) {
280
+ memorySpine.deactivate(`daemon mode changed to '${daemonMode}'`);
281
+ activeRef.value = null;
282
+ }
283
+ return;
284
+ }
285
+ if (activeRef.value === daemonBaseUrl) return; // already wired to this exact adopted daemon
286
+ memorySpine.activate(createTuiMemorySpineTransport({ baseUrl: daemonBaseUrl, authToken: sharedDaemonToken }));
287
+ activeRef.value = daemonBaseUrl;
288
+ log.info(`[bootstrap] memory spine: adopted external daemon at ${daemonBaseUrl} — routing memory ops over the wire`);
289
+ }