@pellux/goodvibes-tui 1.25.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 (78) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +9605 -3119
  4. package/package.json +9 -7
  5. package/scripts/postinstall.js +57 -1
  6. package/src/audio/activation-sound.ts +117 -0
  7. package/src/audio/capture.ts +112 -0
  8. package/src/audio/onnxruntime-assets.d.ts +24 -0
  9. package/src/audio/voice-capture-wiring.ts +119 -0
  10. package/src/audio/voice-input-session.ts +181 -0
  11. package/src/audio/wake-inference.ts +182 -0
  12. package/src/audio/wake-runtime.ts +341 -0
  13. package/src/cli/bundle-command.ts +4 -1
  14. package/src/cli/help.ts +10 -0
  15. package/src/cli/management-utils.ts +4 -1
  16. package/src/cli/redaction.ts +68 -3
  17. package/src/config/goodvibes-home.ts +10 -0
  18. package/src/config/secret-config.ts +64 -8
  19. package/src/core/turn-event-wiring.ts +7 -16
  20. package/src/core/voice-capture-status.ts +53 -0
  21. package/src/core/voice-stt-gateway.ts +75 -0
  22. package/src/core/wake-provision-runner.ts +86 -0
  23. package/src/core/wake-provision-status.ts +170 -0
  24. package/src/core/workstream-notification.ts +69 -0
  25. package/src/daemon/cli.ts +86 -5
  26. package/src/daemon/handlers/credentials.ts +15 -5
  27. package/src/daemon/provision-wake-model.ts +81 -0
  28. package/src/daemon/send/channels.ts +200 -0
  29. package/src/daemon/send/command.ts +332 -0
  30. package/src/daemon/send/composition.ts +99 -0
  31. package/src/daemon/send/failure-text.ts +93 -0
  32. package/src/daemon/send/inert-text.ts +225 -0
  33. package/src/daemon/send/stdin.ts +24 -0
  34. package/src/input/command-registry.ts +6 -0
  35. package/src/input/commands/config.ts +68 -2
  36. package/src/input/commands/experience-runtime.ts +37 -3
  37. package/src/input/commands/local-provider-runtime.ts +9 -1
  38. package/src/input/commands/operator-runtime.ts +6 -1
  39. package/src/input/commands/payment-card-intake.ts +200 -0
  40. package/src/input/commands/profile-render.ts +250 -0
  41. package/src/input/commands/profile-runtime.ts +362 -0
  42. package/src/input/commands/profile-types.ts +381 -0
  43. package/src/input/commands.ts +7 -0
  44. package/src/input/composer-editor.ts +4 -0
  45. package/src/input/handler-shortcuts.ts +8 -0
  46. package/src/input/keybindings.ts +10 -1
  47. package/src/input/onboarding/onboarding-feature-units.ts +5 -5
  48. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  49. package/src/input/payments-config.ts +167 -0
  50. package/src/input/provider-key-intake.ts +10 -2
  51. package/src/input/settings-modal-commit.ts +146 -0
  52. package/src/input/settings-modal-data.ts +30 -0
  53. package/src/input/settings-modal-reset.ts +6 -2
  54. package/src/input/settings-modal-secrets.ts +9 -2
  55. package/src/input/settings-modal-types.ts +30 -2
  56. package/src/input/settings-modal.ts +27 -107
  57. package/src/main.ts +12 -14
  58. package/src/panels/modals/marketplace-modal.ts +5 -0
  59. package/src/panels/modals/skills-modal.ts +5 -0
  60. package/src/renderer/process-indicator.ts +66 -0
  61. package/src/renderer/settings-modal-helpers.ts +8 -2
  62. package/src/renderer/settings-modal.ts +16 -1
  63. package/src/renderer/shell-surface.ts +22 -3
  64. package/src/runtime/bootstrap-core.ts +7 -7
  65. package/src/runtime/bootstrap.ts +3 -3
  66. package/src/runtime/device-posture-composition.ts +110 -0
  67. package/src/runtime/disposal-wiring.ts +16 -0
  68. package/src/runtime/install-self-check-startup.ts +23 -0
  69. package/src/runtime/onboarding/types.ts +6 -1
  70. package/src/runtime/path-shadow-startup.ts +243 -0
  71. package/src/runtime/path-shadow.ts +418 -0
  72. package/src/runtime/reachability-notice.ts +100 -0
  73. package/src/runtime/runtime-services-types.ts +260 -0
  74. package/src/runtime/services.ts +95 -209
  75. package/src/runtime/voice-setup-services.ts +50 -4
  76. package/src/shell/voice-capture-shell.ts +69 -0
  77. package/src/verification/verification-ledger.ts +163 -24
  78. package/src/version.ts +1 -1
