@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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +9605 -3119
- package/package.json +9 -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/help.ts +10 -0
- package/src/cli/management-utils.ts +4 -1
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +10 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- 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/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +86 -5
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/command-registry.ts +6 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/experience-runtime.ts +37 -3
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands.ts +7 -0
- package/src/input/composer-editor.ts +4 -0
- 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/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +30 -2
- package/src/input/settings-modal.ts +27 -107
- package/src/main.ts +12 -14
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/process-indicator.ts +66 -0
- package/src/renderer/settings-modal-helpers.ts +8 -2
- package/src/renderer/settings-modal.ts +16 -1
- 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/install-self-check-startup.ts +23 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +260 -0
- package/src/runtime/services.ts +95 -209
- package/src/runtime/voice-setup-services.ts +50 -4
- package/src/shell/voice-capture-shell.ts +69 -0
- package/src/verification/verification-ledger.ts +163 -24
- package/src/version.ts +1 -1
package/src/cli/redaction.ts
CHANGED
|
@@ -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 !==
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// chain
|
|
335
|
-
|
|
336
|
-
|
|
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');
|