@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.30",
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": "^0.25.5",
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
+ }
@@ -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 value = { provider: current.provider, model: current.registryKey };
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
- return formatJsonOrText(runtime.cli)({ ...snapshot, setup }, [
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
  }