@pellux/goodvibes-tui 0.19.30 → 0.19.32
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 +24 -0
- package/README.md +12 -3
- package/docs/foundation-artifacts/operator-contract.json +778 -494
- package/package.json +2 -2
- package/src/audio/player.ts +156 -0
- package/src/audio/spoken-turn-controller.ts +200 -0
- package/src/audio/spoken-turn-wiring.ts +44 -0
- package/src/audio/text-chunker.ts +110 -0
- package/src/cli/management.ts +26 -3
- package/src/cli/provider-auth-routes.ts +22 -0
- package/src/input/command-registry.ts +5 -0
- package/src/input/commands/services-runtime.ts +1 -0
- package/src/input/commands/tts-runtime.ts +136 -0
- package/src/input/commands.ts +2 -0
- package/src/input/handler-onboarding.ts +12 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +25 -2
- package/src/input/onboarding/onboarding-wizard-types.ts +2 -0
- package/src/main.ts +19 -29
- package/src/panels/services-panel.ts +2 -0
- package/src/renderer/onboarding/onboarding-wizard.ts +38 -14
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +6 -1
- package/src/runtime/bootstrap-command-parts.ts +6 -1
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/runtime/onboarding/derivation.ts +1 -1
- package/src/runtime/onboarding/snapshot.ts +2 -0
- package/src/runtime/onboarding/types.ts +1 -0
- package/src/runtime/services.ts +1 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.32",
|
|
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",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
92
92
|
"@ast-grep/napi": "^0.42.0",
|
|
93
93
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
94
|
-
"@pellux/goodvibes-sdk": "
|
|
94
|
+
"@pellux/goodvibes-sdk": "0.25.8",
|
|
95
95
|
"bash-language-server": "^5.6.0",
|
|
96
96
|
"fuse.js": "^7.1.0",
|
|
97
97
|
"graphql": "^16.13.2",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import type { Writable } from 'node:stream';
|
|
5
|
+
import type { VoiceAudioChunk } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
6
|
+
|
|
7
|
+
export interface StreamingAudioPlayerCommand {
|
|
8
|
+
readonly command: string;
|
|
9
|
+
readonly args: readonly string[];
|
|
10
|
+
readonly label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StreamingAudioPlayer {
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly available: boolean;
|
|
16
|
+
play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions): Promise<void>;
|
|
17
|
+
stop(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StreamingAudioPlaybackOptions {
|
|
21
|
+
readonly format?: string;
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SpawnProcess {
|
|
26
|
+
readonly stdin: Writable;
|
|
27
|
+
once(event: 'close', listener: () => void): this;
|
|
28
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
29
|
+
}
|
|
30
|
+
type SpawnProcessFactory = (command: string, args: readonly string[]) => SpawnProcess;
|
|
31
|
+
|
|
32
|
+
export interface LocalStreamingAudioPlayerOptions {
|
|
33
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
34
|
+
readonly spawnProcess?: SpawnProcessFactory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class LocalStreamingAudioPlayer implements StreamingAudioPlayer {
|
|
38
|
+
readonly command: StreamingAudioPlayerCommand | null;
|
|
39
|
+
private activeProcess: SpawnProcess | null = null;
|
|
40
|
+
private readonly spawnProcess: SpawnProcessFactory;
|
|
41
|
+
|
|
42
|
+
constructor(options: LocalStreamingAudioPlayerOptions = {}) {
|
|
43
|
+
this.command = resolveStreamingAudioPlayerCommand(options.env ?? process.env);
|
|
44
|
+
this.spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get label(): string {
|
|
48
|
+
return this.command?.label ?? 'no streaming audio player found';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get available(): boolean {
|
|
52
|
+
return this.command !== null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async play(chunks: AsyncIterable<VoiceAudioChunk>, options: StreamingAudioPlaybackOptions = {}): Promise<void> {
|
|
56
|
+
if (!this.command) {
|
|
57
|
+
throw new Error('No streaming audio player found. Install mpv or ffplay to use /tts live playback.');
|
|
58
|
+
}
|
|
59
|
+
if (options.signal?.aborted) return;
|
|
60
|
+
|
|
61
|
+
const proc = this.spawnProcess(this.command.command, buildPlayerArgs(this.command, options.format));
|
|
62
|
+
this.activeProcess = proc;
|
|
63
|
+
const abort = () => {
|
|
64
|
+
try { proc.stdin.destroy(); } catch { /* ignore */ }
|
|
65
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
66
|
+
};
|
|
67
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
for await (const chunk of chunks) {
|
|
71
|
+
if (options.signal?.aborted) break;
|
|
72
|
+
if (chunk.data.byteLength === 0) continue;
|
|
73
|
+
await writeStdin(proc, chunk.data);
|
|
74
|
+
}
|
|
75
|
+
proc.stdin.end();
|
|
76
|
+
await waitForExit(proc);
|
|
77
|
+
} finally {
|
|
78
|
+
options.signal?.removeEventListener('abort', abort);
|
|
79
|
+
if (this.activeProcess === proc) this.activeProcess = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
stop(): void {
|
|
84
|
+
const proc = this.activeProcess;
|
|
85
|
+
this.activeProcess = null;
|
|
86
|
+
if (!proc) return;
|
|
87
|
+
try { proc.stdin.destroy(); } catch { /* ignore */ }
|
|
88
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveStreamingAudioPlayerCommand(env: NodeJS.ProcessEnv = process.env): StreamingAudioPlayerCommand | null {
|
|
93
|
+
const mpv = findExecutable('mpv', env);
|
|
94
|
+
if (mpv) {
|
|
95
|
+
return {
|
|
96
|
+
command: mpv,
|
|
97
|
+
args: ['--no-terminal', '--really-quiet', '--force-window=no', '--cache=no', '-'],
|
|
98
|
+
label: 'mpv',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const ffplay = findExecutable('ffplay', env);
|
|
102
|
+
if (ffplay) {
|
|
103
|
+
return {
|
|
104
|
+
command: ffplay,
|
|
105
|
+
args: ['-nodisp', '-autoexit', '-loglevel', 'error', '-i', 'pipe:0'],
|
|
106
|
+
label: 'ffplay',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildPlayerArgs(command: StreamingAudioPlayerCommand, format?: string): readonly string[] {
|
|
113
|
+
if (command.label !== 'ffplay' || !format) return command.args;
|
|
114
|
+
const normalized = format.trim().toLowerCase();
|
|
115
|
+
if (!normalized || normalized.includes('/')) return command.args;
|
|
116
|
+
return ['-nodisp', '-autoexit', '-loglevel', 'error', '-f', normalized, '-i', 'pipe:0'];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findExecutable(name: string, env: NodeJS.ProcessEnv): string | null {
|
|
120
|
+
const pathValue = env.PATH ?? '';
|
|
121
|
+
const extensions = process.platform === 'win32'
|
|
122
|
+
? ['', '.exe', '.cmd', '.bat']
|
|
123
|
+
: [''];
|
|
124
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
125
|
+
if (!dir) continue;
|
|
126
|
+
for (const ext of extensions) {
|
|
127
|
+
const candidate = join(dir, `${name}${ext}`);
|
|
128
|
+
try {
|
|
129
|
+
accessSync(candidate, constants.X_OK);
|
|
130
|
+
return candidate;
|
|
131
|
+
} catch {
|
|
132
|
+
// Keep scanning PATH.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function defaultSpawnProcess(command: string, args: readonly string[]): SpawnProcess {
|
|
140
|
+
return spawn(command, [...args], { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeStdin(proc: SpawnProcess, data: Uint8Array): Promise<void> {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
proc.stdin.write(Buffer.from(data), (error) => {
|
|
146
|
+
if (error) reject(error);
|
|
147
|
+
else resolve();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function waitForExit(proc: SpawnProcess): Promise<void> {
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
proc.once('close', () => resolve());
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config/schema';
|
|
3
|
+
import type { TurnEvent } from '@pellux/goodvibes-sdk/platform/runtime/events/index';
|
|
4
|
+
import type { VoiceService, VoiceSynthesisStreamResult } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
5
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
|
|
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 readonly voiceService: Pick<VoiceService, 'synthesizeStream'>;
|
|
29
|
+
private readonly configManager: Pick<ConfigManager, 'get'>;
|
|
30
|
+
private readonly player: StreamingAudioPlayer;
|
|
31
|
+
private readonly notify?: (message: string) => void;
|
|
32
|
+
private readonly now: () => number;
|
|
33
|
+
private readonly setIntervalImpl: typeof setInterval;
|
|
34
|
+
private readonly clearIntervalImpl: typeof clearInterval;
|
|
35
|
+
|
|
36
|
+
constructor(options: SpokenTurnControllerOptions) {
|
|
37
|
+
this.voiceService = options.voiceService;
|
|
38
|
+
this.configManager = options.configManager;
|
|
39
|
+
this.player = options.player;
|
|
40
|
+
this.notify = options.notify;
|
|
41
|
+
this.now = options.now ?? (() => Date.now());
|
|
42
|
+
this.setIntervalImpl = options.setInterval ?? setInterval;
|
|
43
|
+
this.clearIntervalImpl = options.clearInterval ?? clearInterval;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
submitNextTurn(prompt: string): boolean {
|
|
47
|
+
const normalized = prompt.trim();
|
|
48
|
+
if (!normalized) return false;
|
|
49
|
+
this.stop();
|
|
50
|
+
if (!this.player.available) {
|
|
51
|
+
this.notify?.('[TTS] Text response will continue, but live audio is unavailable. Install mpv or ffplay.');
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
this.pendingPrompt = normalized;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(message?: string): void {
|
|
59
|
+
this.pendingPrompt = null;
|
|
60
|
+
this.activeTurnId = null;
|
|
61
|
+
this.chunker?.reset();
|
|
62
|
+
this.chunker = null;
|
|
63
|
+
this.stopTimer();
|
|
64
|
+
for (const controller of this.abortControllers) controller.abort();
|
|
65
|
+
this.abortControllers.clear();
|
|
66
|
+
this.player.stop();
|
|
67
|
+
this.playbackChain = Promise.resolve();
|
|
68
|
+
this.errorReportedForTurn = false;
|
|
69
|
+
if (message) this.notify?.(`[TTS] ${message}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleTurnEvent(event: TurnEvent): void {
|
|
73
|
+
if (event.type === 'TURN_SUBMITTED') {
|
|
74
|
+
this.maybeStartTurn(event.turnId, event.prompt);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!this.activeTurnId || event.turnId !== this.activeTurnId) return;
|
|
78
|
+
|
|
79
|
+
if (event.type === 'STREAM_DELTA') {
|
|
80
|
+
this.enqueueChunks(this.chunker?.push(event.content) ?? []);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (event.type === 'STREAM_END' || event.type === 'TURN_COMPLETED') {
|
|
84
|
+
this.finishTurn(event.turnId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.type === 'TURN_CANCEL' || event.type === 'TURN_ERROR' || event.type === 'PREFLIGHT_FAIL') {
|
|
88
|
+
this.stop(event.type === 'TURN_CANCEL' ? 'Spoken output stopped.' : 'Spoken output stopped because the turn did not complete.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private maybeStartTurn(turnId: string, prompt: string): void {
|
|
93
|
+
if (!this.pendingPrompt) return;
|
|
94
|
+
if (prompt.trim() !== this.pendingPrompt) return;
|
|
95
|
+
this.pendingPrompt = null;
|
|
96
|
+
this.activeTurnId = turnId;
|
|
97
|
+
this.chunkSequence = 0;
|
|
98
|
+
this.errorReportedForTurn = false;
|
|
99
|
+
this.chunker = new TtsTextChunker({ now: this.now });
|
|
100
|
+
this.playbackChain = Promise.resolve();
|
|
101
|
+
this.startTimer();
|
|
102
|
+
this.notify?.(`[TTS] Live playback queued through ${this.player.label}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private finishTurn(turnId: string): void {
|
|
106
|
+
if (turnId !== this.activeTurnId) return;
|
|
107
|
+
this.enqueueChunks(this.chunker?.flushAll() ?? []);
|
|
108
|
+
this.stopTimer();
|
|
109
|
+
const chain = this.playbackChain;
|
|
110
|
+
chain.finally(() => {
|
|
111
|
+
if (this.activeTurnId !== turnId) return;
|
|
112
|
+
this.activeTurnId = null;
|
|
113
|
+
this.chunker = null;
|
|
114
|
+
this.abortControllers.clear();
|
|
115
|
+
}).catch(() => {
|
|
116
|
+
// Errors are already reported in the queued task.
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private startTimer(): void {
|
|
121
|
+
this.stopTimer();
|
|
122
|
+
this.timer = this.setIntervalImpl(() => {
|
|
123
|
+
if (!this.activeTurnId || !this.chunker) return;
|
|
124
|
+
this.enqueueChunks(this.chunker.flushDue());
|
|
125
|
+
}, 250);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private stopTimer(): void {
|
|
129
|
+
if (!this.timer) return;
|
|
130
|
+
this.clearIntervalImpl(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private enqueueChunks(chunks: readonly string[]): void {
|
|
135
|
+
for (const chunk of chunks) {
|
|
136
|
+
this.enqueueChunk(chunk);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private enqueueChunk(text: string): void {
|
|
141
|
+
const turnId = this.activeTurnId;
|
|
142
|
+
if (!turnId || !text.trim()) return;
|
|
143
|
+
const sequence = ++this.chunkSequence;
|
|
144
|
+
const abortController = new AbortController();
|
|
145
|
+
this.abortControllers.add(abortController);
|
|
146
|
+
const resultPromise = this.synthesize(text, turnId, sequence, abortController.signal)
|
|
147
|
+
.then((result) => ({ ok: true as const, result }))
|
|
148
|
+
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
149
|
+
|
|
150
|
+
this.playbackChain = this.playbackChain.then(async () => {
|
|
151
|
+
if (abortController.signal.aborted) return;
|
|
152
|
+
const result = await resultPromise;
|
|
153
|
+
this.abortControllers.delete(abortController);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
this.reportError(result.error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await this.player.play(result.result.chunks, {
|
|
159
|
+
format: String(result.result.format ?? 'mp3'),
|
|
160
|
+
signal: abortController.signal,
|
|
161
|
+
});
|
|
162
|
+
}).catch((error: unknown) => {
|
|
163
|
+
this.abortControllers.delete(abortController);
|
|
164
|
+
this.reportError(error);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private synthesize(text: string, turnId: string, sequence: number, signal: AbortSignal): Promise<VoiceSynthesisStreamResult> {
|
|
169
|
+
return this.voiceService.synthesizeStream(readOptionalConfigString(this.configManager, 'tts.provider'), {
|
|
170
|
+
text,
|
|
171
|
+
voiceId: readOptionalConfigString(this.configManager, 'tts.voice'),
|
|
172
|
+
format: 'mp3',
|
|
173
|
+
signal,
|
|
174
|
+
metadata: {
|
|
175
|
+
source: 'goodvibes-tui',
|
|
176
|
+
feature: 'live-tts',
|
|
177
|
+
turnId,
|
|
178
|
+
sequence,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private reportError(error: unknown): void {
|
|
184
|
+
if (this.errorReportedForTurn) return;
|
|
185
|
+
this.errorReportedForTurn = true;
|
|
186
|
+
this.activeTurnId = null;
|
|
187
|
+
this.chunker = null;
|
|
188
|
+
this.stopTimer();
|
|
189
|
+
for (const controller of this.abortControllers) controller.abort();
|
|
190
|
+
this.abortControllers.clear();
|
|
191
|
+
this.player.stop();
|
|
192
|
+
this.playbackChain = Promise.resolve();
|
|
193
|
+
this.notify?.(`[TTS] Live playback stopped: ${summarizeError(error)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readOptionalConfigString(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string | undefined {
|
|
198
|
+
const value = String(configManager.get(key) ?? '').trim();
|
|
199
|
+
return value || undefined;
|
|
200
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
|
|
2
|
+
import type { UiRuntimeEvents } from '@pellux/goodvibes-sdk/platform/runtime/ui-events';
|
|
3
|
+
import type { VoiceService } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
4
|
+
import { LocalStreamingAudioPlayer } from './player.ts';
|
|
5
|
+
import { SpokenTurnController } from './spoken-turn-controller.ts';
|
|
6
|
+
|
|
7
|
+
export interface SpokenTurnRuntime {
|
|
8
|
+
readonly unsubs: readonly (() => void)[];
|
|
9
|
+
submitNextTurn(prompt: string): boolean;
|
|
10
|
+
stop(message?: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WireSpokenTurnRuntimeOptions {
|
|
14
|
+
readonly voiceService: VoiceService;
|
|
15
|
+
readonly configManager: ConfigManager;
|
|
16
|
+
readonly events: UiRuntimeEvents;
|
|
17
|
+
readonly notify: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function wireSpokenTurnRuntime(options: WireSpokenTurnRuntimeOptions): SpokenTurnRuntime {
|
|
21
|
+
const controller = new SpokenTurnController({
|
|
22
|
+
voiceService: options.voiceService,
|
|
23
|
+
configManager: options.configManager,
|
|
24
|
+
player: new LocalStreamingAudioPlayer(),
|
|
25
|
+
notify: options.notify,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const turns = options.events.turns;
|
|
29
|
+
const unsubs = [
|
|
30
|
+
turns.on('TURN_SUBMITTED', (event) => controller.handleTurnEvent(event)),
|
|
31
|
+
turns.on('PREFLIGHT_FAIL', (event) => controller.handleTurnEvent(event)),
|
|
32
|
+
turns.on('STREAM_DELTA', (event) => controller.handleTurnEvent(event)),
|
|
33
|
+
turns.on('STREAM_END', (event) => controller.handleTurnEvent(event)),
|
|
34
|
+
turns.on('TURN_COMPLETED', (event) => controller.handleTurnEvent(event)),
|
|
35
|
+
turns.on('TURN_ERROR', (event) => controller.handleTurnEvent(event)),
|
|
36
|
+
turns.on('TURN_CANCEL', (event) => controller.handleTurnEvent(event)),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
unsubs,
|
|
41
|
+
submitNextTurn: (prompt) => controller.submitNextTurn(prompt),
|
|
42
|
+
stop: (message) => controller.stop(message),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
package/src/cli/management.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth
|
|
|
21
21
|
import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing/index';
|
|
22
22
|
import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing/qr-generator';
|
|
23
23
|
import type { GoodVibesCliParseResult } from './types.ts';
|
|
24
|
+
import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
|
|
24
25
|
import { classifyProviderSetup } from './provider-classification.ts';
|
|
25
26
|
import { resolveRuntimeEndpointBinding } from './endpoints.ts';
|
|
26
27
|
import { applyRuntimeEndpointFlagOverrides } from './config-overrides.ts';
|
|
@@ -378,11 +379,23 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
|
|
|
378
379
|
const snapshots = await listProviderRuntimeSnapshots(services.providerRegistry);
|
|
379
380
|
const current = services.providerRegistry.getCurrentModel();
|
|
380
381
|
if (sub === 'current') {
|
|
381
|
-
const
|
|
382
|
+
const snapshot = snapshots.find((candidate) => candidate.providerId === current.provider);
|
|
383
|
+
const authRoutes = snapshot?.runtime.auth?.routes ?? [];
|
|
384
|
+
const value = {
|
|
385
|
+
provider: current.provider,
|
|
386
|
+
model: current.registryKey,
|
|
387
|
+
configured: snapshot?.runtime.auth?.configured ?? true,
|
|
388
|
+
configuredVia: snapshot?.runtime.auth?.mode ?? 'unknown',
|
|
389
|
+
authRoutes,
|
|
390
|
+
authRouteSummary: summarizeProviderAuthRoutes(authRoutes),
|
|
391
|
+
};
|
|
382
392
|
return formatJsonOrText(runtime.cli)(value, [
|
|
383
393
|
'GoodVibes current provider',
|
|
384
394
|
` provider: ${current.provider}`,
|
|
385
395
|
` model: ${current.registryKey}`,
|
|
396
|
+
` configured: ${yesNo(value.configured)}`,
|
|
397
|
+
` via: ${value.configuredVia}`,
|
|
398
|
+
` auth routes: ${value.authRouteSummary}`,
|
|
386
399
|
].join('\n'));
|
|
387
400
|
}
|
|
388
401
|
if (sub === 'use' || sub === 'set') {
|
|
@@ -417,13 +430,21 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
|
|
|
417
430
|
configured: snapshot.runtime.auth?.configured ?? true,
|
|
418
431
|
modelCount: snapshot.modelCount,
|
|
419
432
|
});
|
|
420
|
-
|
|
433
|
+
const authRoutes = snapshot.runtime.auth?.routes ?? [];
|
|
434
|
+
return formatJsonOrText(runtime.cli)({
|
|
435
|
+
...snapshot,
|
|
436
|
+
setup,
|
|
437
|
+
authRoutes,
|
|
438
|
+
authRouteSummary: summarizeProviderAuthRoutes(authRoutes),
|
|
439
|
+
}, [
|
|
421
440
|
`Provider ${snapshot.providerId}`,
|
|
422
441
|
` active: ${yesNo(snapshot.active)}`,
|
|
423
442
|
` setup: ${setup.setupLabel}`,
|
|
424
443
|
` configured: ${yesNo(snapshot.runtime.auth?.configured ?? true)}`,
|
|
425
444
|
` via: ${snapshot.runtime.auth?.mode ?? 'unknown'}`,
|
|
426
445
|
` models: ${snapshot.modelCount}`,
|
|
446
|
+
` auth routes: ${summarizeProviderAuthRoutes(authRoutes)}`,
|
|
447
|
+
...authRoutes.map((route) => ` ${formatProviderAuthRoute(route)}`),
|
|
427
448
|
` detail: ${snapshot.runtime.auth?.detail ?? snapshot.runtime.notes?.join('; ') ?? ''}`,
|
|
428
449
|
].join('\n'));
|
|
429
450
|
}
|
|
@@ -442,11 +463,13 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
|
|
|
442
463
|
models: snapshot.modelCount,
|
|
443
464
|
current: current.provider === snapshot.providerId,
|
|
444
465
|
detail: snapshot.runtime.auth?.detail ?? snapshot.runtime.notes?.join('; ') ?? '',
|
|
466
|
+
authRoutes: snapshot.runtime.auth?.routes ?? [],
|
|
467
|
+
authRouteSummary: summarizeProviderAuthRoutes(snapshot.runtime.auth?.routes),
|
|
445
468
|
}));
|
|
446
469
|
return formatJsonOrText(runtime.cli)(value, [
|
|
447
470
|
'GoodVibes providers',
|
|
448
471
|
...value.map((provider) =>
|
|
449
|
-
` ${provider.current ? '*' : ' '} ${provider.provider.padEnd(18)} setup=${provider.setupClass} configured=${yesNo(provider.configured)} via=${provider.configuredVia ?? 'n/a'} models=${provider.models} ${provider.detail ?? ''}`.trimEnd(),
|
|
472
|
+
` ${provider.current ? '*' : ' '} ${provider.provider.padEnd(18)} setup=${provider.setupClass} configured=${yesNo(provider.configured)} via=${provider.configuredVia ?? 'n/a'} models=${provider.models} routes=${provider.authRouteSummary} ${provider.detail ?? ''}`.trimEnd(),
|
|
450
473
|
),
|
|
451
474
|
].join('\n'));
|
|
452
475
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProviderAuthRouteDescriptor } from '@pellux/goodvibes-sdk/platform/providers/interface';
|
|
2
|
+
|
|
3
|
+
function routeUsable(route: ProviderAuthRouteDescriptor): boolean {
|
|
4
|
+
return route.usable ?? route.configured;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function summarizeProviderAuthRoutes(routes: readonly ProviderAuthRouteDescriptor[] | undefined): string {
|
|
8
|
+
if (!routes?.length) return 'n/a';
|
|
9
|
+
const configured = routes.filter((route) => route.configured).length;
|
|
10
|
+
const usable = routes.filter(routeUsable).length;
|
|
11
|
+
return `${configured}/${routes.length} configured, ${usable}/${routes.length} usable`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatProviderAuthRoute(route: ProviderAuthRouteDescriptor): string {
|
|
15
|
+
const status = [
|
|
16
|
+
route.configured ? 'configured' : 'not configured',
|
|
17
|
+
routeUsable(route) ? 'usable' : 'not usable',
|
|
18
|
+
route.freshness,
|
|
19
|
+
].filter((part): part is string => Boolean(part));
|
|
20
|
+
const detail = route.detail?.trim();
|
|
21
|
+
return `${route.label} [${route.route}; ${status.join(', ')}]${detail ? ` - ${detail}` : ''}`;
|
|
22
|
+
}
|
|
@@ -19,6 +19,7 @@ import type { OpsApi } from '@pellux/goodvibes-sdk/platform/runtime/ops-api';
|
|
|
19
19
|
import type { OperatorClient } from '@pellux/goodvibes-sdk/platform/runtime/operator-client';
|
|
20
20
|
import type { PeerClient } from '@pellux/goodvibes-sdk/platform/runtime/peer-client';
|
|
21
21
|
import type { DirectTransport } from '@pellux/goodvibes-sdk/platform/runtime/transports/direct';
|
|
22
|
+
import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice/index';
|
|
22
23
|
import type {
|
|
23
24
|
CommandWorkspaceShellServices,
|
|
24
25
|
} from '@pellux/goodvibes-sdk/platform/runtime/shell-command-workspace';
|
|
@@ -58,6 +59,8 @@ export interface CommandUiActions {
|
|
|
58
59
|
print: (text: string) => void;
|
|
59
60
|
exit: () => void;
|
|
60
61
|
submitInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers/interface').ContentPart[]) => void;
|
|
62
|
+
submitSpokenInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers/interface').ContentPart[]) => void;
|
|
63
|
+
stopSpokenOutput?: () => void;
|
|
61
64
|
executeCommand?: (name: string, args: string[]) => Promise<boolean>;
|
|
62
65
|
cancelGeneration?: () => void;
|
|
63
66
|
completeModelSelection?: (selection: {
|
|
@@ -146,6 +149,8 @@ export interface CommandWorkspaceServices
|
|
|
146
149
|
export interface CommandPlatformConfigServices {
|
|
147
150
|
readonly config: DeepReadonly<GoodVibesConfig>;
|
|
148
151
|
readonly configManager: ConfigManager;
|
|
152
|
+
readonly voiceProviderRegistry?: VoiceProviderRegistry;
|
|
153
|
+
readonly voiceService?: VoiceService;
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
export interface CommandPlatformServices
|
|
@@ -41,6 +41,7 @@ export function registerServicesRuntimeCommands(registry: CommandRegistry): void
|
|
|
41
41
|
` webhookUrl: ${inspection.hasWebhookUrl ? 'present' : 'missing'}`,
|
|
42
42
|
` signingSecret: ${inspection.hasSigningSecret ? 'present' : 'missing'}`,
|
|
43
43
|
` publicKey: ${inspection.hasPublicKey ? 'present' : 'missing'}`,
|
|
44
|
+
` appToken: ${inspection.hasAppToken ? 'present' : 'missing'}`,
|
|
44
45
|
].join('\n'));
|
|
45
46
|
return;
|
|
46
47
|
}
|