@@ -1,6 +1,56 @@
1
1
  export const REDACTED_VALUE = '<redacted>';
2
2
 
3
+ // Matches a config path whose LAST SEGMENT is exactly one of these words. That
4
+ // is the whole reach of this pattern, and it is why the declared list below
5
+ // exists: `surfaces.calendar.caldavPassword` does not end in a `.password`
6
+ // segment, it ends in a `caldavPassword` segment, so this pattern answers false
7
+ // for it — as it does for `imapPassword`, `appPassword` and `authToken`.
3
8
  const SENSITIVE_PATH_PATTERN = /(^|\.)(apiKey|accessToken|botToken|appToken|signingSecret|webhookSecret|verifyToken|verificationToken|secret|password|token|keyFile)$/i;
9
+
10
+ // Credential-bearing config keys that are sensitive by NAME rather than by the
11
+ // generic trailing-word list above. Every one of these was carried in the clear
12
+ // by a support bundle before it was listed here, because the pattern matches a
13
+ // trailing WORD and these names carry the credential word in the middle or
14
+ // prefixed by a protocol ("caldavPassword", "imapPassword", "appPassword",
15
+ // "authToken").
16
+ //
17
+ // The `*Ref` Cloudflare keys normally hold a `goodvibes://secrets/...`
18
+ // reference, which shouldRedactValue already lets through as safe. They are
19
+ // listed as a BACKSTOP, for the same reason the mail keys are worth listing
20
+ // twice over: it costs nothing, and it is what stands between a future code
21
+ // path that writes a literal token there and that token reaching a file the
22
+ // owner emails to someone for support.
23
+ const SENSITIVE_CONFIG_KEYS: ReadonlySet<string> = new Set([
24
+ // Mailbox / CalDAV — the credentials the daemon polls mail and calendar with.
25
+ 'surfaces.email.password',
26
+ 'surfaces.email.imapPassword',
27
+ 'surfaces.email.imap.password',
28
+ 'surfaces.email.smtp.password',
29
+ 'surfaces.calendar.caldavPassword',
30
+ // Telephony.
31
+ 'surfaces.telephony.authToken',
32
+ 'surfaces.msteams.appPassword',
33
+ // Cloudflare token references.
34
+ 'cloudflare.apiTokenRef',
35
+ 'cloudflare.tunnelTokenRef',
36
+ 'cloudflare.accessServiceTokenRef',
37
+ 'cloudflare.workerTokenRef',
38
+ 'cloudflare.workerClientTokenRef',
39
+ // Cluster shared phrase.
40
+ 'cluster.secret',
41
+ // Payment card material. ADDED to this set, never substituted for it: this
42
+ // set is a hardcoded list with no delegation, so replacing it wholesale with
43
+ // another product's would silently stop redacting whatever that one omits.
44
+ // The four names below end in no word the suffix pattern knows —
45
+ // "cardNumber", "cardExpiry" and "cardholderName" match none of them — which
46
+ // is the whole reason a declared list exists rather than a naming habit.
47
+ // (The `.map` below lowercases every entry; the lookup lowercases too.)
48
+ 'payments.cardNumber',
49
+ 'payments.cardExpiry',
50
+ 'payments.cardCvv',
51
+ 'payments.cardholderName',
52
+ ].map((key) => key.toLowerCase()));
53
+
4
54
  const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
