@pellux/goodvibes-tui 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/docs/foundation-artifacts/operator-contract.json +3756 -1110
  3. package/package.json +8 -7
  4. package/scripts/postinstall.js +57 -1
  5. package/src/audio/activation-sound.ts +117 -0
  6. package/src/audio/capture.ts +112 -0
  7. package/src/audio/onnxruntime-assets.d.ts +24 -0
  8. package/src/audio/voice-capture-wiring.ts +119 -0
  9. package/src/audio/voice-input-session.ts +181 -0
  10. package/src/audio/wake-inference.ts +182 -0
  11. package/src/audio/wake-runtime.ts +341 -0
  12. package/src/cli/bundle-command.ts +4 -1
  13. package/src/cli/management-utils.ts +4 -1
  14. package/src/core/voice-capture-status.ts +53 -0
  15. package/src/core/voice-stt-gateway.ts +75 -0
  16. package/src/core/wake-provision-runner.ts +86 -0
  17. package/src/core/wake-provision-status.ts +170 -0
  18. package/src/daemon/cli.ts +38 -1
  19. package/src/daemon/provision-wake-model.ts +81 -0
  20. package/src/input/command-registry.ts +6 -0
  21. package/src/input/commands/experience-runtime.ts +37 -3
  22. package/src/input/handler-shortcuts.ts +8 -0
  23. package/src/input/keybindings.ts +10 -1
  24. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  25. package/src/input/settings-modal-types.ts +14 -1
  26. package/src/main.ts +12 -14
  27. package/src/renderer/process-indicator.ts +66 -0
  28. package/src/renderer/settings-modal-helpers.ts +4 -2
  29. package/src/renderer/shell-surface.ts +22 -3
  30. package/src/runtime/bootstrap-core.ts +7 -7
  31. package/src/runtime/bootstrap.ts +1 -1
  32. package/src/runtime/device-posture-composition.ts +110 -0
  33. package/src/runtime/disposal-wiring.ts +16 -0
  34. package/src/runtime/runtime-services-types.ts +17 -0
  35. package/src/runtime/services.ts +81 -4
  36. package/src/runtime/voice-setup-services.ts +50 -4
  37. package/src/shell/voice-capture-shell.ts +69 -0
  38. package/src/verification/verification-ledger.ts +59 -20
  39. 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
@@ -8,10 +8,11 @@ import {
8
8
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
9
9
  import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
10
10
  import { resolveGoodVibesHomeOwnership } from '../config/goodvibes-home.ts';
11
- import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
11
+ import { RuntimeEventBus, GlobalNetworkTransportInstaller, configureRuntimeEventBusDefaults, runtimeEventBusOptionsFrom } from '@/runtime/index.ts';
12
12
  import { bindFeatureSettingsBridge, createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
13
13
  import { createRuntimeStore } from '../runtime/store/index.ts';
14
14
  import { createRuntimeServices } from '../runtime/services.ts';
15
+ import { startDeviceHousekeeping } from '../runtime/device-posture-composition.ts';
15
16
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
16
17
  import { createHostPowerSeam } from '@pellux/goodvibes-sdk/platform/power';
17
18
  import { flushActivityLogSync, logger } from '@pellux/goodvibes-sdk/platform/utils';
@@ -34,6 +35,7 @@ import {
34
35
  persistProviders,
35
36
  } from '@pellux/goodvibes-sdk/platform/discovery';
36
37
  import { createSafeHostServeFactory } from './safe-serve.ts';
38
+ import { runProvisionWakeModelCommand } from './provision-wake-model.ts';
37
39
  import { runSendCommand } from './send/command.ts';
38
40
  import { createSendStack } from './send/composition.ts';
39
41
  import { readAllStdin } from './send/stdin.ts';
@@ -181,6 +183,25 @@ async function main(): Promise<void> {
181
183
  process.exit(result.exitCode);
182
184
  }
183
185
 
186
+ // `provision-wake-model …` is intercepted here for the same two reasons, plus
187
+ // its own: the curl installer runs it on the binary it has just placed, before
188
+ // any daemon exists, and it must reach the SDK's pinned manifest rather than
189
+ // have install.sh carry a second copy of the pins. It composes no runtime — it
190
+ // resolves a home directory, derives the managed voice root the running daemon
191
+ // uses, and calls one SDK function. See provision-wake-model.ts for why it
192
+ // exits 0 on a failed download.
193
+ if (rawArgs[0] === 'provision-wake-model') {
194
+ const ownership = resolveDaemonCliOwnership();
195
+ const result = await runProvisionWakeModelCommand(rawArgs.slice(1), {
196
+ homeDirectory: ownership.homeDirectory,
197
+ });
198
+ for (const line of result.lines) {
199
+ // eslint-disable-next-line no-console
200
+ console.log(line);
201
+ }
202
+ process.exit(result.exitCode);
203
+ }
204
+
184
205
  // Parse CLI flags first so --daemon-home and --working-dir env vars are set
185
206
  // before resolveDaemonCliOwnership() reads them.
186
207
  const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
@@ -343,6 +364,9 @@ async function main(): Promise<void> {
343
364
  }
344
365
  }
345
366
 
367
+ // Point the bus listener cap at runtime.eventBus.maxListeners before the
368
+ // first bus exists, so every bus this process builds later uses it.
369
+ configureRuntimeEventBusDefaults(runtimeEventBusOptionsFrom((key) => config.get(key)));
346
370
  const runtimeBus = new RuntimeEventBus();
347
371
  const runtimeStore = createRuntimeStore();
348
372
  // Gate states derive from the domain settings keys; the bridge keeps live
@@ -374,6 +398,13 @@ async function main(): Promise<void> {
374
398
  // the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
375
399
  // the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
376
400
  powerSeam: createHostPowerSeam(),
401
+ // The wake-word model ships with the installation, and this is the retry: at
402
+ // every daemon start, sweep the managed wake tree and fetch whatever the
403
+ // install could not (an offline install, a killed download, a changed pin).
404
+ // It never blocks startup and never fails it — see the SDK's
405
+ // voice/wake/install-provision.ts. The webui reads the model bytes from THIS
406
+ // process, so a provisioned daemon is what makes the browser path work too.
407
+ provisionWakeModelsAtBoot: true,
377
408
  });
