@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "1.0.0",
102
+ "@pellux/goodvibes-sdk": "1.3.3",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -15,6 +15,14 @@ export interface StreamingAudioPlayer {
15
15
  readonly available: boolean;
16
16
  play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
17
17
  stop(): void;
18
+ /**
19
+ * Resolves once the currently playing sink has finished naturally (its
20
+ * process closed) or after `timeoutMs`, whichever comes first; resolves
21
+ * immediately when nothing is playing. The exit path uses this to let the
22
+ * audio the user is already hearing finish inside a short bounded window
23
+ * instead of killing it mid-drain. stop() remains the instant cut.
24
+ */
25
+ waitForDrain(timeoutMs: number): Promise<void>;
18
26
  }
19
27
 
20
28
  export interface StreamingAudioPlaybackOptions {
@@ -25,6 +33,8 @@ export interface StreamingAudioPlaybackOptions {
25
33
  interface SpawnProcess {
26
34
  readonly stdin: Writable;
27
35
  once(event: 'close', listener: () => void): this;
36
+ once(event: 'spawn', listener: () => void): this;
37
+ once(event: 'error', listener: (error: unknown) => void): this;
28
38
  kill(signal?: NodeJS.Signals | number): boolean;
29
39
  }
30
40
  type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
@@ -32,6 +42,13 @@ type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnPr
32
42
  export interface LocalStreamingAudioPlayerOptions {
33
43
  readonly env?: NodeJS.ProcessEnv;
34
44
  readonly spawnProcess?: SpawnProcessFactory;
45
+ /**
46
+ * Injected player command — the test seam companion to `spawnProcess`.
47
+ * When set, the constructor uses it verbatim instead of scanning PATH, so
48
+ * deterministic fake-sink tests never depend on mpv/ffplay being installed
49
+ * on the machine running them. Pass null to model "no player found".
50
+ */
51
+ readonly command?: StreamingAudioPlayerCommand | null;
35
52
  }
36
53
 
37
54
  export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
@@ -40,7 +57,9 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
40
57
  private readonly spawnProcess: SpawnProcessFactory;
41
58
 
42
59
  constructor(options: LocalStreamingAudioPlayerOptions = {}) {
43
- this.command = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
60
+ this.command = options.command !== undefined
61
+ ? options.command
62
+ : resolveStreamingAudioPlayerCommand(options.env ?? process.env);
44
63
  this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
45
64
  }
46
65
 
@@ -67,12 +86,27 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
67
86
  options.signal?.addEventListener('abort', abort, { once: true });
68
87
 
69
88
  try {
89
+ // Head survival: hold the first audio byte until the sink has actually
90
+ // started (its 'spawn' event) instead of writing into a process that has
91
+ // not exec'd yet. Writing before the player is up is the spawn race that
92
+ // clipped the beginning of playback. A spawn failure rejects here so the
93
+ // caller can report it honestly rather than swallowing a dead player.
94
+ await awaitReady(proc, options.signal);
95
+ if (options.signal?.aborted) return;
96
+
70
97
  for await (const chunk of chunks) {
71
98
  if (options.signal?.aborted) break;
72
99
  if (chunk.data.byteLength === 0) continue;
73
100
  await writeStdin(proc, chunk.data);
74
101
  }
75
- proc.stdin.end();
102
+
103
+ // An intentional interrupt (turn cancel / quit chord / /tts stop) has
104
+ // already torn the process down via `abort` and must cut immediately —
105
+ // do not wait on a graceful drain. A natural end-of-speech, by contrast,
106
+ // closes stdin and waits for the sink to play out every buffered sample
107
+ // so the tail of the response is never truncated.
108
+ if (options.signal?.aborted) return;
109
+ try { proc.stdin.end(); } catch { /* ignore */ }
76
110
  await waitForExit(proc);
77
111
  } finally {
78
112
  options.signal?.removeEventListener('abort', abort);
@@ -87,14 +121,33 @@ export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
87
121
  try { proc.stdin.destroy(); } catch { /* ignore */ }
88
122
  try { proc.kill('SIGTERM'); } catch { /* ignore */ }
89
123
  }
124
+
125
+ waitForDrain(timeoutMs: number): Promise<void> {
126
+ const proc = this.activeProcess;
127
+ if (!proc) return Promise.resolve();
128
+ return new Promise((resolve) => {
129
+ let settled = false;
130
+ const settle = () => {
131
+ if (settled) return;
132
+ settled = true;
133
+ clearTimeout(timer);
134
+ resolve();
135
+ };
136
+ const timer = setTimeout(settle, timeoutMs);
137
+ proc.once('close', settle);
138
+ });
139
+ }
90
140
  }
91
141
 
92
142
  export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
93
143
  const mpv = findExecutable('mpv', env);
94
144
  if (mpv) {
145
+ // No --cache=no: mpv's read-ahead cache buffers the incoming pipe so the
146
+ // opening audio survives device-open latency and network jitter instead of
147
+ // underrunning while the output device is still spinning up.
95
148
  return {
96
149
  command: mpv,
97
- args: ['--no-terminal', '--really-quiet', '--force-window=no', '--cache=no', '-'],
150
+ args: ['--no-terminal', '--really-quiet', '--force-window=no', '-'],
98
151
  label: 'mpv',
99
152
  };
100
153
  }
@@ -102,18 +155,51 @@ export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = proc
102
155
  if (ffplay) {
103
156
  return {
104
157
  command: ffplay,
105
- args: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
158
+ args: FFPLAY_BASE_ARGS,
106
159
  label: 'ffplay',
107
160
  };
108
161
  }
109
162
  return null;
110
163
  }
