@pellux/goodvibes-tui 1.9.1 → 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 (91) hide show
  1. package/CHANGELOG.md +36 -5
  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/player.ts +91 -5
  8. package/src/audio/spoken-turn-wiring.ts +10 -2
  9. package/src/cli/management-utils.ts +4 -0
  10. package/src/config/credential-availability.ts +1 -1
  11. package/src/config/index.ts +1 -1
  12. package/src/core/turn-cancellation.ts +1 -1
  13. package/src/daemon/cli.ts +4 -4
  14. package/src/daemon/service-commands.ts +8 -8
  15. package/src/export/cost-utils.ts +9 -1
  16. package/src/input/command-registry.ts +16 -5
  17. package/src/input/commands/config.ts +1 -1
  18. package/src/input/commands/control-room-runtime.ts +12 -5
  19. package/src/input/commands/incident-runtime.ts +2 -2
  20. package/src/input/commands/memory.ts +12 -12
  21. package/src/input/commands/planning-runtime.ts +1 -1
  22. package/src/input/commands/recall-bundle.ts +8 -8
  23. package/src/input/commands/recall-capture.ts +3 -3
  24. package/src/input/commands/recall-query.ts +41 -15
  25. package/src/input/commands/recall-review.ts +12 -12
  26. package/src/input/commands/session-content.ts +1 -1
  27. package/src/input/feed-context-factory.ts +1 -1
  28. package/src/input/handler-content-actions.ts +2 -2
  29. package/src/input/handler-feed-routes.ts +2 -2
  30. package/src/input/handler-feed.ts +1 -1
  31. package/src/input/handler-onboarding-daemon-adopt.ts +3 -3
  32. package/src/input/handler-picker-routes.ts +2 -2
  33. package/src/input/handler-shortcuts.ts +1 -1
  34. package/src/input/model-picker.ts +2 -2
  35. package/src/input/onboarding/onboarding-wizard-apply.ts +3 -3
  36. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +3 -3
  37. package/src/input/onboarding/onboarding-wizard-steps.ts +1 -1
  38. package/src/input/session-picker-modal.ts +1 -1
  39. package/src/input/settings-modal-data.ts +56 -2
  40. package/src/main.ts +8 -6
  41. package/src/panels/base-panel.ts +1 -1
  42. package/src/panels/builtin/operations.ts +4 -4
  43. package/src/panels/builtin/shared.ts +4 -4
  44. package/src/panels/cost-tracker-panel.ts +2 -2
  45. package/src/panels/fleet-panel-format.ts +1 -1
  46. package/src/panels/fleet-panel.ts +15 -15
  47. package/src/panels/fleet-read-model.ts +11 -11
  48. package/src/panels/fleet-steer.ts +2 -2
  49. package/src/panels/fleet-stop.ts +2 -2
  50. package/src/panels/fleet-tabs.ts +4 -4
  51. package/src/panels/fleet-transcript.ts +5 -5
  52. package/src/panels/modals/memory-modal.ts +77 -16
  53. package/src/panels/types.ts +1 -1
  54. package/src/renderer/compaction-quality.ts +1 -1
  55. package/src/renderer/fleet-tab-strip.ts +2 -2
  56. package/src/renderer/footer-tips.ts +1 -1
  57. package/src/renderer/model-workspace.ts +1 -1
  58. package/src/renderer/session-picker-modal.ts +4 -4
  59. package/src/renderer/shell-surface.ts +1 -1
  60. package/src/renderer/status-glyphs.ts +3 -3
  61. package/src/renderer/terminal-bg-probe.ts +1 -1
  62. package/src/renderer/theme.ts +2 -2
  63. package/src/renderer/turn-injection.ts +3 -3
  64. package/src/renderer/ui-factory.ts +7 -7
  65. package/src/renderer/ui-primitives.ts +3 -3
  66. package/src/runtime/bootstrap-command-context.ts +4 -0
  67. package/src/runtime/bootstrap-command-parts.ts +4 -1
  68. package/src/runtime/bootstrap-core.ts +3 -3
  69. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  70. package/src/runtime/bootstrap-shell.ts +18 -7
  71. package/src/runtime/bootstrap.ts +22 -22
  72. package/src/runtime/code-index-services.ts +2 -2
  73. package/src/runtime/index.ts +11 -3
  74. package/src/runtime/legacy-daemon-migration.ts +9 -9
  75. package/src/runtime/memory-fold.ts +1 -1
  76. package/src/runtime/memory-spine-transport.ts +289 -0
  77. package/src/runtime/onboarding/snapshot.ts +3 -3
  78. package/src/runtime/onboarding/types.ts +2 -2
  79. package/src/runtime/operator-token-cleanup.ts +2 -2
  80. package/src/runtime/orchestrator-core-services.ts +29 -4
  81. package/src/runtime/process-lifecycle.ts +19 -5
  82. package/src/runtime/services.ts +27 -28
  83. package/src/runtime/session-inbound-inputs.ts +1 -1
  84. package/src/runtime/session-spine-transport.ts +2 -2
  85. package/src/runtime/ui-services.ts +1 -1
  86. package/src/runtime/workstream-services.ts +1 -1
  87. package/src/runtime/wrfc-persistence.ts +2 -2
  88. package/src/shell/ui-openers.ts +2 -2
  89. package/src/version.ts +1 -1
  90. package/src/audio/spoken-turn-controller.ts +0 -243
  91. package/src/audio/text-chunker.ts +0 -110
