@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/knowledge-graphql.graphql +5 -0
- package/docs/foundation-artifacts/knowledge-store.sql +33 -0
- package/docs/foundation-artifacts/operator-contract.json +4026 -1376
- package/package.json +2 -2
- package/src/audio/spoken-turn-wiring.ts +7 -2
- package/src/cli/management-utils.ts +4 -0
- package/src/export/cost-utils.ts +9 -1
- package/src/input/command-registry.ts +11 -0
- package/src/input/commands/control-room-runtime.ts +12 -5
- package/src/input/commands/incident-runtime.ts +2 -2
- package/src/input/commands/memory.ts +11 -11
- package/src/input/commands/recall-bundle.ts +8 -8
- package/src/input/commands/recall-capture.ts +3 -3
- package/src/input/commands/recall-query.ts +41 -15
- package/src/input/commands/recall-review.ts +10 -10
- package/src/input/settings-modal-data.ts +54 -0
- package/src/main.ts +3 -2
- package/src/panels/builtin/shared.ts +3 -3
- package/src/panels/cost-tracker-panel.ts +2 -2
- package/src/panels/modals/memory-modal.ts +77 -16
- package/src/runtime/bootstrap-command-context.ts +4 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -1
- package/src/runtime/bootstrap-shell.ts +15 -4
- package/src/runtime/bootstrap.ts +18 -18
- package/src/runtime/memory-spine-transport.ts +289 -0
- package/src/runtime/orchestrator-core-services.ts +27 -2
- package/src/runtime/services.ts +13 -14
- package/src/version.ts +1 -1
- package/src/audio/spoken-turn-controller.ts +0 -271
- 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(
|
|
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
|
-
|
|
42
|
-
|
|
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 {
|
|
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
|
-
|
|
79
|
-
|
|
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 {
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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,
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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
|
+
}
|