@pellux/goodvibes-tui 1.26.0 → 1.27.1
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 +9 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +3756 -1110
- package/package.json +8 -7
- package/scripts/postinstall.js +57 -1
- package/src/audio/activation-sound.ts +117 -0
- package/src/audio/capture.ts +112 -0
- package/src/audio/onnxruntime-assets.d.ts +24 -0
- package/src/audio/voice-capture-wiring.ts +119 -0
- package/src/audio/voice-input-session.ts +181 -0
- package/src/audio/wake-inference.ts +182 -0
- package/src/audio/wake-runtime.ts +341 -0
- package/src/cli/bundle-command.ts +4 -1
- package/src/cli/management-utils.ts +4 -1
- package/src/cli/tui-startup.ts +10 -1
- package/src/core/voice-capture-status.ts +53 -0
- package/src/core/voice-stt-gateway.ts +75 -0
- package/src/core/wake-provision-runner.ts +86 -0
- package/src/core/wake-provision-status.ts +170 -0
- package/src/daemon/cli.ts +117 -28
- package/src/daemon/fatal-boot-report.ts +111 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/input/command-registry.ts +6 -0
- package/src/input/commands/experience-runtime.ts +37 -3
- package/src/input/handler-shortcuts.ts +8 -0
- package/src/input/keybindings.ts +10 -1
- package/src/input/onboarding/onboarding-feature-units.ts +5 -5
- package/src/input/settings-modal-types.ts +14 -1
- package/src/main.ts +12 -14
- package/src/renderer/process-indicator.ts +66 -0
- package/src/renderer/settings-modal-helpers.ts +4 -2
- package/src/renderer/shell-surface.ts +22 -3
- package/src/runtime/bootstrap-core.ts +7 -7
- package/src/runtime/bootstrap.ts +3 -3
- package/src/runtime/device-posture-composition.ts +110 -0
- package/src/runtime/disposal-wiring.ts +16 -0
- package/src/runtime/embedded-service-factories.ts +16 -0
- package/src/runtime/legacy-daemon-migration.ts +12 -1
- package/src/runtime/runtime-services-types.ts +17 -0
- package/src/runtime/services.ts +81 -4
- package/src/runtime/voice-setup-services.ts +50 -4
- package/src/shell/voice-capture-shell.ts +69 -0
- package/src/verification/verification-ledger.ts +59 -20
- package/src/version.ts +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voice-stt-gateway.ts — speech-to-text for captured audio.
|
|
3
|
+
*
|
|
4
|
+
* Both voice consumers end here: push-to-talk hands over what the user just
|
|
5
|
+
* said, and a confirmed wake hands over the utterance that followed it. The
|
|
6
|
+
* transcription itself is the daemon's, not this process's — `voice.stt`
|
|
7
|
+
* (POST /api/voice/stt) transcribes an audio artifact through whichever voice
|
|
8
|
+
* provider is registered, including the managed local whisper that
|
|
9
|
+
* `/voice setup` provisions.
|
|
10
|
+
*
|
|
11
|
+
* The verb has no named facade on the in-process OperatorClient, so it goes over
|
|
12
|
+
* the generic operator invoke path exactly like the voice PROVISIONING verbs do
|
|
13
|
+
* (see core/voice-provision-gateway.ts) — same daemon, same resolution, same
|
|
14
|
+
* honest refusal reasons up front rather than a throw mid-capture.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
18
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
|
|
19
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
20
|
+
import type { UtteranceAudioArtifact } from '@pellux/goodvibes-sdk/platform/voice';
|
|
21
|
+
import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
|
|
22
|
+
|
|
23
|
+
/** voice.stt output — the recognised text plus the provider that produced it. */
|
|
24
|
+
export type VoiceTranscriptionResult = OperatorMethodOutput<'voice.stt'>;
|
|
25
|
+
|
|
26
|
+
/** The narrow verb surface a capture consumer needs. */
|
|
27
|
+
export interface VoiceSttGateway {
|
|
28
|
+
/** Transcribe one captured utterance; resolves to the recognised text. */
|
|
29
|
+
transcribe(audio: UtteranceAudioArtifact): Promise<string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why transcription is unavailable, surfaced verbatim so a capture path prints
|
|
34
|
+
* the daemon's reason instead of inventing one.
|
|
35
|
+
*/
|
|
36
|
+
export type VoiceSttGatewayResolution =
|
|
37
|
+
| { readonly available: true; readonly gateway: VoiceSttGateway }
|
|
38
|
+
| { readonly available: false; readonly reason: string };
|
|
39
|
+
|
|
40
|
+
export interface VoiceSttGatewayDeps {
|
|
41
|
+
readonly configManager: ConfigManager;
|
|
42
|
+
readonly homeDirectory: string | (() => string);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build the live speech-to-text gateway, or say why there is none. */
|
|
46
|
+
export function createVoiceSttGateway(deps: VoiceSttGatewayDeps): VoiceSttGatewayResolution {
|
|
47
|
+
const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
|
|
48
|
+
if (!rpc.available) return { available: false, reason: rpc.reason };
|
|
49
|
+
const { sdk } = rpc;
|
|
50
|
+
return {
|
|
51
|
+
available: true,
|
|
52
|
+
gateway: {
|
|
53
|
+
transcribe: async (audio) => {
|
|
54
|
+
// The generic invoke widens to `unknown` for this verb; the cast names the
|
|
55
|
+
// contract's own output type rather than inventing a local shape, so a
|
|
56
|
+
// contract change surfaces here as a type error.
|
|
57
|
+
const result = await sdk.operator.invoke('voice.stt', { audio }) as VoiceTranscriptionResult;
|
|
58
|
+
return result.text;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render a `voice.stt` rejection honestly. A 501 (the verb is cataloged but this
|
|
66
|
+
* daemon has no voice provider wired behind it) and a 404 (an older daemon
|
|
67
|
+
* without the route) are both "no transcription here" — distinct from a request
|
|
68
|
+
* that reached a provider and failed, which the user can act on differently.
|
|
69
|
+
*/
|
|
70
|
+
export function describeTranscriptionFailure(error: unknown): string {
|
|
71
|
+
if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
|
|
72
|
+
return 'this daemon has no speech-to-text provider wired up — run /voice setup to provision the managed local runtime.';
|
|
73
|
+
}
|
|
74
|
+
return describeOperatorRpcError(error);
|
|
75
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// wake-provision-runner.ts — what `/voice wake status` and `/voice wake setup` do.
|
|
3
|
+
//
|
|
4
|
+
// `/voice wake setup` IS NO LONGER HOW THE MODEL ARRIVES. It used to be the only
|
|
5
|
+
// way, and the result was that installing goodvibes produced a wake-word feature
|
|
6
|
+
// that could not start until the user went and found a command nobody had told
|
|
7
|
+
// them about. The installer and the npm postinstall provision the model now, and
|
|
8
|
+
// a daemon retries at boot whatever the install could not fetch (the SDK's
|
|
9
|
+
// voice/wake/install-provision.ts owns that policy).
|
|
10
|
+
//
|
|
11
|
+
// So what this command is FOR, on a normal machine, is recovery and
|
|
12
|
+
// re-provisioning: an install that was offline, an artifact that stopped
|
|
13
|
+
// verifying, or a re-fetch after the pinned model changes. It still works
|
|
14
|
+
// exactly as it did, which is the point — the degraded path an offline install
|
|
15
|
+
// falls back to is this one.
|
|
16
|
+
//
|
|
17
|
+
// What has NOT changed is the runtime rule. Nothing in this path downloads on its
|
|
18
|
+
// own: `/voice wake status` only reads what is on disk (verifying by content,
|
|
19
|
+
// never by existence), and a download happens only because the user typed
|
|
20
|
+
// `setup`. Turning `voice.wake.enabled` on never fetches anything either — see
|
|
21
|
+
// audio/wake-runtime.ts, which reports what is missing and names this command.
|
|
22
|
+
//
|
|
23
|
+
// Both SDK calls are injectable so a wire test drives the whole flow — a fresh
|
|
24
|
+
// host, a corrupt artifact, a failed component — with no network and no files.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
provisionWakeWordModels,
|
|
29
|
+
wakeProvisionStatus,
|
|
30
|
+
type WakeProvisionProgress,
|
|
31
|
+
type WakeProvisionResult,
|
|
32
|
+
type WakeProvisionStatus,
|
|
33
|
+
type WakeRuntimeSettings,
|
|
34
|
+
} from '@pellux/goodvibes-sdk/platform/voice';
|
|
35
|
+
import {
|
|
36
|
+
WAKE_SETUP_ANNOUNCEMENT,
|
|
37
|
+
wakeProvisionReceiptLines,
|
|
38
|
+
wakeStatusLines,
|
|
39
|
+
} from './wake-provision-status.ts';
|
|
40
|
+
|
|
41
|
+
export interface WakeProvisionRunnerDeps {
|
|
42
|
+
/** Managed root the wake tree hangs off; `<managedRoot>/wake` holds the artifacts. */
|
|
43
|
+
readonly managedRoot: string;
|
|
44
|
+
/** Already-resolved `voice.wake.*` rows, so the status block reports the live posture. */
|
|
45
|
+
readonly settings: WakeRuntimeSettings;
|
|
46
|
+
readonly print: (block: string) => void;
|
|
47
|
+
/** Injected in tests; defaults to the SDK's content-verifying read. */
|
|
48
|
+
readonly readStatus?: (managedRoot: string) => WakeProvisionStatus;
|
|
49
|
+
/** Injected in tests; defaults to the SDK's checksum-pinned download. */
|
|
50
|
+
readonly provision?: (
|
|
51
|
+
managedRoot: string,
|
|
52
|
+
onProgress: (progress: WakeProvisionProgress) => void,
|
|
53
|
+
) => Promise<WakeProvisionResult>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Print the honest on-disk + configured posture. Reads only; never downloads. */
|
|
57
|
+
export function printWakeStatus(deps: WakeProvisionRunnerDeps): void {
|
|
58
|
+
const read = deps.readStatus ?? ((managedRoot: string) => wakeProvisionStatus({ managedRoot }));
|
|
59
|
+
deps.print(['Wake-Word Detection', ...wakeStatusLines(read(deps.managedRoot), deps.settings)].join('\n'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run the provision, narrating each artifact as its phase advances, then print
|
|
64
|
+
* the receipt. A component that fails is named in the receipt with its "got X,
|
|
65
|
+
* want Y" reason rather than folded into an overall failure.
|
|
66
|
+
*/
|
|
67
|
+
export async function runWakeProvision(deps: WakeProvisionRunnerDeps): Promise<void> {
|
|
68
|
+
const provision = deps.provision
|
|
69
|
+
?? ((managedRoot: string, onProgress: (progress: WakeProvisionProgress) => void) =>
|
|
70
|
+
provisionWakeWordModels({ managedRoot, onProgress }));
|
|
71
|
+
deps.print(WAKE_SETUP_ANNOUNCEMENT);
|
|
72
|
+
// One line per phase CHANGE, so a slow download does not repeat itself.
|
|
73
|
+
const lastPhase = new Map<string, string>();
|
|
74
|
+
const onProgress = (progress: WakeProvisionProgress): void => {
|
|
75
|
+
if (lastPhase.get(progress.component) === progress.phase) return;
|
|
76
|
+
lastPhase.set(progress.component, progress.phase);
|
|
77
|
+
const extra = progress.message !== undefined ? ` — ${progress.message}` : '';
|
|
78
|
+
deps.print(` ${progress.component}: ${progress.phase}${extra}`);
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const result = await provision(deps.managedRoot, onProgress);
|
|
82
|
+
deps.print(['Wake-Word Setup — receipt', ...wakeProvisionReceiptLines(result)].join('\n'));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
deps.print(`Wake-Word Setup\n provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// wake-provision-status.ts — the surface-facing projection of wake-word
|
|
3
|
+
// provisioning and of the rows that are or are not in force.
|
|
4
|
+
//
|
|
5
|
+
// The wake models are checksum-pinned and arrive WITH THE INSTALLATION: the
|
|
6
|
+
// installer and the npm postinstall provision them, and a daemon retries at boot
|
|
7
|
+
// whatever the install could not fetch. `/voice wake setup` is the recovery act
|
|
8
|
+
// for the case where that did not land, not the normal way in — so the lines
|
|
9
|
+
// below name it where it helps and do not imply it is a required step.
|
|
10
|
+
//
|
|
11
|
+
// This module is the read-only projection the /voice wake surfaces render from:
|
|
12
|
+
// per-artifact present / verified / corrupt / bytes taken straight from the SDK's
|
|
13
|
+
// wakeProvisionStatus, which verifies by CONTENT rather than by existence — a
|
|
14
|
+
// truncated or wrong-asset file reports corrupt instead of present, because a
|
|
15
|
+
// detector loading it would fail in a way the user could not diagnose.
|
|
16
|
+
//
|
|
17
|
+
// Pure formatting only (no I/O), mirroring voice-provision-status.ts: the command
|
|
18
|
+
// layer fetches the status and hands it here, so these builders are testable
|
|
19
|
+
// against fixture shapes.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
WakeArtifactStatus,
|
|
24
|
+
WakeProvisionResult,
|
|
25
|
+
WakeProvisionStatus,
|
|
26
|
+
WakeRuntimeSettings,
|
|
27
|
+
WakeSurfaceCapabilities,
|
|
28
|
+
} from '@pellux/goodvibes-sdk/platform/voice';
|
|
29
|
+
import { noiseSuppressionSupport } from '@pellux/goodvibes-sdk/platform/voice/capture';
|
|
30
|
+
import { formatVoiceBytes } from './voice-provision-status.ts';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a terminal can actually do, so a `voice.wake.*` row is refused or reported
|
|
34
|
+
* rather than faked. Lives here — beside the status projection and away from the
|
|
35
|
+
* inference runtime — so the command layer can resolve settings without pulling
|
|
36
|
+
* onnxruntime into a `/voice wake status` call.
|
|
37
|
+
*/
|
|
38
|
+
export function terminalWakeCapabilities(status?: Pick<WakeProvisionStatus, 'vadReady'>): WakeSurfaceCapabilities {
|
|
39
|
+
return {
|
|
40
|
+
// Asked of the SDK rather than declared here. The filter is a WebAssembly
|
|
41
|
+
// module carried in the package, so the only question is whether this runtime
|
|
42
|
+
// has WebAssembly at all — which the SDK answers, with a reason a settings
|
|
43
|
+
// surface can show. A host constant would go stale the moment the stage
|
|
44
|
+
// shipped, which is exactly what happened to the previous one.
|
|
45
|
+
speexAvailable: noiseSuppressionSupport().supported,
|
|
46
|
+
// The speech gate needs its own provisioned model, so this follows what is
|
|
47
|
+
// VERIFIED ON DISK rather than what the build is capable of: with the artifact
|
|
48
|
+
// missing, `voice.wake.vadThreshold` above 0 still blocks startup and says so.
|
|
49
|
+
vadAvailable: status?.vadReady === true,
|
|
50
|
+
// A terminal has a filesystem (retainAudio) and an audio player (a custom
|
|
51
|
+
// activation sound file).
|
|
52
|
+
canRetainAudio: true,
|
|
53
|
+
canPlayLocalFile: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One artifact's line: what is on disk, verified by content. */
|
|
58
|
+
export function wakeArtifactLine(label: string, artifact: WakeArtifactStatus): string {
|
|
59
|
+
if (artifact.verified) return ` ${label}: verified (${formatVoiceBytes(artifact.bytes)})`;
|
|
60
|
+
if (artifact.corrupt) {
|
|
61
|
+
return ` ${label}: PRESENT BUT FAILS VERIFICATION (${formatVoiceBytes(artifact.bytes)}) — torn, truncated, or the wrong asset; /voice wake setup replaces it`;
|
|
62
|
+
}
|
|
63
|
+
return ` ${label}: missing`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Blockers, in the SDK's own words. A blocker means the detector must NOT start,
|
|
68
|
+
* so the row's key and the written reason are both shown — a swallowed blocker is
|
|
69
|
+
* a user staring at a feature that is on and doing nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function describeWakeBlockers(settings: Pick<WakeRuntimeSettings, 'blockers'>): string[] {
|
|
72
|
+
return settings.blockers.map((blocker) => ` ${blocker.key}: ${blocker.detail}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Limitations: the detector runs, with one row not in force, and says which. */
|
|
76
|
+
export function describeWakeLimitations(settings: Pick<WakeRuntimeSettings, 'limitations'>): string[] {
|
|
77
|
+
return settings.limitations.map((limitation) => ` ${limitation.key}: ${limitation.detail}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The `/voice wake status` block. */
|
|
81
|
+
export function wakeStatusLines(
|
|
82
|
+
status: WakeProvisionStatus,
|
|
83
|
+
settings: WakeRuntimeSettings,
|
|
84
|
+
): string[] {
|
|
85
|
+
const lines: string[] = [
|
|
86
|
+
` feature: voice.wake.enabled=${settings.enabled ? 'on' : 'off'}, voice.wake.surfaces.tui=${settings.surfaceEnabled ? 'on' : 'off'}`,
|
|
87
|
+
` listening on this terminal: ${settings.active ? 'yes, when the models are provisioned' : 'no'}`,
|
|
88
|
+
` models provisioned: ${status.ready ? 'yes' : `no (${status.reason ?? 'not-provisioned'})`}`,
|
|
89
|
+
` model version: ${status.modelVersion ?? 'unpinned'}`,
|
|
90
|
+
wakeArtifactLine('classifier', status.classifier),
|
|
91
|
+
wakeArtifactLine('speech-embedding front end', status.embedding),
|
|
92
|
+
// One NOTICE per redistributable artifact, named so it is obvious which is
|
|
93
|
+
// which: both are required, and a reader chasing a missing one needs to know
|
|
94
|
+
// whether it is ours or Google's.
|
|
95
|
+
wakeArtifactLine('attribution NOTICE (classifier)', status.notice),
|
|
96
|
+
wakeArtifactLine('attribution NOTICE (front end)', status.embeddingNotice),
|
|
97
|
+
// Reported but never presented as a problem: nothing on this terminal loads
|
|
98
|
+
// it, and it is here so a surface can see whether the daemon could serve it.
|
|
99
|
+
`${wakeArtifactLine('classifier, tflite form (served to other runtimes; unused here)', status.mobileClassifier)}`,
|
|
100
|
+
// The speech gate reports separately from `models provisioned` for the same
|
|
101
|
+
// reason it is outside the SDK's `ready`: voice.wake.vadThreshold is 0 unless
|
|
102
|
+
// someone turns it on, so a missing gate is not a broken detector. It is
|
|
103
|
+
// printed rather than left out because vadThreshold above 0 refuses to start
|
|
104
|
+
// when the gate is absent, and a reader needs to see why.
|
|
105
|
+
` speech gate provisioned: ${status.vadReady ? 'yes' : 'no — voice.wake.vadThreshold above 0 will refuse to start'}`,
|
|
106
|
+
wakeArtifactLine('speech gate', status.vad),
|
|
107
|
+
wakeArtifactLine('attribution NOTICE (speech gate)', status.vadNotice),
|
|
108
|
+
];
|
|
109
|
+
if (!status.ready) {
|
|
110
|
+
lines.push(
|
|
111
|
+
` a fresh provision would download ${formatVoiceBytes(status.downloadBytes)}. Installing goodvibes normally does this,`,
|
|
112
|
+
' and a running daemon retries at boot — run /voice wake setup to fetch it now (nothing downloads on its own).',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
lines.push(
|
|
116
|
+
` wake models configured: ${settings.modelIds.length > 0 ? settings.modelIds.join(', ') : 'none (voice.wake.models is empty, so nothing is scored)'}`,
|
|
117
|
+
` recorder: voice.wake.captureCommand=${settings.capture.backend}, device=${settings.capture.device.trim().length > 0 ? settings.capture.device : 'system default'}`,
|
|
118
|
+
` after a wake: ${settings.autoSubmit ? 'the transcript is submitted as a turn' : 'the transcript is placed in the composer'}`,
|
|
119
|
+
` indicator: voice.wake.indicator=${settings.indicator}, activation sound=${settings.activationSound.kind}`,
|
|
120
|
+
` retained audio: voice.wake.retainAudio=${settings.retainAudio}`,
|
|
121
|
+
);
|
|
122
|
+
if (status.recallIsSyntheticOnly) {
|
|
123
|
+
lines.push(' the published recall figures for this model are measured on synthesised speech only — no human recording of the phrase exists behind them.');
|
|
124
|
+
}
|
|
125
|
+
const blockers = describeWakeBlockers(settings);
|
|
126
|
+
if (blockers.length > 0) lines.push(' rows blocking startup:', ...blockers);
|
|
127
|
+
const limitations = describeWakeLimitations(settings);
|
|
128
|
+
if (limitations.length > 0) lines.push(' rows not in force:', ...limitations);
|
|
129
|
+
return lines;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `/voice wake setup` receipt. */
|
|
133
|
+
export function wakeProvisionReceiptLines(result: WakeProvisionResult): string[] {
|
|
134
|
+
const lines = [
|
|
135
|
+
` ready: ${result.ready ? 'yes' : 'no'}`,
|
|
136
|
+
` model version: ${result.modelVersion ?? 'unpinned'}`,
|
|
137
|
+
// Separate from `ready` on purpose: this terminal loads the onnx build, so a
|
|
138
|
+
// missing tflite twin is not a detector that cannot run.
|
|
139
|
+
` tflite form (for runtimes that cannot load onnx): ${result.mobileFormatReady ? 'installed' : 'not installed — nothing on this terminal needs it'}`,
|
|
140
|
+
// Separate from `ready` for its own reason: voice.wake.vadThreshold ships at
|
|
141
|
+
// 0, so the detector runs without the gate — but a value above 0 refuses to
|
|
142
|
+
// start without it, which is what this line lets a reader act on.
|
|
143
|
+
` speech gate (voice.wake.vadThreshold): ${result.vadReady ? 'installed' : 'not installed — voice.wake.vadThreshold above 0 will refuse to start'}`,
|
|
144
|
+
];
|
|
145
|
+
for (const outcome of result.outcomes) {
|
|
146
|
+
const detail = outcome.state === 'failed'
|
|
147
|
+
? ` — ${outcome.error ?? 'no reason reported'}`
|
|
148
|
+
: outcome.bytes !== undefined ? ` (${formatVoiceBytes(outcome.bytes)})` : '';
|
|
149
|
+
lines.push(` ${outcome.component}: ${outcome.state}${detail}`);
|
|
150
|
+
lines.push(` ${outcome.path}`);
|
|
151
|
+
}
|
|
152
|
+
if (result.noticePath !== null) {
|
|
153
|
+
lines.push(` attribution NOTICE (travels with the classifier): ${result.noticePath}`);
|
|
154
|
+
}
|
|
155
|
+
if (result.embeddingNoticePath !== null) {
|
|
156
|
+
lines.push(` attribution NOTICE (travels with the front end): ${result.embeddingNoticePath}`);
|
|
157
|
+
}
|
|
158
|
+
if (result.recallIsSyntheticOnly) {
|
|
159
|
+
lines.push(' the published recall figures for this model are measured on synthesised speech only — no human recording of the phrase exists behind them.');
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The one-line announcement printed before a (multi-megabyte) provision runs. */
|
|
165
|
+
export const WAKE_SETUP_ANNOUNCEMENT = [
|
|
166
|
+
'Wake-Word Setup',
|
|
167
|
+
' downloading the pinned "hey goodvibes" classifier (both runtime formats), the shared speech-embedding front end, and the speech gate voice.wake.vadThreshold runs…',
|
|
168
|
+
' every artifact is checksum-verified and the download is resumable — one that already matches is skipped, so re-running this only fetches what is missing.',
|
|
169
|
+
' installing goodvibes normally does this for you; running it by hand is how you recover an install that could not reach the network.',
|
|
170
|
+
].join('\n');
|
package/src/daemon/cli.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import {
|
|
3
4
|
ConfigManager,
|
|
5
|
+
daemonConfigPathForHome,
|
|
4
6
|
deriveControlPlaneBaseUrl,
|
|
5
7
|
readControlPlaneBinding,
|
|
6
8
|
resolveDaemonEnabled,
|
|
@@ -8,14 +10,16 @@ import {
|
|
|
8
10
|
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
9
11
|
import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
|
|
10
12
|
import { resolveGoodVibesHomeOwnership } from '../config/goodvibes-home.ts';
|
|
11
|
-
import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
|
|
13
|
+
import { RuntimeEventBus, GlobalNetworkTransportInstaller, configureRuntimeEventBusDefaults, runtimeEventBusOptionsFrom } from '@/runtime/index.ts';
|
|
12
14
|
import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
|
|
13
15
|
import { createRuntimeStore } from '../runtime/store/index.ts';
|
|
14
16
|
import { createRuntimeServices } from '../runtime/services.ts';
|
|
17
|
+
import { startDeviceHousekeeping } from '../runtime/device-posture-composition.ts';
|
|
15
18
|
import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
16
19
|
import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
|
|
17
|
-
import { flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
20
|
+
import { configureActivityLogger, flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
18
21
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
22
|
+
import { reportFatalBootFailure, writeExitingStdoutLine, writeFatalLine } from './fatal-boot-report.ts';
|
|
19
23
|
import {
|
|
20
24
|
getOrCreateCompanionToken,
|
|
21
25
|
pruneStaleOperatorTokens,
|
|
@@ -34,6 +38,7 @@ import {
|
|
|
34
38
|
persistProviders,
|
|
35
39
|
} from '@pellux/goodvibes-sdk/platform/discovery';
|
|
36
40
|
import { createSafeHostServeFactory } from './safe-serve.ts';
|
|
41
|
+
import { runProvisionWakeModelCommand } from './provision-wake-model.ts';
|
|
37
42
|
import { runSendCommand } from './send/command.ts';
|
|
38
43
|
import { createSendStack } from './send/composition.ts';
|
|
39
44
|
import { readAllStdin } from './send/stdin.ts';
|
|
@@ -128,17 +133,26 @@ async function main(): Promise<void> {
|
|
|
128
133
|
workingDir: ownership.workingDirectory,
|
|
129
134
|
homeDir: ownership.homeDirectory,
|
|
130
135
|
surfaceRoot: 'tui',
|
|
136
|
+
// Named explicitly rather than left to ConfigManager's own
|
|
137
|
+
// `<homeDir>/.goodvibes/daemon/settings.json` derivation, so the daemon
|
|
138
|
+
// tier this reads/writes agrees with `ownership.daemonHomeDirectory`
|
|
139
|
+
// even when `GOODVIBES_DAEMON_HOME` moved it away from the plain home.
|
|
140
|
+
daemonTierPath: daemonConfigPathForHome(ownership.daemonHomeDirectory),
|
|
131
141
|
});
|
|
132
142
|
const result = await runClusterCommand({
|
|
133
143
|
argv: rawArgs.slice(1),
|
|
134
144
|
configManager: clusterConfig,
|
|
135
145
|
daemonHomeDir: ownership.daemonHomeDirectory,
|
|
136
146
|
});
|
|
137
|
-
|
|
147
|
+
// Every write below is immediately followed by process.exit, so all of them
|
|
148
|
+
// go to the descriptor synchronously rather than through a stream that can
|
|
149
|
+
// still be in flight when the process stops existing — see
|
|
150
|
+
// fatal-boot-report.ts. (The OSC 52 clipboard sequence is unterminated
|
|
151
|
+
// either way; the line terminator this adds after it is inert.)
|
|
152
|
+
if (result.rawOutput) writeExitingStdoutLine(`\u001b${result.rawOutput}`);
|
|
138
153
|
for (const line of result.lines) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
else console.error(line);
|
|
154
|
+
if (result.exitCode === 0) writeExitingStdoutLine(line);
|
|
155
|
+
else writeFatalLine(line);
|
|
142
156
|
}
|
|
143
157
|
process.exit(result.exitCode);
|
|
144
158
|
}
|
|
@@ -158,6 +172,10 @@ async function main(): Promise<void> {
|
|
|
158
172
|
// reason to message the owner is usually that something stopped.
|
|
159
173
|
if (rawArgs[0] === 'send') {
|
|
160
174
|
const ownership = resolveDaemonCliOwnership();
|
|
175
|
+
// Named before the delivery runs, because the flushActivityLogSync() below
|
|
176
|
+
// is what preserves this send's OUTBOUND_HTTP record — and a logger with no
|
|
177
|
+
// destination has nothing to flush, so that record was never written.
|
|
178
|
+
configureActivityLogger(join(ownership.workingDirectory, '.goodvibes', 'logs'));
|
|
161
179
|
runDaemonConfigMigration(ownership.homeDirectory);
|
|
162
180
|
const stack = createSendStack({
|
|
163
181
|
workingDirectory: ownership.workingDirectory,
|
|
@@ -170,10 +188,12 @@ async function main(): Promise<void> {
|
|
|
170
188
|
readStdin: readAllStdin,
|
|
171
189
|
stdinIsTty: process.stdin.isTTY === true,
|
|
172
190
|
});
|
|
191
|
+
// Descriptor writes, not stream writes: the exit two lines below can leave
|
|
192
|
+
// a `console.log` still in flight, and `send` is most often run precisely
|
|
193
|
+
// because something has already stopped. See fatal-boot-report.ts.
|
|
173
194
|
for (const line of result.lines) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
else console.error(line);
|
|
195
|
+
if (result.exitCode === 0) writeExitingStdoutLine(line);
|
|
196
|
+
else writeFatalLine(line);
|
|
177
197
|
}
|
|
178
198
|
// The activity log holds the OUTBOUND_HTTP record for the send that just
|
|
179
199
|
// happened (or did not); a process exiting this promptly would drop it.
|
|
@@ -181,13 +201,36 @@ async function main(): Promise<void> {
|
|
|
181
201
|
process.exit(result.exitCode);
|
|
182
202
|
}
|
|
183
203
|
|
|
204
|
+
// `provision-wake-model …` is intercepted here for the same two reasons, plus
|
|
205
|
+
// its own: the curl installer runs it on the binary it has just placed, before
|
|
206
|
+
// any daemon exists, and it must reach the SDK's pinned manifest rather than
|
|
207
|
+
// have install.sh carry a second copy of the pins. It composes no runtime — it
|
|
208
|
+
// resolves a home directory, derives the managed voice root the running daemon
|
|
209
|
+
// uses, and calls one SDK function. See provision-wake-model.ts for why it
|
|
210
|
+
// exits 0 on a failed download.
|
|
211
|
+
if (rawArgs[0] === 'provision-wake-model') {
|
|
212
|
+
const ownership = resolveDaemonCliOwnership();
|
|
213
|
+
const result = await runProvisionWakeModelCommand(rawArgs.slice(1), {
|
|
214
|
+
homeDirectory: ownership.homeDirectory,
|
|
215
|
+
});
|
|
216
|
+
// The curl installer runs this on a binary it has just placed and reads the
|
|
217
|
+
// result; an exit that discards the receipt would report nothing at all.
|
|
218
|
+
for (const line of result.lines) {
|
|
219
|
+
writeExitingStdoutLine(line);
|
|
220
|
+
}
|
|
221
|
+
process.exit(result.exitCode);
|
|
222
|
+
}
|
|
223
|
+
|
|
184
224
|
// Parse CLI flags first so --daemon-home and --working-dir env vars are set
|
|
185
225
|
// before resolveDaemonCliOwnership() reads them.
|
|
186
226
|
const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
|
|
187
227
|
if (cli.errors.length > 0) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
228
|
+
// A parse refusal is the most common reason a service unit's daemon never
|
|
229
|
+
// starts, so it goes on the descriptor the journal is attached to rather
|
|
230
|
+
// than through a stream that exits out from under it.
|
|
231
|
+
writeFatalLine(cli.errors.join('\n'));
|
|
232
|
+
writeFatalLine('');
|
|
233
|
+
writeFatalLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
|
|
191
234
|
process.exit(2);
|
|
192
235
|
}
|
|
193
236
|
if (cli.warnings.length > 0) {
|
|
@@ -196,11 +239,11 @@ async function main(): Promise<void> {
|
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
241
|
if (cli.flags.help || cli.command === 'help') {
|
|
199
|
-
|
|
242
|
+
writeExitingStdoutLine(renderGoodVibesDaemonHelp('goodvibes-daemon'));
|
|
200
243
|
process.exit(0);
|
|
201
244
|
}
|
|
202
245
|
if (cli.flags.version || cli.command === 'version') {
|
|
203
|
-
|
|
246
|
+
writeExitingStdoutLine(renderGoodVibesVersion('goodvibes-daemon'));
|
|
204
247
|
process.exit(0);
|
|
205
248
|
}
|
|
206
249
|
const cliFlags = cli.flags;
|
|
@@ -214,13 +257,33 @@ async function main(): Promise<void> {
|
|
|
214
257
|
}
|
|
215
258
|
|
|
216
259
|
const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
|
|
260
|
+
// Give the shared logger a destination before anything else runs. It never
|
|
261
|
+
// had one in this entrypoint: `logger` only writes to a file once
|
|
262
|
+
// `configureActivityLogger()` has named one, so every logger.info/warn/error
|
|
263
|
+
// in the standalone daemon — including the fatal handler at the bottom of
|
|
264
|
+
// this file and every flushActivityLogSync() call — went nowhere at all.
|
|
265
|
+
// That is why a crash-looping daemon left an empty activity log next to a
|
|
266
|
+
// silent console. src/cli/entrypoint.ts does the same for the interactive
|
|
267
|
+
// process; this is the daemon finally matching it.
|
|
268
|
+
configureActivityLogger(join(workingDir, '.goodvibes', 'logs'));
|
|
217
269
|
runDaemonConfigMigration(homeDirectory);
|
|
218
|
-
|
|
270
|
+
// `daemonTierPath` named explicitly (SDK 1.21.0) rather than left to
|
|
271
|
+
// ConfigManager's own `<homeDir>/.goodvibes/daemon/settings.json`
|
|
272
|
+
// derivation: `daemonHomeDirectory` is this process's own resolution of
|
|
273
|
+
// `--daemon-home`/`GOODVIBES_DAEMON_HOME`, and the two must agree so the
|
|
274
|
+
// daemon's config reads and writes the same file its identity state
|
|
275
|
+
// (operator-tokens.json, auth-users.json) already lives beside.
|
|
276
|
+
const config = new ConfigManager({
|
|
277
|
+
workingDir,
|
|
278
|
+
homeDir: homeDirectory,
|
|
279
|
+
surfaceRoot: 'tui',
|
|
280
|
+
daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
|
|
281
|
+
});
|
|
219
282
|
new GlobalNetworkTransportInstaller().install(config);
|
|
220
283
|
|
|
221
284
|
const overrideErrors = applyRuntimeConfigOverrides(config, cliFlags.configOverrides);
|
|
222
285
|
if (overrideErrors.length > 0) {
|
|
223
|
-
|
|
286
|
+
writeFatalLine(overrideErrors.join('\n'));
|
|
224
287
|
process.exit(2);
|
|
225
288
|
}
|
|
226
289
|
applyRuntimeFeatureFlagOverrides(config, {
|
|
@@ -240,7 +303,7 @@ async function main(): Promise<void> {
|
|
|
240
303
|
}
|
|
241
304
|
const endpointOverrideErrors = applyRuntimeEndpointFlagOverrides(config, 'controlPlane', cliFlags);
|
|
242
305
|
if (endpointOverrideErrors.length > 0) {
|
|
243
|
-
|
|
306
|
+
writeFatalLine(endpointOverrideErrors.join('\n'));
|
|
244
307
|
process.exit(2);
|
|
245
308
|
}
|
|
246
309
|
if (cliFlags.port !== undefined) logger.info('daemon: --port flag applied', { port: cliFlags.port });
|
|
@@ -280,9 +343,11 @@ async function main(): Promise<void> {
|
|
|
280
343
|
// consent as any other non-interactive destructive confirmation.
|
|
281
344
|
confirmMigration: cliFlags.yes,
|
|
282
345
|
});
|
|
346
|
+
// install-service / uninstall-service / service-status print the unit path,
|
|
347
|
+
// the follow-up commands and the honest result, then exit immediately —
|
|
348
|
+
// exactly the race a stream write loses. Descriptor writes instead.
|
|
283
349
|
for (const line of result.lines) {
|
|
284
|
-
|
|
285
|
-
console.log(line);
|
|
350
|
+
writeExitingStdoutLine(line);
|
|
286
351
|
}
|
|
287
352
|
process.exit(result.exitCode);
|
|
288
353
|
}
|
|
@@ -343,6 +408,9 @@ async function main(): Promise<void> {
|
|
|
343
408
|
}
|
|
344
409
|
}
|
|
345
410
|
|
|
411
|
+
// Point the bus listener cap at runtime.eventBus.maxListeners before the
|
|
412
|
+
// first bus exists, so every bus this process builds later uses it.
|
|
413
|
+
configureRuntimeEventBusDefaults(runtimeEventBusOptionsFrom((key) => config.get(key)));
|
|
346
414
|
const runtimeBus = new RuntimeEventBus();
|
|
347
415
|
const runtimeStore = createRuntimeStore();
|
|
348
416
|
// Gate states derive from the domain settings keys; the bridge keeps live
|
|
@@ -374,6 +442,13 @@ async function main(): Promise<void> {
|
|
|
374
442
|
// the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
|
|
375
443
|
// the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
|
|
376
444
|
powerSeam: createHostPowerSeam(),
|
|
445
|
+
// The wake-word model ships with the installation, and this is the retry: at
|
|
446
|
+
// every daemon start, sweep the managed wake tree and fetch whatever the
|
|
447
|
+
// install could not (an offline install, a killed download, a changed pin).
|
|
448
|
+
// It never blocks startup and never fails it — see the SDK's
|
|
449
|
+
// voice/wake/install-provision.ts. The webui reads the model bytes from THIS
|
|
450
|
+
// process, so a provisioned daemon is what makes the browser path work too.
|
|
451
|
+
provisionWakeModelsAtBoot: true,
|
|
377
452
|
});
|
|
378
453
|
|
|
379
454
|
// Load persisted providers from disk so the provider registry is pre-populated
|
|
@@ -460,6 +535,12 @@ async function main(): Promise<void> {
|
|
|
460
535
|
// box that has been off for months re-keys itself with no operator action.
|
|
461
536
|
await runtimeServices.startCluster();
|
|
462
537
|
|
|
538
|
+
// Grants and captures from paired phones outlive a restart, so the recovery
|
|
539
|
+
// sweep runs before this daemon serves its first request and the periodic one
|
|
540
|
+
// keeps a long-running daemon from going days without a sweep. The stop is
|
|
541
|
+
// registered with the disposal scope, so shutdown clears the timer.
|
|
542
|
+
startDeviceHousekeeping(runtimeServices.devicePosture);
|
|
543
|
+
|
|
463
544
|
await Promise.all([
|
|
464
545
|
daemon.start(),
|
|
465
546
|
config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
|
|
@@ -536,7 +617,12 @@ async function main(): Promise<void> {
|
|
|
536
617
|
// that is what clients resolve when they look for the daemon.
|
|
537
618
|
try {
|
|
538
619
|
runDaemonConfigMigration(homeDirectory);
|
|
539
|
-
const clientViewConfig = new ConfigManager({
|
|
620
|
+
const clientViewConfig = new ConfigManager({
|
|
621
|
+
workingDir,
|
|
622
|
+
homeDir: homeDirectory,
|
|
623
|
+
surfaceRoot: 'tui',
|
|
624
|
+
daemonTierPath: daemonConfigPathForHome(daemonHomeDirectory),
|
|
625
|
+
});
|
|
540
626
|
const clientEndpoint = resolveRuntimeEndpointBinding(clientViewConfig, 'controlPlane');
|
|
541
627
|
const reconcile = await reconcileRedundantLegacyUnit({
|
|
542
628
|
homeDir: homedir(),
|
|
@@ -592,13 +678,16 @@ async function main(): Promise<void> {
|
|
|
592
678
|
console.log(bannerLines.join('\n'));
|
|
593
679
|
}
|
|
594
680
|
|
|
595
|
-
void main().catch(
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
681
|
+
void main().catch((error) => {
|
|
682
|
+
// A daemon that dies during startup must leave the reason where an operator
|
|
683
|
+
// will actually find it: on the file descriptor the service journal is
|
|
684
|
+
// attached to, written synchronously, BEFORE the activity log is attempted.
|
|
685
|
+
//
|
|
686
|
+
// Doing it the other way round is what shipped mute. This handler used to
|
|
687
|
+
// call logger.error and flushActivityLogSync and nothing else, and the
|
|
688
|
+
// logger had no destination this early in boot — so the released 1.27.0
|
|
689
|
+
// binary crash-looped 77 times with exit 1, zero bytes on stdout, zero bytes
|
|
690
|
+
// on stderr and an empty activity log. See fatal-boot-report.ts.
|
|
691
|
+
reportFatalBootFailure(error);
|
|
603
692
|
process.exit(1);
|
|
604
693
|
});
|