@pellux/goodvibes-agent 1.5.7 → 1.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -3
- package/README.md +1 -1
- package/dist/package/main.js +309 -150
- package/package.json +1 -1
- package/src/agent/memory-prompt.ts +3 -3
- package/src/agent/operator-actions.ts +1 -1
- package/src/agent/prompt-context-receipts.ts +3 -3
- package/src/agent/session-registration.ts +1 -1
- package/src/agent/vibe-file.ts +5 -5
- package/src/audio/player.ts +91 -5
- package/src/audio/spoken-turn-controller.ts +281 -29
- package/src/audio/spoken-turn-wiring.ts +13 -2
- package/src/cli/local-library-command-shared.ts +1 -1
- package/src/cli/memory-command.ts +9 -6
- package/src/cli/resume-relaunch-notice.ts +1 -1
- package/src/cli/tui-startup.ts +1 -1
- package/src/config/credential-status.ts +1 -1
- package/src/config/index.ts +1 -1
- package/src/config/secrets.ts +1 -1
- package/src/core/conversation-rendering.ts +2 -2
- package/src/core/system-message-noise.ts +3 -3
- package/src/core/thinking-overlay.ts +1 -1
- package/src/input/agent-workspace-basic-command-editor-submission.ts +1 -1
- package/src/input/agent-workspace-basic-command-editors.ts +1 -1
- package/src/input/agent-workspace-calendar-connect-editor.ts +1 -1
- package/src/input/agent-workspace-calendar-oauth-editor.ts +1 -1
- package/src/input/agent-workspace-calendar-subscribe-editor.ts +1 -1
- package/src/input/agent-workspace-command-editor-engine.ts +2 -2
- package/src/input/agent-workspace-direct-editor-submission.ts +1 -1
- package/src/input/agent-workspace-email-connect-editor.ts +1 -1
- package/src/input/agent-workspace-live-counters.ts +1 -1
- package/src/input/agent-workspace-settings.ts +1 -1
- package/src/input/agent-workspace-snapshot-builders.ts +2 -2
- package/src/input/agent-workspace-snapshot-config.ts +1 -1
- package/src/input/agent-workspace-snapshot.ts +9 -9
- package/src/input/agent-workspace-types.ts +4 -4
- package/src/input/agent-workspace.ts +1 -1
- package/src/input/commands/calendar-connect-runtime.ts +1 -1
- package/src/input/commands/calendar-subscription-runtime.ts +2 -2
- package/src/input/commands/operator-actions-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/commands/session-workflow.ts +1 -1
- package/src/input/feed-context-factory.ts +4 -4
- package/src/input/handler-feed.ts +9 -9
- package/src/input/handler.ts +1 -1
- package/src/input/panel-paste-flood-guard.ts +3 -3
- package/src/input/settings-modal.ts +1 -1
- package/src/main.ts +28 -33
- package/src/renderer/agent-workspace-context-lines.ts +1 -1
- package/src/renderer/startup-theme-probe.ts +1 -1
- package/src/renderer/status-glyphs.ts +2 -2
- package/src/renderer/terminal-bg-probe.ts +1 -1
- package/src/renderer/terminal-escapes.ts +3 -2
- package/src/renderer/theme-mode-config.ts +2 -2
- package/src/renderer/theme.ts +4 -4
- package/src/renderer/thinking.ts +1 -1
- package/src/renderer/ui-factory.ts +1 -1
- package/src/renderer/ui-primitives.ts +2 -2
- package/src/runtime/bootstrap-core.ts +2 -2
- package/src/runtime/bootstrap.ts +3 -3
- package/src/runtime/calendar-boot-refresh.ts +1 -1
- package/src/runtime/lan-scan-consent.ts +1 -1
- package/src/runtime/services.ts +7 -7
- package/src/runtime/terminal-output-guard.ts +1 -1
- package/src/runtime/ui-services.ts +1 -1
- package/src/runtime/unhandled-rejection-guard.ts +41 -0
- package/src/shell/agent-workspace-fullscreen.ts +1 -1
- package/src/shell/terminal-focus-mode.ts +9 -9
- package/src/shell/ui-openers.ts +1 -1
- package/src/tools/agent-harness-operator-methods.ts +1 -1
- package/src/tools/agent-harness-personal-ops-discovery.ts +2 -2
- package/src/tools/agent-harness-personal-ops-lanes.ts +1 -1
- package/src/tools/agent-harness-personal-ops-types.ts +1 -1
- package/src/tools/agent-harness-prompt-context.ts +3 -3
- package/src/tools/agent-local-registry-memory.ts +1 -1
- package/src/tools/agent-operator-method-tool.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -6,6 +6,35 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
6
6
|
import { TtsTextChunker } from './text-chunker.ts';
|
|
7
7
|
import type { StreamingAudioPlayer } from './player.ts';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* How many synthesis requests may sit in the pipeline at once (synthesizing,
|
|
11
|
+
* waiting to play, or playing). 2 = the chunk being played plus ONE prefetch,
|
|
12
|
+
* so the next audio is ready the moment the current sink drains. Bounding
|
|
13
|
+
* this is what keeps a streaming answer from bursting N concurrent requests
|
|
14
|
+
* at the voice provider — ElevenLabs plans allow as few as 3 concurrent, and
|
|
15
|
+
* an unbounded burst 429s the whole turn. The SDK config schema has no tts.*
|
|
16
|
+
* key for pipeline tuning, so this is a constant by design.
|
|
17
|
+
*/
|
|
18
|
+
const SYNTHESIS_PIPELINE_WINDOW = 2;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Upper bound for one merged synthesis request's text. The ElevenLabs
|
|
22
|
+
* provider passes request text through verbatim (no cap of its own); the API
|
|
23
|
+
* caps text per request by plan — 2,500 chars on the lowest tiers, 5,000 on
|
|
24
|
+
* most others. 1,500 stays safely under every plan while still folding a
|
|
25
|
+
* multi-paragraph answer into one or two requests.
|
|
26
|
+
*/
|
|
27
|
+
const SYNTHESIS_MERGE_MAX_CHARS = 1500;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Backoff schedule for transient synthesis failures (429 rate/concurrency
|
|
31
|
+
* limits, transient 5xx, network drops): first retry after 1s, second after
|
|
32
|
+
* 2.5s, then the chunk is skipped honestly and the turn continues. The SDK's
|
|
33
|
+
* provider errors are plain Error strings with the HTTP status embedded — no
|
|
34
|
+
* Retry-After header is exposed — so the schedule is fixed, not server-driven.
|
|
35
|
+
*/
|
|
36
|
+
const SYNTHESIS_RETRY_DELAYS_MS = [1000, 2500] as const;
|
|
37
|
+
|
|
9
38
|
export interface SpokenTurnControllerOptions {
|
|
10
39
|
readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
11
40
|
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
@@ -14,6 +43,9 @@ export interface SpokenTurnControllerOptions {
|
|
|
14
43
|
readonly now?: () => number;
|
|
15
44
|
readonly setInterval?: typeof setInterval;
|
|
16
45
|
readonly clearInterval?: typeof clearInterval;
|
|
46
|
+
/** Injectable clock for retry backoff timers (tests use fakes). */
|
|
47
|
+
readonly setTimeout?: typeof setTimeout;
|
|
48
|
+
readonly clearTimeout?: typeof clearTimeout;
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
export class SpokenTurnController {
|
|
@@ -25,6 +57,16 @@ export class SpokenTurnController {
|
|
|
25
57
|
private readonly abortControllers = new Set<AbortController>();
|
|
26
58
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
27
59
|
private errorReportedForTurn = false;
|
|
60
|
+
private noPlayerNoticed = false;
|
|
61
|
+
/** Chunker output waiting to be merged into a synthesis request. */
|
|
62
|
+
private pendingTexts: string[] = [];
|
|
63
|
+
/** Requests currently in the pipeline (synthesizing / waiting / playing). */
|
|
64
|
+
private pipelineDepth = 0;
|
|
65
|
+
/** Bumped on every teardown so stale pipeline releases are ignored. */
|
|
66
|
+
private pipelineGeneration = 0;
|
|
67
|
+
private pumpScheduled = false;
|
|
68
|
+
/** Set when TURN_COMPLETED arrives; the turn releases once the pipeline drains. */
|
|
69
|
+
private completedTurnId: string | null = null;
|
|
28
70
|
private readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
29
71
|
private readonly configManager: Pick<ConfigManager, 'get'>;
|
|
30
72
|
private readonly player: StreamingAudioPlayer;
|
|
@@ -32,6 +74,8 @@ export class SpokenTurnController {
|
|
|
32
74
|
private readonly now: () => number;
|
|
33
75
|
private readonly setIntervalImpl: typeof setInterval;
|
|
34
76
|
private readonly clearIntervalImpl: typeof clearInterval;
|
|
77
|
+
private readonly setTimeoutImpl: typeof setTimeout;
|
|
78
|
+
private readonly clearTimeoutImpl: typeof clearTimeout;
|
|
35
79
|
|
|
36
80
|
constructor(options: SpokenTurnControllerOptions) {
|
|
37
81
|
this.voiceService = options.voiceService;
|
|
@@ -41,6 +85,8 @@ export class SpokenTurnController {
|
|
|
41
85
|
this.now = options.now ?? (() => Date.now());
|
|
42
86
|
this.setIntervalImpl = options.setInterval ?? setInterval;
|
|
43
87
|
this.clearIntervalImpl = options.clearInterval ?? clearInterval;
|
|
88
|
+
this.setTimeoutImpl = options.setTimeout ?? setTimeout;
|
|
89
|
+
this.clearTimeoutImpl = options.clearTimeout ?? clearTimeout;
|
|
44
90
|
}
|
|
45
91
|
|
|
46
92
|
submitNextTurn(prompt: string): boolean {
|
|
@@ -48,25 +94,65 @@ export class SpokenTurnController {
|
|
|
48
94
|
if (!normalized) return false;
|
|
49
95
|
this.stop();
|
|
50
96
|
if (!this.player.available) {
|
|
51
|
-
this.
|
|
97
|
+
if (!this.noPlayerNoticed) {
|
|
98
|
+
this.noPlayerNoticed = true;
|
|
99
|
+
this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
|
|
100
|
+
}
|
|
52
101
|
return false;
|
|
53
102
|
}
|
|
103
|
+
// Reset the no-player notice if player becomes available again.
|
|
104
|
+
this.noPlayerNoticed = false;
|
|
54
105
|
this.pendingPrompt = normalized;
|
|
55
106
|
return true;
|
|
56
107
|
}
|
|
57
108
|
|
|
58
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Returns whether speech was actually ACTIVE when stopped. The notice only
|
|
111
|
+
* prints in that case — stop() on an idle controller used to notify anyway,
|
|
112
|
+
* spamming "[TTS] Spoken output stopped." on every Ctrl+C (an earlier replay
|
|
113
|
+
* fix); callers use the return to decide whether the press "did a
|
|
114
|
+
* job" (see handleCtrlC's consume-on-speech-stop).
|
|
115
|
+
*/
|
|
116
|
+
stop(message?: string): boolean {
|
|
117
|
+
const wasActive = this.pendingPrompt !== null || this.activeTurnId !== null
|
|
118
|
+
|| this.chunker !== null || this.abortControllers.size > 0;
|
|
59
119
|
this.pendingPrompt = null;
|
|
60
120
|
this.activeTurnId = null;
|
|
61
121
|
this.chunker?.reset();
|
|
62
122
|
this.chunker = null;
|
|
63
123
|
this.stopTimer();
|
|
124
|
+
this.resetPipeline();
|
|
64
125
|
for (const controller of this.abortControllers) controller.abort();
|
|
65
126
|
this.abortControllers.clear();
|
|
66
127
|
this.player.stop();
|
|
67
128
|
this.playbackChain = Promise.resolve();
|
|
68
129
|
this.errorReportedForTurn = false;
|
|
69
|
-
if (message) this.notify?.(`[TTS] ${message}`);
|
|
130
|
+
if (message && wasActive) this.notify?.(`[TTS] ${message}`);
|
|
131
|
+
return wasActive;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Exit-path teardown: drops everything not yet audible (pending arm,
|
|
136
|
+
* buffered text, queued chunks) but lets the audio the user is already
|
|
137
|
+
* hearing finish naturally, capped at `drainTimeoutMs`, before the hard
|
|
138
|
+
* stop. Deliberate interrupts (Ctrl+C, /tts stop, turn cancel) keep their
|
|
139
|
+
* instant path through stop(); this is only for exiting the app while the
|
|
140
|
+
* final audio of a completed response is still draining.
|
|
141
|
+
*/
|
|
142
|
+
async stopForExit(drainTimeoutMs = 2000): Promise<void> {
|
|
143
|
+
this.pendingPrompt = null;
|
|
144
|
+
this.activeTurnId = null;
|
|
145
|
+
this.chunker?.reset();
|
|
146
|
+
this.chunker = null;
|
|
147
|
+
this.stopTimer();
|
|
148
|
+
this.resetPipeline();
|
|
149
|
+
// Cancel chunks that have not started playing; the chunk currently in the
|
|
150
|
+
// sink is not in this set (its controller is released before playback).
|
|
151
|
+
for (const controller of this.abortControllers) controller.abort();
|
|
152
|
+
this.abortControllers.clear();
|
|
153
|
+
await this.player.waitForDrain(drainTimeoutMs);
|
|
154
|
+
// Backstop: anything still alive after the window is torn down hard.
|
|
155
|
+
this.stop();
|
|
70
156
|
}
|
|
71
157
|
|
|
72
158
|
handleTurnEvent(event: TurnEvent): void {
|
|
@@ -77,7 +163,7 @@ export class SpokenTurnController {
|
|
|
77
163
|
if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
|
|
78
164
|
|
|
79
165
|
if (event.type === 'STREAM_DELTA') {
|
|
80
|
-
this.
|
|
166
|
+
this.queueTexts(this.chunker?.push(event.content) ?? []);
|
|
81
167
|
return;
|
|
82
168
|
}
|
|
83
169
|
if (event.type === 'STREAM_END') {
|
|
@@ -101,30 +187,33 @@ export class SpokenTurnController {
|
|
|
101
187
|
this.errorReportedForTurn = false;
|
|
102
188
|
this.chunker = new TtsTextChunker({ now: this.now });
|
|
103
189
|
this.playbackChain = Promise.resolve();
|
|
190
|
+
this.resetPipeline();
|
|
104
191
|
this.startTimer();
|
|
105
192
|
this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
|
|
106
193
|
}
|
|
107
194
|
|
|
108
195
|
private finishTurn(turnId: string): void {
|
|
109
196
|
if (turnId !== this.activeTurnId) return;
|
|
110
|
-
this.
|
|
197
|
+
this.queueTexts(this.chunker?.flushAll() ?? []);
|
|
111
198
|
this.stopTimer();
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
199
|
+
this.completedTurnId = turnId;
|
|
200
|
+
// Nothing pending and nothing in flight releases immediately; otherwise
|
|
201
|
+
// the last pipeline slot to free performs the release.
|
|
202
|
+
this.maybeReleaseTurn();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private resetPipeline(): void {
|
|
206
|
+
this.pendingTexts = [];
|
|
207
|
+
this.pipelineDepth = 0;
|
|
208
|
+
this.pipelineGeneration++;
|
|
209
|
+
this.completedTurnId = null;
|
|
121
210
|
}
|
|
122
211
|
|
|
123
212
|
private startTimer(): void {
|
|
124
213
|
this.stopTimer();
|
|
125
214
|
this.timer = this.setIntervalImpl(() => {
|
|
126
215
|
if (!this.activeTurnId || !this.chunker) return;
|
|
127
|
-
this.
|
|
216
|
+
this.queueTexts(this.chunker.flushDue());
|
|
128
217
|
}, 250);
|
|
129
218
|
}
|
|
130
219
|
|
|
@@ -134,45 +223,163 @@ export class SpokenTurnController {
|
|
|
134
223
|
this.timer = null;
|
|
135
224
|
}
|
|
136
225
|
|
|
137
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Chunker output does NOT map 1:1 to synthesis requests. Text queues here
|
|
228
|
+
* and the pump merges everything pending into one request whenever a
|
|
229
|
+
* pipeline slot is free — so the request count tracks how often the model
|
|
230
|
+
* out-paces the audio, not how many sentences it wrote. A short answer that
|
|
231
|
+
* arrives before the first pump tick is exactly one request.
|
|
232
|
+
*/
|
|
233
|
+
private queueTexts(chunks: readonly string[]): void {
|
|
138
234
|
for (const chunk of chunks) {
|
|
139
|
-
this.
|
|
235
|
+
if (chunk.trim()) this.pendingTexts.push(chunk);
|
|
236
|
+
}
|
|
237
|
+
if (this.pendingTexts.length > 0) this.schedulePump();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Deferred one tick so text delivered in the same synchronous burst (fast
|
|
242
|
+
* deltas, or a turn that completes instantly) coalesces into a single
|
|
243
|
+
* request instead of firing per sentence boundary.
|
|
244
|
+
*/
|
|
245
|
+
private schedulePump(): void {
|
|
246
|
+
if (this.pumpScheduled) return;
|
|
247
|
+
this.pumpScheduled = true;
|
|
248
|
+
queueMicrotask(() => {
|
|
249
|
+
this.pumpScheduled = false;
|
|
250
|
+
this.pump();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private pump(): void {
|
|
255
|
+
while (this.activeTurnId && this.pendingTexts.length > 0 && this.pipelineDepth < SYNTHESIS_PIPELINE_WINDOW) {
|
|
256
|
+
this.dispatchChunk(this.takeMergedText());
|
|
140
257
|
}
|
|
258
|
+
this.maybeReleaseTurn();
|
|
141
259
|
}
|
|
142
260
|
|
|
143
|
-
|
|
261
|
+
/** Merge everything pending into one request, capped at the per-request text limit. */
|
|
262
|
+
private takeMergedText(): string {
|
|
263
|
+
let merged = '';
|
|
264
|
+
while (this.pendingTexts.length > 0) {
|
|
265
|
+
const next = this.pendingTexts[0]!;
|
|
266
|
+
if (!merged && next.length > SYNTHESIS_MERGE_MAX_CHARS) {
|
|
267
|
+
// A single oversized entry (e.g. a large end-of-turn flush): split at
|
|
268
|
+
// a word boundary under the per-request cap; the rest stays queued.
|
|
269
|
+
const cut = findSplitIndex(next, SYNTHESIS_MERGE_MAX_CHARS);
|
|
270
|
+
this.pendingTexts[0] = next.slice(cut).trim();
|
|
271
|
+
return next.slice(0, cut).trim();
|
|
272
|
+
}
|
|
273
|
+
if (merged && merged.length + 1 + next.length > SYNTHESIS_MERGE_MAX_CHARS) break;
|
|
274
|
+
merged = merged ? `${merged} ${next}` : next;
|
|
275
|
+
this.pendingTexts.shift();
|
|
276
|
+
}
|
|
277
|
+
return merged;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private dispatchChunk(text: string): void {
|
|
144
281
|
const turnId = this.activeTurnId;
|
|
145
282
|
if (!turnId || !text.trim()) return;
|
|
146
283
|
const sequence = ++this.chunkSequence;
|
|
284
|
+
const generation = this.pipelineGeneration;
|
|
285
|
+
this.pipelineDepth++;
|
|
147
286
|
const abortController = new AbortController();
|
|
148
287
|
this.abortControllers.add(abortController);
|
|
149
|
-
const resultPromise = this.
|
|
288
|
+
const resultPromise = this.synthesizeWithRetry(text, turnId, sequence, abortController.signal)
|
|
150
289
|
.then((result) => ({ ok: true as const, result }))
|
|
151
290
|
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
152
291
|
|
|
153
292
|
this.playbackChain = this.playbackChain.then(async () => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
293
|
+
try {
|
|
294
|
+
if (abortController.signal.aborted) {
|
|
295
|
+
this.abortControllers.delete(abortController);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const result = await resultPromise;
|
|
299
|
+
this.abortControllers.delete(abortController);
|
|
300
|
+
// Re-check after the await: an abort that landed while synthesis was
|
|
301
|
+
// in flight (deliberate stop or exit) makes the rejection expected —
|
|
302
|
+
// it must not be reported, and it must not hard-stop a sink that may
|
|
303
|
+
// still be draining the previous chunk.
|
|
304
|
+
if (abortController.signal.aborted) return;
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
// Retries are exhausted (or the failure was not transient). Skip
|
|
307
|
+
// just this chunk and keep speaking the rest of the turn — a gap in
|
|
308
|
+
// speech beats losing the whole response.
|
|
309
|
+
this.reportSkippedChunk(result.error);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
await this.player.play(result.result.chunks, {
|
|
313
|
+
format: String(result.result.format ?? 'mp3'),
|
|
314
|
+
signal: abortController.signal,
|
|
315
|
+
});
|
|
316
|
+
} finally {
|
|
317
|
+
this.releasePipelineSlot(generation);
|
|
160
318
|
}
|
|
161
|
-
await this.player.play(result.result.chunks, {
|
|
162
|
-
format: String(result.result.format ?? 'mp3'),
|
|
163
|
-
signal: abortController.signal,
|
|
164
|
-
});
|
|
165
319
|
}).catch((error: unknown) => {
|
|
166
320
|
this.abortControllers.delete(abortController);
|
|
167
321
|
this.reportError(error);
|
|
168
322
|
});
|
|
169
323
|
}
|
|
170
324
|
|
|
325
|
+
private releasePipelineSlot(generation: number): void {
|
|
326
|
+
if (generation !== this.pipelineGeneration) return;
|
|
327
|
+
this.pipelineDepth = Math.max(0, this.pipelineDepth - 1);
|
|
328
|
+
if (this.pendingTexts.length > 0) {
|
|
329
|
+
this.schedulePump();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.maybeReleaseTurn();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private maybeReleaseTurn(): void {
|
|
336
|
+
if (!this.completedTurnId || this.completedTurnId !== this.activeTurnId) return;
|
|
337
|
+
if (this.pendingTexts.length > 0 || this.pipelineDepth > 0 || this.pumpScheduled) return;
|
|
338
|
+
this.activeTurnId = null;
|
|
339
|
+
this.completedTurnId = null;
|
|
340
|
+
this.chunker = null;
|
|
341
|
+
this.abortControllers.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async synthesizeWithRetry(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
345
|
+
for (let attempt = 0; ; attempt++) {
|
|
346
|
+
try {
|
|
347
|
+
return await this.synthesize(text, turnId, sequence, signal);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const retryable = attempt < SYNTHESIS_RETRY_DELAYS_MS.length
|
|
350
|
+
&& !signal.aborted
|
|
351
|
+
&& isTransientSynthesisError(error);
|
|
352
|
+
if (!retryable) throw error;
|
|
353
|
+
await this.delay(SYNTHESIS_RETRY_DELAYS_MS[attempt]!, signal);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Abortable backoff sleep — an abort clears the timer and rejects, so a stop mid-backoff leaves nothing running. */
|
|
359
|
+
private delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
if (signal.aborted) {
|
|
362
|
+
reject(new Error('Synthesis retry cancelled'));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const timer = this.setTimeoutImpl(() => {
|
|
366
|
+
signal.removeEventListener('abort', onAbort);
|
|
367
|
+
resolve();
|
|
368
|
+
}, ms);
|
|
369
|
+
const onAbort = () => {
|
|
370
|
+
this.clearTimeoutImpl(timer);
|
|
371
|
+
reject(new Error('Synthesis retry cancelled'));
|
|
372
|
+
};
|
|
373
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
171
377
|
private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
172
378
|
return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
|
|
173
379
|
text,
|
|
174
380
|
voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
|
|
175
381
|
format: 'mp3',
|
|
382
|
+
speed: readOptionalConfigNumber(this.configManager, 'tts.speed'),
|
|
176
383
|
signal,
|
|
177
384
|
metadata: {
|
|
178
385
|
source: 'goodvibes-agent',
|
|
@@ -183,12 +390,23 @@ export class SpokenTurnController {
|
|
|
183
390
|
});
|
|
184
391
|
}
|
|
185
392
|
|
|
393
|
+
/**
|
|
394
|
+
* One synthesis request failed after its retries. Report once per turn and
|
|
395
|
+
* keep going — the rest of the response still plays.
|
|
396
|
+
*/
|
|
397
|
+
private reportSkippedChunk(error: unknown): void {
|
|
398
|
+
if (this.errorReportedForTurn) return;
|
|
399
|
+
this.errorReportedForTurn = true;
|
|
400
|
+
this.notify?.(`[TTS] Skipping part of the spoken response — synthesis kept failing (${summarizeError(error)}). Playback continues with the rest.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
186
403
|
private reportError(error: unknown): void {
|
|
187
404
|
if (this.errorReportedForTurn) return;
|
|
188
405
|
this.errorReportedForTurn = true;
|
|
189
406
|
this.activeTurnId = null;
|
|
190
407
|
this.chunker = null;
|
|
191
408
|
this.stopTimer();
|
|
409
|
+
this.resetPipeline();
|
|
192
410
|
for (const controller of this.abortControllers) controller.abort();
|
|
193
411
|
this.abortControllers.clear();
|
|
194
412
|
this.player.stop();
|
|
@@ -197,7 +415,41 @@ export class SpokenTurnController {
|
|
|
197
415
|
}
|
|
198
416
|
}
|
|
199
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Transient = worth a bounded retry: rate/concurrency limits (HTTP 429),
|
|
420
|
+
* transient server errors (5xx), and network-level drops. The SDK's voice
|
|
421
|
+
* providers throw plain Error strings with the HTTP status embedded in the
|
|
422
|
+
* message, so classification is by message content.
|
|
423
|
+
*/
|
|
424
|
+
function isTransientSynthesisError(error: unknown): boolean {
|
|
425
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
426
|
+
if (message.includes('429') || message.includes('rate limit') || message.includes('rate_limit')
|
|
427
|
+
|| message.includes('too many requests') || message.includes('concurrent')) return true;
|
|
428
|
+
if (/http 5\d\d/.test(message)) return true;
|
|
429
|
+
return message.includes('fetch failed') || message.includes('network')
|
|
430
|
+
|| message.includes('timed out') || message.includes('timeout')
|
|
431
|
+
|| message.includes('econnreset') || message.includes('socket');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Split point at or under `limit`, preferring the last word boundary. */
|
|
435
|
+
function findSplitIndex(text: string, limit: number): number {
|
|
436
|
+
const space = text.lastIndexOf(' ', limit);
|
|
437
|
+
return space > 0 ? space : limit;
|
|
438
|
+
}
|
|
439
|
+
|
|
200
440
|
function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
|
|
201
441
|
const value = String(configManager.get(key) ?? '').trim();
|
|
202
442
|
return value || undefined;
|
|
203
443
|
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* readOptionalConfigNumber — reads a numeric config value by key.
|
|
447
|
+
*
|
|
448
|
+
* Accepts a string key and casts it, returning undefined when the value is
|
|
449
|
+
* absent, zero, or not a finite positive number.
|
|
450
|
+
*/
|
|
451
|
+
function readOptionalConfigNumber(configManager: Pick<ConfigManager, 'get'>, key: string): number | undefined {
|
|
452
|
+
const raw = configManager.get(key as ConfigKey);
|
|
453
|
+
const value = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
454
|
+
return isFinite(value) && value > 0 ? value : undefined;
|
|
455
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
2
|
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
3
3
|
import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
|
|
4
|
+
import type { StreamingAudioPlayer } from './player.ts';
|
|
4
5
|
import { LocalStreamingAudioPlayer } from './player.ts';
|
|
5
6
|
import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
6
7
|
|
|
7
8
|
export interface SpokenTurnRuntime {
|
|
8
9
|
readonly unsubs: readonly (() => void)[];
|
|
9
10
|
submitNextTurn(prompt: string): boolean;
|
|
10
|
-
|
|
11
|
+
/** Returns whether speech was actually active (see controller.stop). */
|
|
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>;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export interface WireSpokenTurnRuntimeOptions {
|
|
@@ -15,13 +19,19 @@ export interface WireSpokenTurnRuntimeOptions {
|
|
|
15
19
|
readonly configManager: ConfigManager;
|
|
16
20
|
readonly events: UiRuntimeEvents;
|
|
17
21
|
readonly notify: (message: string) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Optional player factory — injected in tests to avoid spawning real
|
|
24
|
+
* subprocesses. Defaults to LocalStreamingAudioPlayer.
|
|
25
|
+
*/
|
|
26
|
+
readonly playerFactory?: () => StreamingAudioPlayer;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
|
|
30
|
+
const player = options.playerFactory ? options.playerFactory() : new LocalStreamingAudioPlayer();
|
|
21
31
|
const controller = new SpokenTurnController({
|
|
22
32
|
voiceService: options.voiceService,
|
|
23
33
|
configManager: options.configManager,
|
|
24
|
-
player
|
|
34
|
+
player,
|
|
25
35
|
notify: options.notify,
|
|
26
36
|
});
|
|
27
37
|
|
|
@@ -40,5 +50,6 @@ export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): Sp
|
|
|
40
50
|
unsubs,
|
|
41
51
|
submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
|
|
42
52
|
stop: (message) => controller.stop(message),
|
|
53
|
+
stopForExit: (drainTimeoutMs) => controller.stopForExit(drainTimeoutMs),
|
|
43
54
|
};
|
|
44
55
|
}
|
|
@@ -7,7 +7,7 @@ import type { CliCommandRuntime } from './management.ts';
|
|
|
7
7
|
/**
|
|
8
8
|
* Shared prelude for the Agent-local library CLI commands (personas, skills,
|
|
9
9
|
* skill bundles): option parsing, success/failure envelopes, and registry
|
|
10
|
-
* accessors. Split out of local-library-command.ts (
|
|
10
|
+
* accessors. Split out of local-library-command.ts (the file
|
|
11
11
|
* cleanly contained three independent command handlers glued together) so
|
|
12
12
|
* personas-command.ts, skills-command.ts, and skill-bundle-command.ts can
|
|
13
13
|
* share one option-parsing/output prelude without duplicating it.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
|
-
import { createShellPathService } from '@/runtime/index.ts';
|
|
4
3
|
import {
|
|
5
4
|
MemoryEmbeddingProviderRegistry,
|
|
6
5
|
MemoryRegistry,
|
|
7
6
|
MemoryStore,
|
|
7
|
+
resolveCanonicalMemoryDbPath,
|
|
8
8
|
type MemoryBundle,
|
|
9
9
|
type MemoryClass,
|
|
10
10
|
type MemoryLink,
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
} from '@pellux/goodvibes-sdk/platform/state';
|
|
19
19
|
import { assertNoSecretLikeMemoryText } from '../agent/memory-safety.ts';
|
|
20
20
|
import { formatAgentRecordReference, formatAgentRecordReviewState } from '../agent/record-labels.ts';
|
|
21
|
-
import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
|
|
22
21
|
import type { CliCommandOutput } from './types.ts';
|
|
23
22
|
import type { CliCommandRuntime } from './management.ts';
|
|
24
23
|
|
|
@@ -196,11 +195,15 @@ function timestamp(value: number): string {
|
|
|
196
195
|
return new Date(value).toISOString().slice(0, 19).replace('T', ' ');
|
|
197
196
|
}
|
|
198
197
|
|
|
198
|
+
// The CLI no longer opens a private per-surface agent/memory.sqlite. It opens the
|
|
199
|
+
// ONE canonical cross-surface store (~/.goodvibes/shared/memory.sqlite) so a memory
|
|
200
|
+
// added via `goodvibes-agent memory add` is visible to the runtime, the TUI, and the
|
|
201
|
+
// SDK — and vice-versa. The old CLI-written store is folded into the canonical store
|
|
202
|
+
// (loss-free, idempotent) by the runtime's foldAgentLegacyMemory at boot, since that
|
|
203
|
+
// fold already sources shellPaths.resolveUserPath('agent', 'memory.sqlite') — the exact
|
|
204
|
+
// path this function used to write to.
|
|
199
205
|
function memoryDbPath(runtime: CliCommandRuntime): string {
|
|
200
|
-
return
|
|
201
|
-
workingDirectory: runtime.workingDirectory,
|
|
202
|
-
homeDirectory: runtime.homeDirectory,
|
|
203
|
-
}).resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'memory.sqlite');
|
|
206
|
+
return resolveCanonicalMemoryDbPath(runtime.homeDirectory);
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
async function withMemory<T>(runtime: CliCommandRuntime, fn: (context: MemoryContext) => Promise<T> | T): Promise<T> {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* RESUME-ON-RELAUNCH
|
|
4
|
+
* RESUME-ON-RELAUNCH.
|
|
5
5
|
*
|
|
6
6
|
* The dogfood finding: relaunching the agent after a normal (non-crash) exit
|
|
7
7
|
* silently starts fresh with no offer or notice about the prior session — the
|
package/src/cli/tui-startup.ts
CHANGED
|
@@ -116,7 +116,7 @@ export function applyInitialTuiCliState(options: {
|
|
|
116
116
|
// Normal relaunch: onboarding is done and the user didn't ask for
|
|
117
117
|
// onboarding or an explicit `sessions resume`. Surface an honest,
|
|
118
118
|
// non-blocking resume affordance instead of silently starting fresh
|
|
119
|
-
// (
|
|
119
|
+
// (a dogfood finding) — never auto-resume, declining is
|
|
120
120
|
// frictionless (just start typing).
|
|
121
121
|
surfaceResumeRelaunchNotice({
|
|
122
122
|
getLastSessionPointer: () => readLastSessionPointer({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* credential-status.ts — client-side, secret-FREE credential STATUS read.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* When GoodVibes Agent acts as a CLIENT of an adopted
|
|
5
5
|
* external daemon, it reads credential *status* (configured / usable) from the
|
|
6
6
|
* daemon's shared store over the wire — the `credentials.get` operator method
|
|
7
7
|
* (GET /config/credentials, admin + read:config). It NEVER receives raw secret
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
59
|
+
// The daemon-client credential STATUS read (secret-free, honest-degrade).
|
|
60
60
|
// Value reads above stay local/env; only the status VISIBILITY path moves to the daemon.
|
|
61
61
|
export {
|
|
62
62
|
deriveCredentialAvailability,
|
package/src/config/secrets.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
|
|
17
17
|
import { GOODVIBES_AGENT_SURFACE_ROOT } from './surface.ts';
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Host-vs-client split: this SecretsManager is the LOCAL-HOST
|
|
20
20
|
// read path — pinned to GOODVIBES_AGENT_SURFACE_ROOT, it resolves secret VALUES from the
|
|
21
21
|
// surface store/env for provider auth, unchanged. When the Agent acts as a CLIENT of an
|
|
22
22
|
// adopted external daemon, credential *status* (configured/usable — never bytes) is read
|
|
@@ -123,8 +123,8 @@ export function renderConversationAssistantMessage(
|
|
|
123
123
|
// `numWidth=6` (fits 999,999 lines, but wastes 3-4 gutter columns on typical
|
|
124
124
|
// messages) or rendering the numbered output into a scratch buffer and trimming.
|
|
125
125
|
// Neither is clearly better than the current two-pass measurement approach.
|
|
126
|
-
//
|
|
127
|
-
// numbers are enabled" was inaccurate:
|
|
126
|
+
// An earlier commit message's claim that this "eliminates double-parse when line
|
|
127
|
+
// numbers are enabled" was inaccurate: that commit eliminated the legacy
|
|
128
128
|
// `renderMarkdown()` duplicate used for code-block line-number mode ('code').
|
|
129
129
|
// The 'all' mode double-call is a deliberate design choice and remains unchanged.
|
|
130
130
|
const measureWidth = showAllLineNumbers ? width : 0;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Ported from the TUI (src/core/system-message-noise.ts). The TUI regexes are
|
|
10
10
|
* kept VERBATIM (provider-replay fold / agents-snapshot drop / stale-replay
|
|
11
11
|
* drop); the only agent-specific addition is the `[Terminal] Captured …` rule
|
|
12
|
-
* (
|
|
12
|
+
* (the stdout-capture leak). Dropped noise is
|
|
13
13
|
* drop-from-the-feed, NOT delete: the captured-write detail stays reachable via
|
|
14
14
|
* the activity log (the terminal-output guard logs each intercept + the count).
|
|
15
15
|
*/
|
|
@@ -46,7 +46,7 @@ const REPLAY_CHAIN_RE = /WRFC chain (\S+) transitioned .* waiting for action/;
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Matches the terminal-output guard's aggregate captured-write notice
|
|
49
|
-
* (
|
|
49
|
+
* (agent-specific). First-run plumbing writes a burst of direct stdout
|
|
50
50
|
* that the guard intercepts and would otherwise summarize into the Recent feed;
|
|
51
51
|
* the count stays reachable in the activity log, so the feed copy is dropped.
|
|
52
52
|
*/
|
|
@@ -57,7 +57,7 @@ const TERMINAL_CAPTURED_RE = /^\[Terminal\] Captured \d+ direct /;
|
|
|
57
57
|
* lookup, so it is trivially testable.
|
|
58
58
|
*/
|
|
59
59
|
export function classifyNoise(message: string, deps: NoiseGateDeps): NoiseVerdict {
|
|
60
|
-
//
|
|
60
|
+
// Agent-specific — the terminal-output guard's aggregate captured-write notice.
|
|
61
61
|
// Kept out of the Recent feed; the detail stays in the activity log.
|
|
62
62
|
if (TERMINAL_CAPTURED_RE.test(message)) {
|
|
63
63
|
return DROP;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* thinking-overlay.ts
|
|
2
|
+
* thinking-overlay.ts — the thinking-indicator overlay + its honest
|
|
3
3
|
* stall clock, extracted from main.ts's render loop.
|
|
4
4
|
*
|
|
5
5
|
* The SDK orchestrator surfaces no lastDeltaAtMs / reconnect signal directly, so
|
|
@@ -42,7 +42,7 @@ function unconfirmed(editor: AgentWorkspaceLocalEditor, message: string, status:
|
|
|
42
42
|
/**
|
|
43
43
|
* The submission data table for the kinds this module builds itself (delegate-task and
|
|
44
44
|
* workplan/notify/secret/profile kinds keep dispatching to their own sibling submission
|
|
45
|
-
* modules below, unchanged).
|
|
45
|
+
* modules below, unchanged). Split out of the single ~570-line
|
|
46
46
|
* `buildAgentWorkspaceBasicCommandEditorSubmission` if-chain, mirroring the
|
|
47
47
|
* construction-side split in agent-workspace-basic-command-editors.ts.
|
|
48
48
|
*/
|
|
@@ -127,7 +127,7 @@ function modelPinUnpinSpec(kind: AgentWorkspaceBasicOwnCommandEditorKind): Agent
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* The field-spec data table for the kinds this module builds itself (the rest are
|
|
130
|
-
* delegated to sibling domain modules above).
|
|
130
|
+
* delegated to sibling domain modules above). Split out of the single
|
|
131
131
|
* ~600-line `createAgentWorkspaceBasicCommandEditor` if-chain into a data table, the
|
|
132
132
|
* same shape used by every other agent-workspace command-editor domain.
|
|
133
133
|
*/
|