5
55
  /\bsk-[A-Za-z0-9_-]{16,}\b/g,
6
56
  /\bghp_[A-Za-z0-9_]{16,}\b/g,
@@ -13,7 +63,7 @@ const SECRET_LIKE_TEXT_PATTERNS: readonly RegExp[] = [
13
63
  ];
14
64
 
15
65
  export function isSensitiveConfigPath(path: string): boolean {
16
- return SENSITIVE_PATH_PATTERN.test(path);
66
+ return SENSITIVE_PATH_PATTERN.test(path) || SENSITIVE_CONFIG_KEYS.has(path.toLowerCase());
17
67
  }
18
68
 
19
69
  export function isRedactedValue(value: unknown): boolean {
@@ -25,9 +75,14 @@ export interface RedactedConfigResult<T> {
25
75
  readonly redactedPaths: readonly string[];
26
76
  }
27
77
 
78
+ // Redaction rule for sensitive config paths:
79
+ // - Non-string values: redact if truthy (i.e. non-null, non-undefined, non-zero, non-false).
80
+ // Rationale: zero and false are never meaningful secrets; null/undefined mean absent.
81
+ // - String values: redact non-empty strings that are not goodvibes:// secret refs.
82
+ // Rationale: empty string means unset; secret refs are safe placeholders, not raw values.
28
83
  function shouldRedactValue(path: string, value: unknown): boolean {
29
84
  if (!isSensitiveConfigPath(path)) return false;
30
- if (typeof value !== 'string') return value !== undefined && value !== null;
85
+ if (typeof value !== 'string') return value !== null && value !== undefined && Boolean(value);
31
86
  if (value.trim().length === 0) return false;
32
87
  if (value.startsWith('goodvibes://secrets/')) return false;
33
88
  return true;
@@ -64,7 +119,17 @@ export function redactConfig<T>(config: T): RedactedConfigResult<T> {
64
119
  }
65
120
 
66
121
  export function redactText(input: string): string {
67
- let output = input;
122
+ // Assignment form: keyword=value — anchored so 'monkey=' and 'donkey=' do NOT match.
123
+ // Matches: token=, access_token=, api_key=, api-key=, secret=, password= and colon form token: value
124
+ let output = input
125
+ .replace(
126
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*=\s*([^ \t\r\n"'`]+)/gi,
127
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
128
+ )
129
+ .replace(
130
+ /(?<![A-Za-z])(?:access_token|api[_-]?key|secret|password|token)\s*:\s*([^ \t\r\n"'`]+)/gi,
131
+ (m, val) => m.slice(0, m.length - val.length) + REDACTED_VALUE,
132
+ );
68
133
  for (const pattern of SECRET_LIKE_TEXT_PATTERNS) {
69
134
  output = output.replace(pattern, REDACTED_VALUE);
70
135
  }
@@ -44,6 +44,15 @@ export interface GoodVibesHomeOwnership {
44
44
  readonly homeDirectory: string;
45
45
  /** The daemon's own identity directory, under the tree root by default. */
46
46
  readonly daemonHomeDirectory: string;
47
+ /**
48
+ * True when either `GOODVIBES_HOME` or `GOODVIBES_DAEMON_HOME` named an
49
+ * override (a blank/whitespace-only value does not count — see
50
+ * resolveGoodVibesHome). Callers that need to know "is this process running
51
+ * out of a relocated tree or identity directory" (e.g. to refuse adopting
52
+ * the machine's real service unit) read this instead of re-reading the raw
53
+ * environment variable themselves — this module is the one reader.
54
+ */
55
+ readonly isOverridden: boolean;
47
56
  }
48
57
 
49
58
  /**
@@ -117,6 +126,7 @@ export function resolveGoodVibesHomeOwnership(
117
126
  return {
118
127
  homeDirectory,
119
128
  daemonHomeDirectory: resolveGoodVibesDaemonHome(homeDirectory, env),
129
+ isOverridden: Boolean(env['GOODVIBES_HOME']?.trim()) || Boolean(env['GOODVIBES_DAEMON_HOME']?.trim()),
120
130
  };
121
131
  }
122
132
 
@@ -1,8 +1,27 @@
1
- import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
1
+ import { isSecretRefInput, isDaemonOwnedConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { ConfigKey } from './index.ts';
3
3
  import type { SecretScope, SecretStorageMedium } from './secrets.ts';
4
4
 
5
5
  export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
6
+ // Mailbox and CalDAV credentials. Their own CONFIG_SCHEMA descriptions read
7
+ // "Stored in the daemon secret tier, never in config" — and until they were
8
+ // listed here that sentence was aspirational: the settings modal wrote them
9
+ // as plain strings into a config JSON file, because membership in this set is
10
+ // the thing that routes an edit through the secret manager instead.
11
+ //
12
+ // The daemon reads each of these back with
13
+ // resolveConfigSecret('<key>') → GOODVIBES_<KEY>, which is exactly the store
14
+ // key buildGoodVibesSecretKey() writes — see
15
+ // daemon/handlers/inbox/providers/email.ts.
16
+ 'surfaces.email.password',
17
+ 'surfaces.email.imapPassword',
18
+ 'surfaces.email.imap.password',
19
+ 'surfaces.email.smtp.password',
20
+ 'surfaces.calendar.caldavPassword',
21
+ // Telephony delivery credentials — same shape, same file, same gap.
22
+ 'surfaces.telephony.authToken',
23
+ 'surfaces.telephony.token',
24
+ 'surfaces.telephony.webhookSecret',
6
25
  'surfaces.slack.signingSecret',
7
26
  'surfaces.slack.botToken',
8
27
  'surfaces.slack.appToken',
@@ -23,6 +42,16 @@ export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
23
42
  'surfaces.bluebubbles.password',
24
43
  'surfaces.mattermost.botToken',
25
44
  'surfaces.matrix.accessToken',
45
+ // TUI-local synthetic sub-keys, one level under the SDK's real `payments`
46
+ // section (not yet a scalar CONFIG_SCHEMA entry — same situation as
47
+ // tts.speed, behavior.notifyAfterSeconds, etc. in settings-modal-data.ts),
48
+ // hence the cast. See input/payments-config.ts for why these are named flat
49
+ // (payments.cardNumber, not payments.card.number): a flat one-level leaf is
50
+ // the shape the real ConfigManager tolerates for an undeclared key.
51
+ 'payments.cardNumber' as ConfigKey,
52
+ 'payments.cardExpiry' as ConfigKey,
53
+ 'payments.cardCvv' as ConfigKey,
54
+ 'payments.cardholderName' as ConfigKey,
26
55
  ]);
27
56
 
28
57
  export interface SecretBackedConfigUpdate {
@@ -96,6 +125,28 @@ export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: st
96
125
  };
97
126
  }
98
127
 
128
+ /**
129
+ * Where a secret-backed write lands when the caller did not name a scope.
130
+ *
131
+ * A daemon-owned config key (`surfaces.*`, `payments.*`, `controlPlane.*`, ...)
132
+ * names a credential the DAEMON executes with, not this interactive client, so
133
+ * its secret material belongs in the daemon-scoped tier the daemon actually
134
+ * reads — the same rule the SDK's config-ownership.ts already applies to the
135
+ * `goodvibes://` reference that points at it.
136
+ *
137
+ * Defaulting these to 'user' (the historical behavior here) split the pair: the
138
+ * reference landed in the daemon's own settings file, because ConfigManager
139
+ * routes daemon-owned keys there, while the value it pointed at sat in a tier
140
+ * the daemon never resolves. The surface reported success and the daemon found
141
+ * nothing. For the mailbox password that is the whole feature failing silently —
142
+ * the daemon is the process that polls IMAP and answers over Telegram, and it
143
+ * does so with every surface closed. A payment card entered through
144
+ * /payments card is the same shape of failure at purchase time.
145
+ */
146
+ export function defaultSecretBackedScope(configKey: ConfigKey): SecretScope {
147
+ return isDaemonOwnedConfigKey(configKey) ? 'daemon' : 'user';
148
+ }
149
+
99
150
  export async function persistSecretBackedConfigValue(
100
151
  configManager: SecretBackedConfigManager,
101
152
  secretsManager: SecretBackedSecretStore | null | undefined,
@@ -104,16 +155,21 @@ export async function persistSecretBackedConfigValue(
104
155
  options: { readonly scope?: SecretScope } = {},
105
156
  ): Promise<string> {
106
157
  const update = buildSecretBackedConfigUpdate(configKey, rawValue);
107
- const scope = options.scope ?? 'user';
158
+ const scope = options.scope ?? defaultSecretBackedScope(configKey);
159
+ const medium = getSecretWriteMedium(configManager.get('storage.secretPolicy'));
160
+
161
+ // 1. Validate config write first. If setDynamic throws, no secret is written (avoids orphans).
162
+ configManager.setDynamic(configKey, update.configValue);
163
+
164
+ // 2. Write new secret only after config accepted it.
108
165
  if (update.secretKey && update.secretValue !== undefined && secretsManager) {
109
- await secretsManager.set(update.secretKey, update.secretValue, {
110
- scope,
111
- medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
112
- });
166
+ await secretsManager.set(update.secretKey, update.secretValue, { scope, medium });
113
167
  }
168
+
169
+ // 3. Clear old secret — pass the same medium so plaintext-medium secrets are found for deletion.
114
170
  if (update.clearSecretKey && secretsManager?.delete) {
115
- await secretsManager.delete(update.clearSecretKey, { scope });
171
+ await secretsManager.delete(update.clearSecretKey, { scope, medium });
116
172
  }
117
- configManager.setDynamic(configKey, update.configValue);
173
+
118
174
  return update.configValue;
119
175
  }
@@ -1,7 +1,7 @@
1
1
  import type { UiRuntimeEvents, RuntimeEventBus } from '@/runtime/index.ts';
2
2
  import { buildPersistedSessionContext, persistConversation } from '@/runtime/index.ts';
3
3
  import { buildCompactionReceiptBlock } from './compaction-receipt.ts';
4
- import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
4
+ import { workstreamFailureNotification } from './workstream-notification.ts';
5
5
  import { persistTurnAnchors, recordTurnAnchor, summarizeTurnLabel } from './rewind-turn-anchors.ts';
6
6
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
7
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -328,21 +328,12 @@ export function wireTurnEventHandlers(
328
328
  unsubs.push(events.workflows.on('WORKFLOW_CHAIN_FAILED', (payload) => {
329
329
  if (!shouldFireAlert(focusTracker, configGet, 'behavior.notifyOnChainFailure')) return;
330
330
  try {
331
- // An operator cancellation is an intended stop, not a failure — narrate it
332
- // as cancelled (the reason already carries the landed-work count from the
333
- // chain's edit ledger), so the notification never contradicts the cancelled
334
- // chain/owner/cohort surfaces.
335
- if (payload.failureKind === 'cancelled') {
336
- notifyCompletion('GoodVibes — WRFC chain cancelled', `chain ${payload.chainId.slice(0, 12)} cancelled: ${payload.reason}`, FORCE_NOTIFY_DURATION_MS);
337
- } else if (payload.failureKind === 'max_turns') {
338
- // A turn-budget exhaustion is a spent ceiling, not an infrastructure
339
- // failure — narrate the limit and where it came from, from the typed
340
- // event fields, never a regex of the prose reason.
341
- notifyCompletion('GoodVibes — WRFC chain hit its turn budget', `chain ${payload.chainId.slice(0, 12)} ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`, FORCE_NOTIFY_DURATION_MS);
342
- } else {
343
- const kindLabel = payload.failureKind === 'transport' ? 'transient transport error' : payload.reason;
344
- notifyCompletion('GoodVibes — WRFC chain failed', `chain ${payload.chainId.slice(0, 12)} failed: ${kindLabel}`, FORCE_NOTIFY_DURATION_MS);
345
- }
331
+ // Title and body come from workstream-notification.ts, which is where
332
+ // the three branches (cancelled / turn budget / failed) are narrated and
333
+ // tested. A notification is a message to a person, so it carries neither
334
+ // the internal name for the machinery nor the chain id.
335
+ const notice = workstreamFailureNotification(payload);
336
+ notifyCompletion(notice.title, notice.body, FORCE_NOTIFY_DURATION_MS);
346
337
  } catch (err) {
347
338
  logger.debug('turn-event-wiring: chain-failure notify error', { error: String(err) });
348
339
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * voice-capture-status.ts — what the shell shows while a microphone is open.
3
+ *
4
+ * A capture indicator is not decoration. Two of the three states here exist
5
+ * because the alternative is a user who cannot tell whether their microphone is
6
+ * live: wake detection holds a device open for as long as the feature is on, and
7
+ * push-to-talk holds one open between two keypresses. `voice.wake.indicator`
8
+ * governs how prominent the detector's row is; a push-to-talk recording is
9
+ * always shown, because the user is mid-act and waiting for it.
10
+ *
11
+ * The shape lives here rather than in the renderer or in src/audio so both can
12
+ * use it: the audio layer must not import shell-UI (the architecture check
13
+ * enforces that), and the renderer must not own capture state.
14
+ */
15
+
16
+ /** What a voice-capture row is reporting. */
17
+ export type VoiceCaptureIndicatorKind =
18
+ /** Push-to-talk: the user pressed the key and is speaking now. */
19
+ 'recording'
20
+ /** Push-to-talk: the device is being opened (a permission prompt takes real time). */
21
+ | 'requesting'
22
+ /** Push-to-talk: capture ended and the audio is being transcribed. */
23
+ | 'transcribing'
24
+ /** Wake detection is listening and scoring frames. */
25
+ | 'wake-listening'
26
+ /** A wake confirmed; the utterance that followed it is being captured. */
27
+ | 'wake-capturing'
28
+ /** The detector's stream died and a restart is scheduled. */
29
+ | 'wake-restarting'
30
+ /** The supervisor gave up; the detector is off until the feature is toggled. */
31
+ | 'wake-latched';
32
+
33
+ /** One live capture row. Absent (null) means no microphone is open and no row renders. */
34
+ export interface VoiceCaptureIndicatorState {
35
+ readonly kind: VoiceCaptureIndicatorKind;
36
+ /** What opened the device, e.g. `parecord`; null before anything is open. */
37
+ readonly deviceLabel: string | null;
38
+ /**
39
+ * Prominence for the wake rows, from `voice.wake.indicator`. `off` suppresses
40
+ * them entirely. Push-to-talk rows ignore it: the user asked for that capture
41
+ * one keypress ago and is owed the confirmation.
42
+ */
43
+ readonly indicator: 'off' | 'statusline' | 'banner';
44
+ /** Extra words for the row — a restart delay, a latch reason, an elapsed time. */
45
+ readonly detail?: string | undefined;
46
+ }
47
+
48
+ /** True when this state should produce a footer row at all. */
49
+ export function voiceCaptureRowVisible(state: VoiceCaptureIndicatorState | null): boolean {
50
+ if (state === null) return false;
51
+ const isWakeRow = state.kind.startsWith('wake-');
52
+ return !isWakeRow || state.indicator !== 'off';
53
+ }
@@ -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');