111
164
 
165
+ // ffplay -autoexit quits as soon as its input ends, before the audio output
166
+ // buffer has drained — that clips the tail of the response. `apad` appends a
167
+ // short run of silence so the real audio is fully played out and only the
168
+ // trailing silence gets trimmed.
169
+ const FFPLAY_APAD = ['-af', 'apad=pad_dur=0.3'] as const;
170
+ const FFPLAY_BASE_ARGS = ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-i', 'pipe:0'] as const;
171
+
112
172
  function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
113
173
  if (command.label !== 'ffplay' || !format) return command.args;
114
174
  const normalized = format.trim().toLowerCase();
115
175
  if (!normalized || normalized.includes('/')) return command.args;
116
- return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
176
+ return ['-nodisp', '-autoexit', '-loglevel', 'error', ...FFPLAY_APAD, '-f', normalized, '-i', 'pipe:0'];
177
+ }
178
+
179
+ /**
180
+ * awaitReady — resolves once the spawned player has actually started (its
181
+ * 'spawn' event), rejects if it fails to start ('error'), and resolves early
182
+ * if the caller aborts during startup so an intentional interrupt is never
183
+ * blocked. This is the readiness gate that keeps the first audio byte from
184
+ * being written into a not-yet-running sink.
185
+ */
186
+ function awaitReady(proc: SpawnProcess, signal?: AbortSignal): Promise<void> {
187
+ if (signal?.aborted) return Promise.resolve();
188
+ return new Promise<void>((resolve, reject) => {
189
+ let settled = false;
190
+ const settle = (action: () => void) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ signal?.removeEventListener('abort', onAbort);
194
+ action();
195
+ };
196
+ const onSpawn = () => settle(resolve);
197
+ const onError = (error: unknown) => settle(() => reject(error instanceof Error ? error : new Error(String(error))));
198
+ const onAbort = () => settle(resolve);
199
+ proc.once('spawn', onSpawn);
200
+ proc.once('error', onError);
201
+ signal?.addEventListener('abort', onAbort, { once: true });
202
+ });
117
203
  }
118
204
 