378
409
 
379
410
  // Load persisted providers from disk so the provider registry is pre-populated
@@ -460,6 +491,12 @@ async function main(): Promise<void> {
460
491
  // box that has been off for months re-keys itself with no operator action.
461
492
  await runtimeServices.startCluster();
462
493
 
494
+ // Grants and captures from paired phones outlive a restart, so the recovery
495
+ // sweep runs before this daemon serves its first request and the periodic one
496
+ // keeps a long-running daemon from going days without a sweep. The stop is
497
+ // registered with the disposal scope, so shutdown clears the timer.
498
+ startDeviceHousekeeping(runtimeServices.devicePosture);
499
+
463
500
  await Promise.all([
464
501
  daemon.start(),
465
502
  config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
@@ -0,0 +1,81 @@
1
+ // ---------------------------------------------------------------------------
2
+ // provision-wake-model.ts — `goodvibes-daemon provision-wake-model`.
3
+ //
4
+ // WHAT THIS IS FOR
5
+ //
6
+ // The curl installer needs to put the wake-word model on the machine, and it must
7
+ // not hold the pinned URLs, byte counts or checksums to do it. Those live in ONE
8
+ // place — the SDK's wake-word manifest — and a shell script that copied them
9
+ // would be a second copy of a pin, drifting silently the first time the model is
10
+ // retrained. So the installer runs the binary it just installed and lets the SDK
11
+ // do what the SDK owns.
12
+ //
13
+ // IT EXITS 0 EVEN WHEN THE DOWNLOAD FAILS, ON PURPOSE
14
+ //
15
+ // The caller is an installer. A wake-word model is not a reason to fail installing
16
+ // a coding tool, and an installer that aborts half-way through is worse than one
17
+ // that finishes without a wake word. The outcome is printed either way — one plain
18
+ // line naming what happened and how to retry — and a running daemon retries at
19
+ // every boot. `--strict` is there for a caller that genuinely wants the exit code
20
+ // to carry the result (a test, a provisioning script that is checking); the
21
+ // installer does not pass it.
22
+ //
23
+ // It composes NOTHING. No runtime, no config manager, no gateway: this reads a
24
+ // home directory, derives the managed voice root the same way the running daemon
25
+ // does, and calls one SDK function. Composing a runtime here would start a second
26
+ // set of pollers on a machine whose daemon is probably already running.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ import {
30
+ provisionWakeWordModelsAtInstall,
31
+ resolveManagedVoiceRoot,
32
+ } from '@pellux/goodvibes-sdk/platform/voice';
33
+
34
+ export interface ProvisionWakeModelResult {
35
+ readonly exitCode: number;
36
+ readonly lines: readonly string[];
37
+ }
38
+
39
+ export interface ProvisionWakeModelDeps {
40
+ /** The home directory whose `.goodvibes/voice` tree receives the artifacts. */
41
+ readonly homeDirectory: string;
42
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
43
+ /** Injected in tests: the SDK policy, so nothing downloads. */
44
+ readonly provisionAtInstall?: typeof provisionWakeWordModelsAtInstall | undefined;
45
+ }
46
+
47
+ /**
48
+ * Run the install-time provision and report it.
49
+ *
50
+ * `--strict` makes a degraded outcome exit 1. Without it, every outcome exits 0,
51
+ * because the caller is an installer and the alternative is an aborted install
52
+ * over an optional model.
53
+ */
54
+ export async function runProvisionWakeModelCommand(
55
+ argv: readonly string[],
56
+ deps: ProvisionWakeModelDeps,
57
+ ): Promise<ProvisionWakeModelResult> {
58
+ const strict = argv.includes('--strict');
59
+ const provision = deps.provisionAtInstall ?? provisionWakeWordModelsAtInstall;
60
+ let managedRoot: string;
61
+ try {
62
+ managedRoot = resolveManagedVoiceRoot(deps.homeDirectory);
63
+ } catch (error) {
64
+ // A home directory this process cannot make sense of is still not a reason to
65
+ // fail an install, so it is reported on the same terms as a failed download.
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ return {
68
+ exitCode: strict ? 1 : 0,
69
+ lines: [`wake-word model: skipped — ${message}`],
70
+ };
71
+ }
72
+ const outcome = await provision({
73
+ managedRoot,
74
+ recoveryHint: '/voice wake setup',
75
+ ...(deps.env !== undefined ? { env: deps.env } : {}),
76
+ });
77
+ return {
78
+ exitCode: strict && outcome.state === 'degraded' ? 1 : 0,
79
+ lines: [`wake-word model: ${outcome.message}`],
80
+ };
81
+ }
@@ -108,6 +108,12 @@ export interface CommandUiActions {
108
108
  deleteQueuedMessage?: (id: string) => boolean;
109
109
  /** Toggle the drill-in of the "used N memories" provenance chip (memory.showProvenance). */
110
110
  toggleMemoryProvenance?: () => void;
111
+ /**
112
+ * Start voice input, or stop the recording already running and transcribe it
113
+ * into the composer. Two discrete presses rather than a hold, because a
114
+ * terminal delivers key events and never key-release state.
115
+ */
116
+ toggleVoiceInput?: () => void;
111
117
  /** The current host sleep-ownership projection (power.status.get, flattened) — chip, held-because, lid-split. */
112
118
  getPowerState?: () => import('../core/power-status.ts').PowerSurfaceState;
113
119
  /** Set the daemon-held keep-awake toggle (power.keepAwake.set), acquiring/releasing the inhibitor live; returns the new state. */
@@ -3,6 +3,10 @@ import { dirname, resolve } from 'node:path';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
4
  import { requirePanelManager, requireShellPaths } from './runtime-services.ts';
5
5
  import { createVoiceProvisionGateway, renderVoiceProvision, runVoiceSetupWithProgress, VOICE_SETUP_ANNOUNCEMENT } from '../../core/voice-provision-gateway.ts';
6
+ import { resolveWakeRuntimeSettings } from '@pellux/goodvibes-sdk/platform/voice/wake/runtime';
7
+ import { terminalWakeCapabilities } from '../../core/wake-provision-status.ts';
8
+ import { printWakeStatus, runWakeProvision } from '../../core/wake-provision-runner.ts';
9
+ import { wakeProvisionStatus } from '@pellux/goodvibes-sdk/platform/voice';
6
10
 
7
11
  interface VoiceBundle {
8
12
  readonly version: 1;
@@ -223,11 +227,41 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
223
227
 
224
228
  registry.register({
225
229
  name: 'voice',
226
- description: 'Review or toggle always-speak mode, provision the managed local voice runtime (setup/status), and package portable voice metadata',
227
- usage: '[review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]',
230
+ description: 'Review or toggle always-speak mode, provision the managed local voice runtime and the wake-word models (setup/status), and package portable voice metadata',
231
+ usage: '[review|status|setup|enable|disable|wake status|wake setup|bundle export <path>|bundle inspect <path>]',
228
232
  async handler(args, ctx) {
229
233
  const shellPaths = requireShellPaths(ctx);
230
234
  const sub = (args[0] ?? 'review').toLowerCase();
235
+ if (sub === 'wake') {
236
+ // The wake models are provisioned locally by the SDK (checksum-pinned
237
+ // download), not through a daemon verb, so this path needs no gateway.
238
+ // Installation already provisions them and a daemon retries at boot;
239
+ // `setup` here is the recovery act, and nothing downloads unless the user
240
+ // typed it — see wake-provision-runner.ts.
241
+ const wakeSub = (args[1] ?? 'status').toLowerCase();
242
+ const managedRoot = shellPaths.resolveUserPath('voice');
243
+ const runner = {
244
+ managedRoot,
245
+ settings: resolveWakeRuntimeSettings(
246
+ (key: string) => ctx.platform.configManager.get(key as Parameters<typeof ctx.platform.configManager.get>[0]),
247
+ 'tui',
248
+ // Read from disk, so `/voice wake status` reports the speech gate as
249
+ // available exactly when its artifact is there and verified.
250
+ terminalWakeCapabilities(wakeProvisionStatus({ managedRoot })),
251
+ ),
252
+ print: (block: string) => ctx.print(block),
253
+ };
254
+ if (wakeSub === 'setup') {
255
+ await runWakeProvision(runner);
256
+ return;
257
+ }
258
+ if (wakeSub === 'status') {
259
+ printWakeStatus(runner);
260
+ return;
261
+ }
262
+ ctx.print('Usage: /voice wake [status|setup]');
263
+ return;
264
+ }
231
265
  if (sub === 'status' || sub === 'setup') {
232
266
  // The one-act managed install returns a final receipt (no per-phase
233
267
  // streaming over the verb), so the announcement block prints first and
@@ -294,7 +328,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
294
328
  return;
295
329
  }
296
330
  }
297
- ctx.print('Usage: /voice [review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]');
331
+ ctx.print('Usage: /voice [review|status|setup|enable|disable|wake status|wake setup|bundle export <path>|bundle inspect <path>]');
298
332
  },
299
333
  });
300
334
  }
@@ -132,6 +132,14 @@ export function handleGlobalShortcutToken(
132
132
  state.commandContext?.toggleMemoryProvenance?.();
133
133
  return true;
134
134
 
135
+ case 'voice-input':
136
+ // Start recording, or stop the recording already running. Routed globally so
137
+ // the key works whether focus is on the composer or the panel workspace —
138
+ // dictation is not a composer-only act. Still consumed when voice input is
139
+ // not wired, so a stray Alt+V never lands in the prompt as text.
140
+ state.commandContext?.toggleVoiceInput?.();
141
+ return true;
142
+
135
143
  case 'toggle-keep-awake': {
136
144
  // Flip the daemon-held keep-awake toggle; the always-visible "sleep
137
145
  // disabled" chip is the confirmation. Fire-and-forget (the seam renders on
@@ -71,7 +71,8 @@ export type KeyAction =
71
71
  | 'yank-pop'
72
72
  | 'cancel-tool-call'
73
73
  | 'toggle-keep-awake'
74
- | 'toggle-memory-provenance';
74
+ | 'toggle-memory-provenance'
75
+ | 'voice-input';
75
76
 
76
77
  /** Human-readable description for each action (used in /keybindings display). */
77
78
  export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
@@ -117,6 +118,10 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
117
118
  'cancel-tool-call': 'Cancel the running tool call (the turn continues)',
118
119
  'toggle-keep-awake': 'Toggle keep-awake (the "sleep disabled" chip)',
119
120
  'toggle-memory-provenance': 'List/hide the memories a turn used (provenance chip drill-in)',
121
+ // Named for what it actually does. A terminal delivers key EVENTS, not held-key
122
+ // state — there is no release event to watch — so this is two discrete presses,
123
+ // and the description says so instead of implying a hold that cannot exist.
124
+ 'voice-input': 'Voice input: press to start recording, press again to stop and transcribe into the composer',
120
125
  };
121
126
 
122
127
  /** Default key bindings for all actions. */
@@ -207,6 +212,10 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
207
212
  // Alt+M: expand/collapse the "used N memories" provenance chip's drill-in.
208
213
  // Alt+M is unused by any other default. A no-op when the chip is not showing.
209
214
  'toggle-memory-provenance': [{ key: 'm', alt: true }],
215
+ // Alt+V: start/stop voice input. Alt+V is unused by any other default (Ctrl+V
216
+ // is paste and stays paste). Two presses rather than a hold: see the action's
217
+ // description — a terminal has no key-release event to hold against.
218
+ 'voice-input': [{ key: 'v', alt: true }],
210
219
  };
211
220
 
212
221
  /** Resolved overrides type: each key can be a single combo or array. */