@@ -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
+ }
@@ -403,12 +403,12 @@ export async function collectOnboardingSnapshot(
403
403
  snapshot: authSnapshotResult.value,
404
404
  },
405
405
  bindSettings: {
406
- // danger.daemon (removed Wave 6 — see docs/decisions/2026-07-05-daemon-by-default.md)
407
- // used to gate the pre-Wave-2 opt-in daemon posture; this site read it RAW
406
+ // danger.daemon (since removed — see docs/decisions/2026-07-05-daemon-by-default.md)
407
+ // used to gate the earlier opt-in daemon posture; this site read it RAW
408
408
  // (not through resolveDaemonEnabled) so the wizard's network-mode
409
409
  // classification tracked "did the user explicitly request the legacy
410
410
  // dangerous posture" rather than "does the daemon run" (which has defaulted
411
- // true unconditionally since Wave 2, and would misclassify every default,
411
+ // true unconditionally since daemon-by-default landed, and would misclassify every default,
412
412
  // local-only install as server-mode if read here). Every realistic config
413
413
  // already evaluated this to `false` (unset, or an explicit `danger.daemon:
414
414
  // false` both did); the alias's removal migration preserves the one case
@@ -126,7 +126,7 @@ export interface OnboardingSurfacesSnapshot {
126
126
  }
127
127
 
128
128
  /**
129
- * W4-D1 wizard wiring: read-only detection of a legacy `goodvibes-daemon.service`
129
+ * Wizard wiring: read-only detection of a legacy `goodvibes-daemon.service`
130
130
  * systemd unit (see `../legacy-daemon-migration.ts`'s `LegacyUnitInfo`), carried
131
131
  * on the snapshot so the Network step can show the guided migration action only
132
132
  * when there is actually something to migrate. Never implies anything was
@@ -137,7 +137,7 @@ export interface OnboardingLegacyDaemonSnapshot {
137
137
  readonly active: boolean;
138
138
  readonly path: string;
139
139
  /**
140
- * F2 follow-up: the unit name this tool actually manages on this host,
140
+ * Follow-up: the unit name this tool actually manages on this host,
141
141
  * resolved from the `service.serviceName` config key at snapshot-collection
142
142
  * time (`resolveConfiguredServiceName`, `../legacy-daemon-migration.ts`) —
143
143
  * so the wizard's detection banner names the real unit instead of the
@@ -4,7 +4,7 @@
4
4
  * Shared helper that enumerates the legacy workspace-scoped `operator-tokens.json`
5
5
  * locations the TUI has written at various pre-0.21.28 versions. Used by both the
6
6
  * in-process bootstrap path (`src/runtime/bootstrap.ts`) and the standalone daemon
7
- * CLI (`src/daemon/cli.ts`) so F3 (stale-token pruning) has a single source of
7
+ * CLI (`src/daemon/cli.ts`) so stale-token pruning has a single source of
8
8
  * truth for where to look.
9
9
  *
10
10
  * Adding a new legacy location: append to `workspaceOperatorTokenCandidates` and
@@ -32,7 +32,7 @@ export function workspaceOperatorTokenCandidates(workingDirectory: string): read
32
32
  }
33
33
 
34
34
  /**
35
- * F1 (adopt-an-already-running-external-daemon): resolve the operator/companion
35
+ * External-daemon adoption: resolve the operator/companion
36
36
  * token this TUI process uses to authenticate with its daemon, honoring
37
37
  * `GOODVIBES_DAEMON_TOKEN` as a non-interactive override.
38
38
  *
@@ -12,7 +12,7 @@ export type OrchestratorCoreServicesSource = Pick<
12
12
  | 'sessionMemoryStore'
13
13
  | 'sessionLineageTracker'
14
14
  | 'idempotencyStore'
15
- | 'memoryRegistry'
15
+ | 'memorySpine'
16
16
  | 'codeIndexStore'
17
17
  | 'codeIndexReindexScheduler'
18
18
  >;
@@ -24,7 +24,7 @@ export type OrchestratorCoreServicesSource = Pick<
24
24
  * cacheHitTracker; main: favoritesStore) — setCoreServices() merges, so the
25
25
  * later main.ts call only overlays, never erases.
26
26
  *
27
- * Wave-5 (wo805) BLOCKER regression guard: `memoryRegistry` here is what turns
27
+ * BLOCKER regression guard: `memoryRegistry` here is what turns
28
28
  * on per-turn passive knowledge injection for the MAIN interactive session —
29
29
  * the SDK turn loop hard-gates on `coreServices.memoryRegistry` (undefined is
30
30
  * a silent no-op: Orchestrator.getTurnInjections() stays empty forever and
@@ -32,6 +32,18 @@ export type OrchestratorCoreServicesSource = Pick<
32
32
  * omitted it independently; routing them through this one function (with
33
33
  * src/test/runtime/orchestrator-core-services.test.ts pinning the field) is
34
34
  * what keeps that from regressing.
35
+ *
36
+ * SDK 1.2.0 FULL DETACH: the SDK's turn loop reads `memoryRegistry.getAll()`
37
+ * SYNCHRONOUSLY (`TurnKnowledgeRegistrySource`), but the memory spine's wire
38
+ * reads are asynchronous — a sync function cannot await the wire. Per
39
+ * docs/decisions/2026-07-06-memory-wire-full-detach.md (SDK repo) this is
40
+ * satisfied by the spine's freshness-stamped recall snapshot instead of the
41
+ * raw local `memoryRegistry`: `getAll()` reads `memorySpine.recallSnapshot()`,
42
+ * which returns whatever the last `refreshRecallSnapshot()` (an async
43
+ * pre-turn hook — see the `handleUserInput` call sites) captured, honestly
44
+ * empty/stale until refreshed. This is what lets per-turn knowledge injection
45
+ * detach from the local store file when a daemon is adopted, instead of
46
+ * always reading the (possibly divergent) local registry regardless of mode.
35
47
  */
36
48
  export function buildSharedOrchestratorCoreServices(input: {
37
49
  readonly services: OrchestratorCoreServicesSource;
@@ -47,8 +59,8 @@ export function buildSharedOrchestratorCoreServices(input: {
47
59
  sessionMemoryStore: services.sessionMemoryStore,
48
60
  sessionLineageTracker: services.sessionLineageTracker,
49
61
  idempotencyStore: services.idempotencyStore,
50
- memoryRegistry: services.memoryRegistry,
51
- // Wave-5 Stage B — main-session code auto-injection + tool-site reindex. Injection is
62
+ memoryRegistry: { getAll: () => services.memorySpine.recallSnapshot().records },
63
+ // Main-session code auto-injection + tool-site reindex. Injection is
52
64
  // additionally gated by the default-off `agent-passive-code-injection` flag inside the
53
65
  // SDK; here we supply the source, the live storage.codeIndexEnabled predicate, and the
54
66
  // reindex scheduler.
@@ -57,3 +69,16 @@ export function buildSharedOrchestratorCoreServices(input: {
57
69
  codeIndexReindexScheduler: services.codeIndexReindexScheduler,
58
70
  };
59
71
  }
72
+
73
+ /**
74
+ * The async pre-turn hook: refreshes the memory spine's recall snapshot over
75
+ * the CURRENT route (wire when adopted, local otherwise) so the synchronous
76
+ * per-turn knowledge injection (`memoryRegistry.getAll()` above) reads
77
+ * up-to-date records instead of whatever the previous refresh captured.
78
+ * Failures are swallowed — an honest stale/empty snapshot (with its own
79
+ * degradation note, surfaced via `recallSnapshot().note`) is preferable to
80
+ * blocking the user's turn on a memory-read failure.
81
+ */
82
+ export async function refreshMemoryRecallSnapshot(services: Pick<RuntimeServices, 'memorySpine'>): Promise<void> {
83
+ await services.memorySpine.refreshRecallSnapshot().catch(() => {});
84
+ }
@@ -57,7 +57,7 @@ export interface ProcessLifecycleDeps {
57
57
  readonly unsubs: ReadonlyArray<() => void>;
58
58
  readonly getRecoveryInterval: () => ReturnType<typeof setInterval> | null;
59
59
  readonly setRecoveryInterval: (value: ReturnType<typeof setInterval> | null) => void;
60
- readonly getStopSpokenOutputForExit: () => (() => void) | null;
60
+ readonly getStopSpokenOutputForExit: () => (() => void | Promise<void>) | null;
61
61
  }
62
62
 
63
63
  export interface ProcessLifecycleHandlers {
@@ -153,7 +153,16 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
153
153
  // must not re-run teardown or double-fire ctx.shutdown.
154
154
  if (exiting) return;
155
155
  exiting = true;
156
- getStopSpokenOutputForExit()?.();
156
+ // Exit lets the spoken audio the user is already hearing finish inside a
157
+ // short bounded window (capped inside stopForExit, under the 3s shutdown
158
+ // budget below) while the rest of teardown proceeds; queued-but-unplayed
159
+ // speech is dropped. Deliberate interrupts (Ctrl+C, /tts stop) still cut
160
+ // instantly through controller.stop() before this path is ever reached.
161
+ let spokenOutputDrain: Promise<void> = Promise.resolve();
162
+ try {
163
+ spokenOutputDrain = Promise.resolve(getStopSpokenOutputForExit()?.()).then(() => undefined);
164
+ } catch { /* non-fatal to exit */ }
165
+ spokenOutputDrain = spokenOutputDrain.catch(() => undefined);
157
166
  unsubs.forEach(fn => fn());
158
167
  const interval = getRecoveryInterval();
159
168
  if (interval !== null) { clearInterval(interval); setRecoveryInterval(null); }
@@ -169,9 +178,14 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
169
178
  try {
170
179
  // Race the graceful shutdown against a hard timeout — externalServices.stop() can hang
171
180
  // and we must still exit; deferredStartup.drain only budgets 100ms internally.
172
- await Promise.race([
173
- ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, ctx.conversation.getTitleSource(), buildSessionContinuityHints()) }).then(() => { shutdownOk = true; }),
174
- new Promise<void>((resolve) => setTimeout(resolve, 3000)),
181
+ // The spoken-audio drain runs concurrently and is internally capped below
182
+ // this budget, so it never extends the exit beyond the hard timeout.
183
+ await Promise.all([
184
+ spokenOutputDrain,
185
+ Promise.race([
186
+ ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, ctx.conversation.getTitleSource(), buildSessionContinuityHints()) }).then(() => { shutdownOk = true; }),
187
+ new Promise<void>((resolve) => setTimeout(resolve, 3000)),
188
+ ]),
175
189
  ]);
176
190
  } catch (err) {
177
191
  logger.debug('ctx.shutdown error during exitApp (non-fatal)', { error: summarizeError(err) });
@@ -24,6 +24,7 @@ import { MultimodalService } from '@pellux/goodvibes-sdk/platform/multimodal';
24
24
  import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
25
25
  import { AgentManager, OverflowHandler, ProcessManager, createWorkflowServices, type WorkflowServices } from '@pellux/goodvibes-sdk/platform/tools';
26
26
  import { FileStateCache, FileUndoManager, MemoryEmbeddingProviderRegistry, MemoryRegistry, MemoryStore, ModeManager, ProjectIndex, resolveCanonicalMemoryDbPath, type CodeIndexStore, type CodeIndexReindexScheduler } from '@pellux/goodvibes-sdk/platform/state';
27
+ import { MemorySpineClient, createLocalMemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
27
28
  import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
28
29
  import type { RuntimeEventBus } from '@/runtime/index.ts';
29
30
  import { createDomainDispatch } from './store/index.ts';
@@ -166,6 +167,8 @@ export interface RuntimeServices {
166
167
  readonly workPlanStore: WorkPlanStore;
167
168
  readonly memoryStore: MemoryStore;
168
169
  readonly memoryRegistry: MemoryRegistry;
170
+ /** Host-vs-client memory access: local until bootstrap.ts activates it for an adopted 'external' daemon (mirrors sessionSpine). */
171
+ readonly memorySpine: MemorySpineClient;
169
172
  readonly serviceRegistry: ServiceRegistry;
170
173
  readonly secretsManager: SecretsManager;
171
174
  readonly subscriptionManager: SubscriptionManager;
@@ -222,24 +225,19 @@ export interface RuntimeServices {
222
225
  readonly agentOrchestrator: AgentOrchestrator;
223
226
  readonly wrfcController: WrfcController;
224
227
  readonly processManager: ProcessManager;
225
- /** Wave 4 (wo703): the phase/work-item orchestration engine — see runtime/workstream-services.ts. */
228
+ /** The phase/work-item orchestration engine — see runtime/workstream-services.ts. */
226
229
  readonly orchestrationEngine: OrchestrationEngine;
227
230
  readonly workstreamCommands: WorkstreamCommandService;
228
- /** Wave 5 (wo804): the repo source-tree code index — see runtime/code-index-services.ts. */
231
+ /** The repo source-tree code index — see runtime/code-index-services.ts. */
229
232
  readonly codeIndexStore: CodeIndexStore;
230
- readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // Wave-5 Stage B tool-site reindex
233
+ readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
231
234
  /** W2.1/W2.2: unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel. */
232
235
  readonly processRegistry: ProcessRegistry;
233
236
  readonly modeManager: ModeManager;
234
237
  readonly fileUndoManager: FileUndoManager;
235
238
  readonly workspaceCheckpointManager: WorkspaceCheckpointManager;
236
239
  readonly integrationHelpers: IntegrationHelperService;
237
- /**
238
- * Re-root path-bound stores (MemoryStore, ProjectIndex) to a new working directory.
239
- * Called by WorkspaceSwapManager after the new directory has been verified.
240
- * Stores that require a process restart emit a warn-level log; they continue serving
241
- * the old path until the daemon restarts with the new --working-dir.
242
- */
240
+ /** Re-root path-bound stores (MemoryStore, ProjectIndex) to a new working directory, called by WorkspaceSwapManager after verification; stores needing a process restart just warn-log and keep serving the old path until the daemon restarts with the new --working-dir. */
243
241
  rerootStores(newWorkingDir: string): Promise<void>;
244
242
  }
245
243
 
@@ -338,7 +336,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
338
336
  executor: agentOrchestrator,
339
337
  configManager,
340
338
  });
341
- agentOrchestrator.setConversationSink({ // Wave-3 Part C6 bridge (mirrors the SDK's own createRuntimeServices)
339
+ agentOrchestrator.setConversationSink({ // Conversation-snapshot bridge (mirrors the SDK's own createRuntimeServices)
342
340
  register: (agentId, source) => agentManager.registerConversationSource(agentId, source),
343
341
  release: (agentId) => agentManager.releaseConversationSource(agentId),
344
342
  });
@@ -387,12 +385,16 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
387
385
  });
388
386
  const artifactStore = new ArtifactStore({ configManager });
389
387
  const memoryEmbeddingRegistry = new MemoryEmbeddingProviderRegistry({ configManager });
390
- // W6-C2 (E6): open the ONE home-scoped canonical store; legacy per-project TUI memory folds in at boot (foldTuiLegacyMemory).
388
+ // Open the ONE home-scoped canonical store; legacy per-project TUI memory folds in at boot (foldTuiLegacyMemory).
391
389
  const memoryDbPath = resolveCanonicalMemoryDbPath(homeDirectory);
392
390
  const memoryStore = new MemoryStore(memoryDbPath, {
393
391
  embeddingRegistry: memoryEmbeddingRegistry,
394
392
  });
395
393
  const memoryRegistry = new MemoryRegistry(memoryStore);
394
+ // Local-until-adopted access facade for spine-shaped consumers (the Memory
395
+ // modal): bootstrap.ts activates the wire transport when a compatible
396
+ // external daemon is adopted, same signal as the session spine.
397
+ const memorySpine = new MemorySpineClient({ local: createLocalMemoryAccess(memoryRegistry) });
396
398
  const deliveryManager = new AutomationDeliveryManager({
397
399
  configManager,
398
400
  serviceRegistry,
@@ -526,14 +528,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
526
528
  automationBridge: automationManager,
527
529
  });
528
530
 
529
- // Daemon handler surfaces: attach HOST handlers to the SDK-auto-registered
530
- // builtin gateway descriptors (channels.* / email.* / calendar.*). The SDK
531
- // owns every id/descriptor/schema; this only fills the handler slot via
532
- // catalog.register(descriptor, handler, { replace: true }). Routing returns
533
- // the resolver the inbox surface consumes; triage decorates channels.inbox.list.
534
- // The remote surface reuses the SAME DistributedRuntimeManager the SDK facade
535
- // injects, so its peer/work methods share one persistent store; remote.peers.*
536
- // stay SDK-published routes (not catalog methods).
531
+ // Daemon handler surfaces: attach HOST handlers to the SDK-auto-registered builtin
532
+ // gateway descriptors (channels.* / email.* / calendar.*) via catalog.register(descriptor,
533
+ // handler, { replace: true }) — the SDK owns every id/descriptor/schema. The remote
534
+ // surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
537
535
  const handlerLogger: HandlerLogger = {
538
536
  info: (message, meta) => console.info(message, meta ?? ''),
539
537
  warn: (message, meta) => console.warn(message, meta ?? ''),
@@ -595,18 +593,18 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
595
593
  artifactStore,
596
594
  });
597
595
  const processManager = new ProcessManager();
598
- // Wave 4 (wo703): the phase/work-item orchestration engine, constructed
596
+ // The phase/work-item orchestration engine, constructed
599
597
  // before the process registry so its fleet nodes (workstream/phase/
600
598
  // work-item) can be folded in below via the registry's optional
601
599
  // orchestrationEngine dep.
602
600
  const { orchestrationEngine, workstreamCommands } = createWorkstreamServices({
603
601
  agentManager, configManager, adaptivePlanner, runtimeBus: options.runtimeBus, projectRoot: workingDirectory,
604
602
  });
605
- // Wave 5 (wo804): repo source-tree code index, sharing memoryEmbeddingRegistry
603
+ // Repo source-tree code index, sharing memoryEmbeddingRegistry
606
604
  // with MemoryStore above. Auto-build is config-gated (default off) — see
607
605
  // code-index-services.ts's header doc.
608
606
  const { codeIndexStore, codeIndexReindexScheduler } = createCodeIndexServices({ workingDirectory, configManager, memoryEmbeddingRegistry });
609
- const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Wave-5 Stage B seam (agent here; main via orchestrator-core-services.ts)
607
+ const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
610
608
  // W2.1/W2.2: one shared process registry aggregating the managers above —
611
609
  // the Fleet panel (panels/fleet-read-model.ts) is its first consumer.
612
610
  // Constructed once here (not per-consumer) so the coalesced tick and the
@@ -614,15 +612,15 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
614
612
  const processRegistry = createProcessRegistry({
615
613
  agentManager,
616
614
  wrfcController,
617
- orchestrationEngine, // Wave 4 (wo703): folds workstream/phase/work-item nodes into the fleet
618
- codeIndexService: codeIndexStore, // Wave 5 (wo804): folds a single 'code-index' node into the fleet
615
+ orchestrationEngine, // Folds workstream/phase/work-item nodes into the fleet
616
+ codeIndexService: codeIndexStore, // Folds a single 'code-index' node into the fleet
619
617
  processManager,
620
618
  watcherRegistry,
621
619
  workflow,
622
620
  approvalBroker,
623
621
  sessionBroker,
624
- messageBus: agentMessageBus, // Wave-3: backs steer()/`steerable` (wo612 builds the composer UI on top)
625
- automationManager, // Wave 6 (wo-F item d4): folds /schedule AutomationJobs into the fleet as 'schedule' nodes
622
+ messageBus: agentMessageBus, // Backs steer()/`steerable` (the Fleet steer composer builds on top)
623
+ automationManager, // Folds /schedule AutomationJobs into the fleet as 'schedule' nodes
626
624
  runtimeBus: options.runtimeBus,
627
625
  // Honest pricing: never fabricate a cost for an unrecognized model.
628
626
  priceUsage: (model, usage) => {
@@ -681,7 +679,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
681
679
  remoteRunnerRegistry,
682
680
  knowledgeService,
683
681
  memoryRegistry,
684
- ...codeInjectionOrchestratorDeps, // Wave-5 Stage B: agent-run code injection + tool-site reindex
682
+ ...codeInjectionOrchestratorDeps, // Agent-run code injection + tool-site reindex
685
683
  archetypeLoader,
686
684
  configManager,
687
685
  providerRegistry,
@@ -725,6 +723,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
725
723
  workPlanStore,
726
724
  memoryStore,
727
725
  memoryRegistry,
726
+ memorySpine,
728
727
  serviceRegistry,
729
728
  secretsManager,
730
729
  subscriptionManager,
@@ -790,7 +789,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
790
789
  workspaceCheckpointManager,
791
790
  integrationHelpers,
792
791
  async rerootStores(newWorkingDir: string): Promise<void> {
793
- // W6-C2 (E6): the memory store is the home-scoped canonical cross-surface store and
792
+ // The memory store is the home-scoped canonical cross-surface store and
794
793
  // deliberately does NOT reroot per-project (that would re-silo memory, the E6
795
794
  // regression). Only working-tree-bound stores (code index, project index) reroot.
796
795
  await codeIndexStore.reroot(newWorkingDir, codeIndexDbPath(newWorkingDir));
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * session-inbound-inputs.ts
3
3
  *
4
- * D3 (One-Platform Wave 2, live-surface steer delivery the inbound half of the
4
+ * Live-surface steer delivery (the inbound half of the
5
5
  * session spine): the TUI's INBOUND path for steer/follow-up inputs that another
6
6
  * live surface (e.g. the webui) queued against THIS TUI's session.
7
7
  *
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * session-spine-transport.ts
3
3
  *
4
- * W3-T1 (One-Platform Wave 3, TUI session-spine cutover): the thin typed-client
4
+ * The TUI session-spine cutover: the thin typed-client
5
5
  * adapter that lets the TUI's bootstrap drive the SDK's
6
6
  * `@pellux/goodvibes-sdk/platform/runtime/session-spine` `SessionSpineClient`
7
- * (extracted in W3-S4) via its injected `SpineTransport` seam.
7
+ * via its injected `SpineTransport` seam.
8
8
  *
9
9
  * The TUI is SDK-clean here: `SpineSessionsClient` is exactly the narrow wire
10
10
  * surface the old TUI-local `session-spine-client.ts` declared (structurally
@@ -114,7 +114,7 @@ export interface UiRuntimeSharedServices {
114
114
  readonly remoteSupervisor: RuntimeServices['remoteSupervisor'] & RemoteSupervisor;
115
115
  /** W2.2: the shared live process registry backing the Fleet panel. */
116
116
  readonly processRegistry: RuntimeServices['processRegistry'];
117
- /** Wave-3 (W3.2): the shared runtime event bus — the Fleet panel subscribes to its 'communication' domain for the honest steer-consumed signal. */
117
+ /** The shared runtime event bus — the Fleet panel subscribes to its 'communication' domain for the honest steer-consumed signal. */
118
118
  readonly runtimeBus: RuntimeServices['runtimeBus'];
119
119
  };
120
120
  }