119
205
  function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {
@@ -3,13 +3,15 @@ import type { UiRuntimeEvents } from '@/runtime/index.ts';
3
3
  import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
4
4
  import type { StreamingAudioPlayer } from './player.ts';
5
5
  import { LocalStreamingAudioPlayer } from './player.ts';
6
- import { SpokenTurnController } from './spoken-turn-controller.ts';
6
+ import { SpokenTurnController } from '@pellux/goodvibes-sdk/platform/voice';
7
7
 
8
8
  export interface SpokenTurnRuntime {
9
9
  readonly unsubs: readonly (() => void)[];
10
10
  submitNextTurn(prompt: string): boolean;
11
11
  /** Returns whether speech was actually active (see controller.stop). */
12
12
  stop(message?: string): boolean;
13
+ /** Exit-path stop: lets the audio already playing drain, bounded (see controller.stopForExit). */
14
+ stopForExit(drainTimeoutMs?: number): Promise<void>;
13
15
  }
14
16
 
15
17
  export interface WireSpokenTurnRuntimeOptions {
@@ -35,10 +37,15 @@ export interface WireSpokenTurnRuntimeOptions {
35
37
  */
36
38
  export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
37
39
  const player = options.playerFactory ? options.playerFactory() : new LocalStreamingAudioPlayer();
40
+ // The TUI's subprocess-based player already satisfies the SDK's AudioSink
41
+ // interface unchanged (see docs/decisions/2026-07-06-spoken-turn-tts-policy-sdk-hoist.md
42
+ // in the SDK repo) — only the option name (`sink` not `player`) and the
43
+ // attribution `source` are new.
38
44
  const controller = new SpokenTurnController({
39
45
  voiceService: options.voiceService,
40
46
  configManager: options.configManager,
41
- player,
47
+ sink: player,
48
+ source: 'goodvibes-tui',
42
49
  notify: options.notify,
43
50
  });
44
51
 
@@ -63,5 +70,6 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
63
70
  unsubs,
64
71
  submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
65
72
  stop: (message) => controller.stop(message),
73
+ stopForExit: (drainTimeoutMs) => controller.stopForExit(drainTimeoutMs),
66
74
  };
67
75
  }
@@ -20,6 +20,7 @@ import { spawn } from 'node:child_process';
20
20
  import { networkInterfaces } from 'node:os';
21
21
  import type { ConfigManager, GoodVibesConfig } from '../config/index.ts';
22
22
  import { bootstrapRuntime } from '../runtime/bootstrap.ts';
23
+ import { refreshMemoryRecallSnapshot } from '../runtime/orchestrator-core-services.ts';
23
24
  import { createRuntimeServices } from '../runtime/services.ts';
24
25
  import { createRuntimeStore } from '../runtime/store/index.ts';
25
26
  import type { RuntimeServices } from '../runtime/services.ts';
@@ -317,6 +318,9 @@ export async function runNonInteractiveAgent(runtime: CliCommandRuntime): Promis
317
318
  });
318
319
 
