@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
@@ -1,5 +1,5 @@
1
1
  // ---------------------------------------------------------------------------
2
- // workstream-services.ts — Wave 4 (wo703)
2
+ // workstream-services.ts — phase/work-item orchestration engine
3
3
  //
4
4
  // Constructs the TUI's OrchestrationEngine instance (@pellux/goodvibes-sdk/
5
5
  // platform/orchestration, landed on SDK main as wo701/W4.1) and a thin
@@ -19,7 +19,7 @@
19
19
  * additionally exposes the recovered set for inspection.
20
20
  *
21
21
  * A chain that looked interrupted (non-terminal) in the old snapshot can be
22
- * reaped to terminal IN PLACE by `WrfcController.importChain` (Wave 6, wo-F:
22
+ * reaped to terminal IN PLACE by `WrfcController.importChain` (the zombie-reap pass:
23
23
  * no member agent survived the restart — see wrfc-controller.ts). This
24
24
  * module always re-checks `chain.state` AFTER the import call, never the
25
25
  * pre-import classification, so a reaped chain is treated as history, not
@@ -209,7 +209,7 @@ class WrfcPersistenceImpl implements WrfcPersistence {
209
209
  // chain. The accessor is optional for read-only test doubles.
210
210
  //
211
211
  // NOTE: importChain may reap this chain to a terminal state IN PLACE
212
- // (Wave 6, wo-F zombie-reap: no member agent survived the restart) —
212
+ // (zombie reap: no member agent survived the restart) —
213
213
  // always read chain.state AFTER this call below, never the pre-import
214
214
  // classification captured by candidateInterrupted.
215
215
  this.controller.importChain?.(chain);
@@ -231,7 +231,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
231
231
 
232
232
  commandContext.openModelPicker = () => {
233
233
  void (async () => {
234
- // D5: getSelectableModels() is catalog-driven and can list models whose
234
+ // getSelectableModels() is catalog-driven and can list models whose
235
235
  // `provider` id (e.g. 'google', sourced from the pricing catalog) was never
236
236
  // handed to providerRegistry.register()/registerRuntimeProvider(). Selecting
237
237
  // such a model fails hard at turn time with ProviderNotFoundError
@@ -264,7 +264,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
264
264
 
265
265
  commandContext.openProviderPicker = () => {
266
266
  void (async () => {
267
- // D5: listModels() surfaces every catalog provider id, not just the ones
267
+ // listModels() surfaces every catalog provider id, not just the ones
268
268
  // actually registered on this runtime (see openModelPicker above for the
269
269
  // same class of bug). Intersect against providerRegistry.has() so the
270
270
  // provider picker never offers a provider that will fail with
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.9.1';
9
+ let _version = '1.10.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;
@@ -1,243 +0,0 @@
1
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
- import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
3
- import type { TurnEvent } from '@/runtime/index.ts';
4
- import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice';
5
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
- import { TtsTextChunker } from './text-chunker.ts';
7
- import type { StreamingAudioPlayer } from './player.ts';
8
-
9
- export interface SpokenTurnControllerOptions {
10
- readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
11
- readonly configManager: Pick<ConfigManager, 'get'>;
12
- readonly player: StreamingAudioPlayer;
13
- readonly notify?: (message: string) => void;
14
- readonly now?: () => number;
15
- readonly setInterval?: typeof setInterval;
16
- readonly clearInterval?: typeof clearInterval;
17
- }
18
-
19
- export class SpokenTurnController {
20
- private pendingPrompt: string | null = null;
21
- private activeTurnId: string | null = null;
22
- private chunker: TtsTextChunker | null = null;
23
- private chunkSequence = 0;
24
- private playbackChain: Promise<void> = Promise.resolve();
25
- private readonly abortControllers = new Set<AbortController>();
26
- private timer: ReturnType<typeof setInterval> | null = null;
27
- private errorReportedForTurn = false;
28
- private noPlayerNoticed = false;
29
- private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
30
- private readonly configManager: Pick<ConfigManager, 'get'>;
31
- private readonly player: StreamingAudioPlayer;
32
- private readonly notify?: (message: string) => void;
33
- private readonly now: () => number;
34
- private readonly setIntervalImpl: typeof setInterval;
35
- private readonly clearIntervalImpl: typeof clearInterval;
36
-
37
- constructor(options: SpokenTurnControllerOptions) {
38
- this.voiceService = options.voiceService;
39
- this.configManager = options.configManager;
40
- this.player = options.player;
41
- this.notify = options.notify;
42
- this.now = options.now ?? (() => Date.now());
43
- this.setIntervalImpl = options.setInterval ?? setInterval;
44
- this.clearIntervalImpl = options.clearInterval ?? clearInterval;
45
- }
46
-
47
- submitNextTurn(prompt: string): boolean {
48
- const normalized = prompt.trim();
49
- if (!normalized) return false;
50
- this.stop();
51
- if (!this.player.available) {
52
- if (!this.noPlayerNoticed) {
53
- this.noPlayerNoticed = true;
54
- this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
55
- }
56
- return false;
57
- }
58
- // Reset the no-player notice if player becomes available again.
59
- this.noPlayerNoticed = false;
60
- this.pendingPrompt = normalized;
61
- return true;
62
- }
63
-
64
- /**
65
- * Returns whether speech was actually ACTIVE when stopped. The notice only
66
- * prints in that case — stop() on an idle controller used to notify anyway,
67
- * spamming "[TTS] Spoken output stopped." on every Ctrl+C (batch replay
68
- * D5/N-noise); callers use the return to decide whether the press "did a
69
- * job" (see handleCtrlC's consume-on-speech-stop).
70
- */
71
- stop(message?: string): boolean {
72
- const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
73
- || this.chunker !== null || this.abortControllers.size > 0;
74
- this.pendingPrompt = null;
75
- this.activeTurnId = null;
76
- this.chunker?.reset();
77
- this.chunker = null;
78
- this.stopTimer();
79
- for (const controller of this.abortControllers) controller.abort();
80
- this.abortControllers.clear();
81
- this.player.stop();
82
- this.playbackChain = Promise.resolve();
83
- this.errorReportedForTurn = false;
84
- if (message && wasActive) this.notify?.(`[TTS] ${message}`);
85
- return wasActive;
86
- }
87
-
88
- handleTurnEvent(event: TurnEvent): void {
89
- if (event.type === 'TURN_SUBMITTED') {
90
- this.maybeStartTurn(event.turnId, event.prompt);
91
- return;
92
- }
93
- if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
94
-
95
- if (event.type === 'STREAM_DELTA') {
96
- this.enqueueChunks(this.chunker?.push(event.content) ?? []);
97
- return;
98
- }
99
- if (event.type === 'STREAM_END') {
100
- return;
101
- }
102
- if (event.type === 'TURN_COMPLETED') {
103
- this.finishTurn(event.turnId);
104
- return;
105
- }
106
- if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
107
- this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
108
- }
109
- }
110
-
111
- private maybeStartTurn(turnId: string, prompt: string): void {
112
- if (!this.pendingPrompt) return;
113
- if (prompt.trim() !== this.pendingPrompt) return;
114
- this.pendingPrompt = null;
115
- this.activeTurnId = turnId;
116
- this.chunkSequence = 0;
117
- this.errorReportedForTurn = false;
118
- this.chunker = new TtsTextChunker({ now: this.now });
119
- this.playbackChain = Promise.resolve();
120
- this.startTimer();
121
- this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
122
- }
123
-
124
- private finishTurn(turnId: string): void {
125
- if (turnId !== this.activeTurnId) return;
126
- this.enqueueChunks(this.chunker?.flushAll() ?? []);
127
- this.stopTimer();
128
- const chain = this.playbackChain;
129
- chain.finally(() => {
130
- if (this.activeTurnId !== turnId) return;
131
- this.activeTurnId = null;
132
- this.chunker = null;
133
- this.abortControllers.clear();
134
- }).catch(() => {
135
- // Errors are already reported in the queued task.
136
- });
137
- }
138
-
139
- private startTimer(): void {
140
- this.stopTimer();
141
- this.timer = this.setIntervalImpl(() => {
142
- if (!this.activeTurnId || !this.chunker) return;
143
- this.enqueueChunks(this.chunker.flushDue());
144
- }, 250);
145
- }
146
-
147
- private stopTimer(): void {
148
- if (!this.timer) return;
149
- this.clearIntervalImpl(this.timer);
150
- this.timer = null;
151
- }
152
-
153
- private enqueueChunks(chunks: readonly string[]): void {
154
- for (const chunk of chunks) {
155
- this.enqueueChunk(chunk);
156
- }
157
- }
158
-
159
- private enqueueChunk(text: string): void {
160
- const turnId = this.activeTurnId;
161
- if (!turnId || !text.trim()) return;
162
- const sequence = ++this.chunkSequence;
163
- const abortController = new AbortController();
164
- this.abortControllers.add(abortController);
165
- const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
166
- .then((result) => ({ ok: true as const, result }))
167
- .catch((error: unknown) => ({ ok: false as const, error }));
168
-
169
- this.playbackChain = this.playbackChain.then(async () => {
170
- if (abortController.signal.aborted) return;
171
- const result = await resultPromise;
172
- this.abortControllers.delete(abortController);
173
- if (!result.ok) {
174
- this.reportError(result.error);
175
- return;
176
- }
177
- await this.player.play(result.result.chunks, {
178
- format: String(result.result.format ?? 'mp3'),
179
- signal: abortController.signal,
180
- });
181
- }).catch((error: unknown) => {
182
- this.abortControllers.delete(abortController);
183
- this.reportError(error);
184
- });
185
- }
186
-
187
- private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
188
- // tts.speed: VoiceSynthesisRequest accepts speed (number | undefined).
189
- // No ConfigKey for tts.speed exists in the current SDK schema — pending
190
- // SDK schema addition. Speed is not threaded from config until that key
191
- // is added. See docs/voice-and-live-tts.md § Speed.
192
- return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
193
- text,
194
- voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
195
- format: 'mp3',
196
- speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
197
- signal,
198
- metadata: {
199
- source: 'goodvibes-tui',
200
- feature: 'live-tts',
201
- turnId,
202
- sequence,
203
- },
204
- });
205
- }
206
-
207
- private reportError(error: unknown): void {
208
- if (this.errorReportedForTurn) return;
209
- this.errorReportedForTurn = true;
210
- this.activeTurnId = null;
211
- this.chunker = null;
212
- this.stopTimer();
213
- for (const controller of this.abortControllers) controller.abort();
214
- this.abortControllers.clear();
215
- this.player.stop();
216
- this.playbackChain = Promise.resolve();
217
- this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
218
- }
219
- }
220
-
221
- function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
222
- const value = String(configManager.get(key) ?? '').trim();
223
- return value || undefined;
224
- }
225
-
226
- /**
227
- * readOptionalConfigNumber — reads a numeric config value by key.
228
- *
229
- * `tts.speed` is not yet a ConfigKey in the SDK schema. This helper accepts
230
- * a string key and casts it, returning undefined when the value is absent,
231
- * zero, or not a finite positive number. Once `tts.speed` is added to the
232
- * SDK schema the cast can be removed and the key typed statically.
233
- *
234
- * SDK handoff note: add { key: 'tts.speed', type: 'number', default: 1,
235
- * description: '...' } to schema-domain-core.js and `tts: { ..., speed: 1 }`
236
- * to DEFAULT_CONFIG.tts to complete this feature.
237
- */
238
- function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
239
- // Cast required: key is not yet a valid ConfigKey in the SDK schema.
240
- const raw = configManager.get(key as ConfigKey);
241
- const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
242
- return isFinite(value) && value > 0 ? value : undefined;
243
- }
@@ -1,110 +0,0 @@
1
- export interface TtsTextChunkerOptions {
2
- readonly minBoundaryChars?: number;
3
- readonly maxChunkChars?: number;
4
- readonly maxLatencyMs?: number;
5
- readonly now?: () => number;
6
- }
7
-
8
- export class TtsTextChunker {
9
- private buffer = '';
10
- private firstBufferedAt: number | null = null;
11
- private readonly minBoundaryChars: number;
12
- private readonly maxChunkChars: number;
13
- private readonly maxLatencyMs: number;
14
- private readonly now: () => number;
15
-
16
- constructor(options: TtsTextChunkerOptions = {}) {
17
- this.minBoundaryChars = options.minBoundaryChars ?? 24;
18
- this.maxChunkChars = options.maxChunkChars ?? 320;
19
- this.maxLatencyMs = options.maxLatencyMs ?? 1_000;
20
- this.now = options.now ?? (() => Date.now());
21
- }
22
-
23
- push(delta: string): string[] {
24
- if (!delta) return [];
25
- if (this.firstBufferedAt === null) this.firstBufferedAt = this.now();
26
- this.buffer += delta;
27
- return this.drainReady(false);
28
- }
29
-
30
- flushDue(): string[] {
31
- if (!this.buffer.trim() || this.firstBufferedAt === null) return [];
32
- if (this.now() - this.firstBufferedAt < this.maxLatencyMs) return [];
33
- return this.drainReady(true);
34
- }
35
-
36
- flushAll(): string[] {
37
- if (!this.buffer.trim()) {
38
- this.buffer = '';
39
- this.firstBufferedAt = null;
40
- return [];
41
- }
42
- return [this.takeChunk(this.buffer.length)].filter(Boolean);
43
- }
44
-
45
- reset(): void {
46
- this.buffer = '';
47
- this.firstBufferedAt = null;
48
- }
49
-
50
- private drainReady(forceLatencyFlush: boolean): string[] {
51
- const chunks: string[] = [];
52
- while (this.buffer.trim()) {
53
- const boundary = this.findBoundary(forceLatencyFlush);
54
- if (boundary <= 0) break;
55
- const chunk = this.takeChunk(boundary);
56
- if (chunk) chunks.push(chunk);
57
- forceLatencyFlush = false;
58
- }
59
- return chunks;
60
- }
61
-
62
- private findBoundary(forceLatencyFlush: boolean): number {
63
- const latestSentence = this.findLatestSentenceBoundary();
64
- if (latestSentence >= this.minBoundaryChars) return latestSentence;
65
-
66
- if (this.buffer.length >= this.maxChunkChars) {
67
- return this.findWordBoundaryBefore(this.maxChunkChars) || this.maxChunkChars;
68
- }
69
-
70
- if (forceLatencyFlush) {
71
- return this.buffer.length;
72
- }
73
-
74
- return -1;
75
- }
76
-
77
- private findLatestSentenceBoundary(): number {
78
- let best = -1;
79
- for (let i = 0; i < this.buffer.length; i++) {
80
- const char = this.buffer[i];
81
- if (char !== '.' && char !== '!' && char !== '?' && char !== ';' && char !== ':' && char !== '\n') {
82
- continue;
83
- }
84
- const next = this.buffer[i + 1];
85
- if (i === this.buffer.length - 1 || next === undefined || /\s/.test(next)) {
86
- best = i + 1;
87
- }
88
- }
89
- return best;
90
- }
91
-
92
- private findWordBoundaryBefore(index: number): number {
93
- const max = Math.min(index, this.buffer.length);
94
- for (let i = max; i > 0; i--) {
95
- if (/\s/.test(this.buffer[i - 1] ?? '')) return i;
96
- }
97
- return -1;
98
- }
99
-
100
- private takeChunk(end: number): string {
101
- const raw = this.buffer.slice(0, end);
102
- this.buffer = this.buffer.slice(end);
103
- this.firstBufferedAt = this.buffer.trim() ? this.now() : null;
104
- return normalizeSpeechText(raw);
105
- }
106
- }
107
-
108
- export function normalizeSpeechText(text: string): string {
109
- return text.replace(/\s+/g, ' ').trim();
110
- }