319
320
  try {
321
+ // Async pre-turn refresh of the memory-spine recall snapshot (SDK 1.2.0
322
+ // full-detach) — see the matching comment in main.ts's submitInput.
323
+ await refreshMemoryRecallSnapshot(ctx.services);
320
324
  await ctx.orchestrator.handleUserInput(prompt);
321
325
  await done;
322
326
  if (outputFormat === 'json') {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * credential-availability.ts — W6-C1 client-side credential-status read.
2
+ * credential-availability.ts — client-side credential-status read.
3
3
  *
4
4
  * When the TUI acts as a CLIENT of an adopted external daemon (the host-service
5
5
  * `mode === 'external'` topology — see runtime/bootstrap.ts), provider/model/secret
@@ -56,7 +56,7 @@ export function getConfiguredSystemPrompt(configManager: Pick<ConfigManager, 'ge
56
56
 
57
57
  export { getConfiguredApiKeys, resolveApiKeys } from '@pellux/goodvibes-sdk/platform/config';
58
58
 
59
- // W6-C1: client-side credential-status read (honest-degrade, status-only). When the
59
+ // Client-side credential-status read (honest-degrade, status-only). When the
60
60
  // TUI acts as a client of an adopted external daemon it reads provider/model/secret
61
61
  // STATUS from that daemon's `credentials.get`, never from its own surfaceRoot store.
62
62
  export {
@@ -8,7 +8,7 @@ import type { SpokenTurnRuntime } from '../audio/spoken-turn-wiring.ts';
8
8
  * cap while still wiring `commandContext.isGenerating` (see W1.6 FIX 1).
9
9
  *
10
10
  * Returns whether SPEECH was actively stopped — a Ctrl+C that silenced live
11
- * TTS is consumed by that job (batch replay D5), same as a press that
11
+ * TTS is consumed by that job (an earlier replay fix), same as a press that
12
12
  * cleared a non-empty prompt; the quit chord starts from a quiet state.
13
13
  */
14
14
  export function createCancelGeneration(
package/src/daemon/cli.ts CHANGED
@@ -163,7 +163,7 @@ async function main(): Promise<void> {
163
163
  logger.info('daemon: --hostname flag applied', { hostname: cliFlags.hostname });
164
164
  }
165
165
 
166
- // D7a Layer 1: `install-service` / `uninstall-service` / `service-status` manage
166
+ // Service lifecycle: `install-service` / `uninstall-service` / `service-status` manage
167
167
  // the systemd USER unit for the shared daemon. They run BEFORE the daemon boots —
168
168
  // no runtime/services are constructed — and exit with the honest result code.
169
169
  const serviceSubcommand = cli.positionals[0];
@@ -204,7 +204,7 @@ async function main(): Promise<void> {
204
204
  homeDirectory,
205
205
  });
206
206
 
207
- // F2: Load persisted providers from disk so the provider registry is pre-populated
207
+ // Load persisted providers from disk so the provider registry is pre-populated
208
208
  // on standalone daemon startup (same machinery the TUI uses after /scan).
209
209
  const discoveryRoots = { homeDirectory, surfaceRoot: 'tui' };
210
210
  const persistedProviders = loadPersistedProviders(discoveryRoots);
@@ -213,7 +213,7 @@ async function main(): Promise<void> {
213
213
  logger.info('daemon: loaded persisted providers', { count: persistedProviders.length });
214
214
  }
215
215
 
216
- // F2: Run a background LAN scan (non-blocking). Discovered servers are registered
216
+ // Run a background LAN scan (non-blocking). Discovered servers are registered
217
217
  // and persisted so subsequent daemon restarts benefit from the warm cache.
218
218
  void scan().then((result) => {
219
219
  if (result.servers.length > 0) {
@@ -245,7 +245,7 @@ async function main(): Promise<void> {
245
245
  // If no explicit daemon token is set, use the companion token so mobile apps can connect.
246
246
  const daemonHomeDir = join(homeDirectory, '.goodvibes', 'daemon');
247
247
  const companionTokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
248
- // F3 resolution (TUI 0.19.20): remove stale pre-0.21.28 workspace-scoped operator
248
+ // Fix (TUI 0.19.20): remove stale pre-0.21.28 workspace-scoped operator
249
249
  // token files so only the canonical <daemonHomeDir>/operator-tokens.json survives.
250
250
  const prune = pruneStaleOperatorTokens({
251
251
  daemonHomeDir,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * D7a Layer 1 (TUI wiring) — `goodvibes-daemon install-service | uninstall-service
2
+ * TUI wiring — `goodvibes-daemon install-service | uninstall-service
3
3
  * | service-status`.
4
4
  *
5
5
  * The daemon is a SYSTEM SERVICE. These subcommands install it as a durable host
@@ -7,8 +7,8 @@
7
7
  * on Windows) so N surfaces share ONE daemon that survives reboots, instead of
8
8
  * every surface spawning a session-scoped daemon.
9
9
  *
10
- * W3 drift note: the SDK's D7a-era `systemd-user-service.ts` (a Linux-only,
11
- * bespoke systemd shim) was dead code and got deleted in SDK W3-S5. The SDK's
10
+ * Drift note: the SDK's older `systemd-user-service.ts` (a Linux-only,
11
+ * bespoke systemd shim) was dead code and got deleted from the SDK. The SDK's
12
12
  * REAL wired service machinery — reached in production by the daemon's own HTTP
13
13
  * `/api/service/*` routes via facade-composition.ts — is
14
14
  * `PlatformServiceManager` (`@pellux/goodvibes-sdk/platform/daemon`): a single
@@ -26,7 +26,7 @@
26
26
  * stray "enabled" symlink may remain until `systemctl --user daemon-reload`
27
27
  * (offered back as a suggested follow-up) or the next login cleans it up.
28
28
  *
29
- * W4-D1: a fourth subcommand, `migrate-service`, closes the Wave-3 "detect and
29
+ * A fourth subcommand, `migrate-service`, closes the earlier "detect and
30
30
  * disclose only" inheritance (W3 Finding 4, below) with a GUIDED, CONSENTED
31
31
  * takeover of the legacy `goodvibes-daemon.service` unit. Design constraints,
32
32
  * all load-bearing:
@@ -77,9 +77,9 @@ export {
77
77
  export type { ManagedServiceActionRunner } from '../runtime/legacy-daemon-migration.ts';
78
78
 
79
79
  // ---------------------------------------------------------------------------
80
- // W3 Finding 4: legacy-unit detection. W4-D1: guided migration.
80
+ // Legacy-unit detection, plus guided migration.
81
81
  //
82
- // The prior D7a-era command installed the daemon's systemd unit under the
82
+ // The prior command installed the daemon's systemd unit under the
83
83
  // literal name `goodvibes-daemon.service`. This module (rewired onto
84
84
  // PlatformServiceManager, see the file banner above) manages a DIFFERENT
85
85
  // unit name (`goodvibes`, SERVICE_NAME). A host that still has the legacy
@@ -243,7 +243,7 @@ function failed(action: 'install' | 'uninstall' | 'status', status: ManagedServi
243
243
 
244
244
  /**
245
245
  * `migrate-service`: the guided, consented takeover of the legacy
246
- * `goodvibes-daemon.service` unit (W4-D1). Thin wrapper over
246
+ * `goodvibes-daemon.service` unit. Thin wrapper over
247
247
  * `runLegacyDaemonMigration` (`../runtime/legacy-daemon-migration.ts`) — see
248
248
  * that module for the design constraints (never auto-migrate,
249
249
  * new-up-then-old-down, adopt-or-warn/never kill an unrecognized process,
@@ -314,7 +314,7 @@ export async function runDaemonServiceCli(input: DaemonServiceCliInput): Promise
314
314
  const extra: string[] = [];
315
315
  if (stopped.actionError) extra.push(`(it may not have been running: ${stopped.actionError})`);
316
316
  // W3 Finding 4: this command only ever touches the TRACKED unit
317
- // (whatever name/path actually resolved, per F2 — not necessarily the
317
+ // (whatever name/path actually resolved — not necessarily the
318
318
  // SERVICE_NAME constant) above — say so explicitly when a legacy unit
319
319
  // also exists, so its continued presence is never a silent surprise.
320
320
  if (legacy.present) extra.push(legacyUnitNote(legacy, resolveManagedUnitName(uninstalled)));
@@ -174,6 +174,14 @@ export interface BudgetAlertConfigAccess {
174
174
  /** Default for the `behavior.budgetAlertUsd` synthetic setting: 0 = no budget configured. */
175
175
  export const BUDGET_ALERT_USD_DEFAULT = 0;
176
176
 
177
+ /**
178
+ * The dot-path config key backing the session cost-budget alert threshold.
179
+ * Shared by cost-tracker-panel.ts (get/set), settings-modal-data.ts (the
180
+ * settings-modal-visible synthetic entry), and readBudgetAlertUsd below, so
181
+ * the string literal lives in exactly one place.
182
+ */
183
+ export const BUDGET_ALERT_USD_CONFIG_KEY = 'behavior.budgetAlertUsd';
184
+
177
185
  /**
178
186
  * readBudgetAlertUsd — read the session cost-budget alert threshold (USD) from
179
187
  * config. This is the single source of truth CostTrackerPanel and the
@@ -183,7 +191,7 @@ export const BUDGET_ALERT_USD_DEFAULT = 0;
183
191
  * when the key is absent or invalid.
184
192
  */
185
193
  export function readBudgetAlertUsd(configGet: (key: string) => unknown): number {
186
- const raw = configGet('behavior.budgetAlertUsd');
194
+ const raw = configGet(BUDGET_ALERT_USD_CONFIG_KEY);
187
195
  const parsed = typeof raw === 'number' ? raw : Number(raw);
188
196
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : BUDGET_ALERT_USD_DEFAULT;
189
197
  }
@@ -13,6 +13,7 @@ import type { KeybindingsManager } from './keybindings.ts';
13
13
  import type { OnboardingWizardMode } from './onboarding/onboarding-wizard.ts';
14
14
  import type { OpenOnboardingWizardOptions } from './handler-ui-state.ts';
15
15
  import type { KnowledgeApi } from '@pellux/goodvibes-sdk/platform/knowledge';
16
+ import type { MemorySpineClient } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
16
17
  import type { HookApi } from '@pellux/goodvibes-sdk/platform/hooks';
17
18
  import type { McpApi } from '@pellux/goodvibes-sdk/platform/mcp';
18
19
  import type { ProviderApi } from '@pellux/goodvibes-sdk/platform/providers';
@@ -192,16 +193,16 @@ export interface CommandSessionServices {
192
193
  * fresh Orchestrator's zeroed default (W0.9).
193
194
  */
194
195
  readonly hydrateSessionUsage?: () => void;
195
- /** Wave 4 (wo703): the orchestration engine's command-facing facade — see runtime/workstream-services.ts. */
196
+ /** The orchestration engine's command-facing facade — see runtime/workstream-services.ts. */
196
197
  readonly workstreamEngine?: import('../runtime/workstream-services.ts').WorkstreamCommandService;
197
- /** Wave 5 (wo804): the repo source-tree code index — see runtime/code-index-services.ts. */
198
+ /** The repo source-tree code index — see runtime/code-index-services.ts. */
198
199
  readonly codeIndexStore?: import('@pellux/goodvibes-sdk/platform/state').CodeIndexStore;
199
- /** Wave-5 Stage B: tool-site reindex scheduler — `/codebase status` reports its last activity. */
200
+ /** Tool-site reindex scheduler — `/codebase status` reports its last activity. */
200
201
  readonly codeIndexReindexScheduler?: import('@pellux/goodvibes-sdk/platform/state').CodeIndexReindexScheduler;
201
- /** Wave-5 Stage B: whether the (default-off) `agent-passive-code-injection` flag is on — for `/codebase status`. */
202
+ /** Whether the (default-off) `agent-passive-code-injection` flag is on — for `/codebase status`. */
202
203
  readonly isPassiveCodeInjectionFlagEnabled?: () => boolean;
203
204
  /**
204
- * Wave 5 (wo805): the MAIN interactive session's per-turn passive-injection
205
+ * The MAIN interactive session's per-turn passive-injection
205
206
  * honesty ring — `Orchestrator.getTurnInjections()`, the main-session
206
207
  * counterpart to `AgentRecord.turnInjections` (wo801). `/recall injections`
207
208
  * with no agent id reads this. Optional so command contexts built without an
@@ -285,6 +286,16 @@ export interface CommandContext
285
286
  readonly peer?: PeerClient;
286
287
  readonly providerApi?: ProviderApi;
287
288
  readonly knowledgeApi?: KnowledgeApi;
289
+ /**
290
+ * The cross-surface memory spine client (SDK 1.2.0 full-detach). `/recall`'s
291
+ * browse/link/queue/export/import subcommands read and write through this —
292
+ * not `knowledgeApi.memory` — so they fully detach from the local store file
293
+ * when a daemon has been adopted, per
294
+ * docs/decisions/2026-07-06-memory-wire-full-detach.md in the SDK repo.
295
+ * `explain` and the `vector` diagnostic subcommand stay on `knowledgeApi.memory`
296
+ * (host-side projection / host-only maintenance, ruled explicitly in that decision).
297
+ */
298
+ readonly memorySpine?: MemorySpineClient;
288
299
  readonly hookApi?: HookApi;
289
300
  readonly mcpApi?: McpApi;
290
301
  readonly opsApi?: OpsApi;
@@ -10,7 +10,7 @@ export function registerConfigCommand(registry: CommandRegistry): void {
10
10
  usage: '[category|key] | set <key> <value>',
11
11
  argsHint: '[category|key] | set <key> <value>',
12
12
  handler(args, ctx) {
13
- // Batch replay D2: `/config set <key> <value>` used to fall through to
13
+ // An earlier replay fix: `/config set <key> <value>` used to fall through to
14
14
  // the workspace with the assignment silently ignored — the dishonest-
15
15
  // fallthrough class. `set` is now a real verb.
16
16
  if (args[0] === 'set') {
@@ -3,7 +3,7 @@ import { buildMcpAttackPathReview } from '@/runtime/index.ts';
3
3
  import { buildKnowledgeInjectionPrompt, selectKnowledgeForTask } from '@pellux/goodvibes-sdk/platform/state';
4
4
  import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import { requireReadModels, requireSubscriptionManager, requireTokenAuditor } from './runtime-services.ts';
6
- import { getMemoryApi } from './recall-query.ts';
6
+ import { getMemoryApi, getMemorySpine } from './recall-query.ts';
7
7
 
8
8
  export function registerControlRoomRuntimeCommands(registry: CommandRegistry): void {
9
9
  registry.register({
@@ -198,7 +198,7 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
198
198
  aliases: ['pmem'],
199
199
  description: 'Inspect durable project memory: risks, runbooks, and architecture notes',
200
200
  usage: '[open | queue [limit] | explain <task...> [--scope <path> ...]]',
201
- handler(args, ctx) {
201
+ async handler(args, ctx) {
202
202
  const subcommand = (args[0] ?? 'open').toLowerCase();
203
203
  if (subcommand === 'open') {
204
204
  if (ctx.openMemoryPanel) {
@@ -208,11 +208,13 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
208
208
  ctx.print('Memory panel is not available in this runtime.');
209
209
  return;
210
210
  }
211
- const memory = getMemoryApi(ctx);
212
- if (!memory) return;
213
211
  if (subcommand === 'queue') {
212
+ // Repointed onto the memory spine (SDK 1.2.0 full-detach) so the
213
+ // queue reflects the daemon's own canonical store when adopted.
214
+ const memory = getMemorySpine(ctx);
215
+ if (!memory) return;
214
216
  const limit = Math.max(1, parseInt(args[1] ?? '10', 10) || 10);
215
- const queue = memory.reviewQueue(limit);
217
+ const queue = await memory.reviewQueue(limit);
216
218
  if (queue.length === 0) {
217
219
  ctx.print('Knowledge review queue is empty.');
218
220
  return;
@@ -224,6 +226,11 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
224
226
  return;
225
227
  }
226
228
  if (subcommand === 'explain') {
229
+ // Stays on the local MemoryApi: `explain` is a host-side projection
230
+ // over whatever read surface is active, not a store operation
231
+ // (docs/decisions/2026-07-06-memory-wire-full-detach.md, SDK repo).
232
+ const memory = getMemoryApi(ctx);
233
+ if (!memory) return;
227
234
  const scopeIdx = args.indexOf('--scope');
228
235
  const scopeValues = scopeIdx !== -1
229
236
  ? args.slice(scopeIdx + 1).filter((token) => !token.startsWith('--'))
@@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
4
  import { buildIncidentMemoryAddOptions } from '@pellux/goodvibes-sdk/platform/state';
5
5
  import { requireShellPaths } from './runtime-services.ts';
6
- import { getMemoryApi } from './recall-query.ts';
6
+ import { getMemorySpine } from './recall-query.ts';
7
7
 
8
8
  export function registerIncidentRuntimeCommands(registry: CommandRegistry): void {
9
9
  registry.register({
@@ -74,7 +74,7 @@ export function registerIncidentRuntimeCommands(registry: CommandRegistry): void
74
74
  return;
75
75
  }
76
76
  if (subcommand === 'capture') {
77
- const memory = getMemoryApi(ctx);
77
+ const memory = getMemorySpine(ctx);
78
78
  if (!memory) return;
79
79
  if (!report) {
80
80
  ctx.print(`Incident not found: ${requestedId ?? 'latest'}`);
@@ -29,7 +29,7 @@ import { VALID_CLASSES, VALID_REVIEW_STATES, VALID_SCOPES } from './recall-share
29
29
  * src/input/commands/session-content.ts) with no modal surface — so that
30
30
  * work order deliberately did NOT touch /memory and used /recall instead.
31
31
  *
32
- * W6-C3 update (Wave 6 core-verb pass, MEMORY fragmentation — worst-class
32
+ * Update from the core-verb naming pass (MEMORY fragmentation — worst-class
33
33
  * collision #2): the agent's own `/memory` command was a plain alias for its
34
34
  * `/recall`-equivalent the whole time, meaning "/memory" meant two unrelated
35
35
  * things depending which surface you were on. The session-notes command was
@@ -99,7 +99,7 @@ export const recallCommand: SlashCommand = {
99
99
 
100
100
  case 'search':
101
101
  case 'find':
102
- handleRecallSearch(rest, context);
102
+ await handleRecallSearch(rest, context);
103
103
  break;
104
104
 
105
105
  case 'vector':
@@ -113,24 +113,24 @@ export const recallCommand: SlashCommand = {
113
113
 
114
114
  case 'get':
115
115
  case 'show':
116
- handleRecallGet(rest, context);
116
+ await handleRecallGet(rest, context);
117
117
  break;
118
118
 
119
119
  case 'queue':
120
- handleRecallQueue(rest, context);
120
+ await handleRecallQueue(rest, context);
121
121
  break;
122
122
 
123
123
  case 'review':
124
- handleRecallReview(rest, context);
124
+ await handleRecallReview(rest, context);
125
125
  break;
126
126
 
127
127
  case 'stale':
128
- handleRecallReview([rest[0] ?? '', 'stale', '--reason', ...rest.slice(1)], context);
128
+ await handleRecallReview([rest[0] ?? '', 'stale', '--reason', ...rest.slice(1)], context);
129
129
  break;
130
130
 
131
131
  case 'contradict':
132
132
  case 'contradicted':
133
- handleRecallReview([rest[0] ?? '', 'contradicted', '--reason', ...rest.slice(1)], context);
133
+ await handleRecallReview([rest[0] ?? '', 'contradicted', '--reason', ...rest.slice(1)], context);
134
134
  break;
135
135
 
136
136
  case 'explain':
@@ -142,7 +142,7 @@ export const recallCommand: SlashCommand = {
142
142
  break;
143
143
 
144
144
  case 'promote':
145
- handleRecallPromote(rest, context);
145
+ await handleRecallPromote(rest, context);
146
146
  break;
147
147
 
148
148
  case 'link':
@@ -151,17 +151,17 @@ export const recallCommand: SlashCommand = {
151
151
 
152
152
  case 'list':
153
153
  case 'ls':
154
- handleRecallList(rest, context);
154
+ await handleRecallList(rest, context);
155
155
  break;
156
156
 
157
157
  case 'remove':
158
158
  case 'delete':
159
159
  case 'rm':
160
- handleRecallRemove(rest, context);
160
+ await handleRecallRemove(rest, context);
161
161
  break;
162
162
 
163
163
  case 'export':
164
- handleRecallExport(rest, context);
164
+ await handleRecallExport(rest, context);
165
165
  break;
166
166
 
167
167
  case 'import':
@@ -170,7 +170,7 @@ export const recallCommand: SlashCommand = {
170
170
 
171
171
  case 'handoff-export':
172
172
  case 'share':
173
- handleRecallHandoffExport(rest, context);
173
+ await handleRecallHandoffExport(rest, context);
174
174
  break;
175
175
 
176
176
  case 'handoff-inspect':
@@ -262,7 +262,7 @@ export function registerPlanningRuntimeCommands(registry: CommandRegistry): void
262
262
  return;
263
263
  }
264
264
 
265
- // Defense (Wave 6 review): a single verb-looking token is almost never a
265
+ // Defense (review finding): a single verb-looking token is almost never a
266
266
  // real planning goal — it is a mistyped or removed subcommand. The
267
267
  // Planning modal used to dispatch `/plan dismiss`, which has no
268
268
  // subcommand and silently fell through to this free-form branch, seeding