@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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workstream-notification.ts — the desktop notification a finished workstream
|
|
3
|
+
* pushes, in words rather than identifiers.
|
|
4
|
+
*
|
|
5
|
+
* These three notifications used to read:
|
|
6
|
+
*
|
|
7
|
+
* GoodVibes — WRFC chain failed
|
|
8
|
+
* chain 7f3a91c02b4e failed: review rejected
|
|
9
|
+
*
|
|
10
|
+
* A desktop notification is a message TO a person, so the standing rule applies
|
|
11
|
+
* to it the same way it applies to a chat message: no internal name for the
|
|
12
|
+
* machinery, and no register id. `WRFC` is the first; `7f3a91c02b4e` is the
|
|
13
|
+
* second, and it is not something the reader can do anything with — it is not
|
|
14
|
+
* the commit, not the branch, not the session.
|
|
15
|
+
*
|
|
16
|
+
* What the reader can act on is why the work stopped, so that is what the body
|
|
17
|
+
* now leads with. Two workstreams that end at the same moment are told apart by
|
|
18
|
+
* their reasons, which is the same principle the channel renderer follows: in
|
|
19
|
+
* plain words, never by an opaque identifier.
|
|
20
|
+
*
|
|
21
|
+
* Split out of turn-event-wiring.ts as a pure function so the text is testable
|
|
22
|
+
* on its own — the wiring itself cannot be asserted against without mocking a
|
|
23
|
+
* process-global notifier.
|
|
24
|
+
*/
|
|
25
|
+
import { formatTurnBudgetOutcome } from './turn-budget-outcome.ts';
|
|
26
|
+
|
|
27
|
+
/** The fields of WORKFLOW_CHAIN_FAILED this narration reads. */
|
|
28
|
+
export interface WorkstreamFailureNarrationInput {
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
readonly failureKind?: 'transport' | 'other' | 'cancelled' | 'max_turns' | undefined;
|
|
31
|
+
readonly turnLimit?: number | undefined;
|
|
32
|
+
readonly turnLimitSource?: 'default' | 'spawn-override' | 'policy-bound' | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkstreamNotification {
|
|
36
|
+
readonly title: string;
|
|
37
|
+
readonly body: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Title and body for a workstream that reached a terminal state.
|
|
42
|
+
*
|
|
43
|
+
* An operator cancellation is an intended stop, not a failure — it is narrated
|
|
44
|
+
* as cancelled (the reason already carries the landed-work count from the
|
|
45
|
+
* workstream's edit ledger) so the notification never contradicts the cancelled
|
|
46
|
+
* workstream/owner/cohort surfaces. A turn-budget exhaustion is a spent ceiling
|
|
47
|
+
* rather than an infrastructure error, and its limit and source are read from
|
|
48
|
+
* the typed event fields, never from a regex of the prose reason.
|
|
49
|
+
*/
|
|
50
|
+
export function workstreamFailureNotification(
|
|
51
|
+
payload: WorkstreamFailureNarrationInput,
|
|
52
|
+
): WorkstreamNotification {
|
|
53
|
+
if (payload.failureKind === 'cancelled') {
|
|
54
|
+
return {
|
|
55
|
+
title: 'GoodVibes — workstream cancelled',
|
|
56
|
+
body: `Cancelled: ${payload.reason}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (payload.failureKind === 'max_turns') {
|
|
60
|
+
return {
|
|
61
|
+
title: 'GoodVibes — workstream hit its turn budget',
|
|
62
|
+
body: `The workstream ${formatTurnBudgetOutcome({ limit: payload.turnLimit, source: payload.turnLimitSource })}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
title: 'GoodVibes — workstream failed',
|
|
67
|
+
body: `Failed: ${payload.failureKind === 'transport' ? 'transient transport error' : payload.reason}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/daemon/cli.ts
CHANGED
|
@@ -7,11 +7,12 @@ import {
|
|
|
7
7
|
} from '@pellux/goodvibes-sdk/platform/config';
|
|
8
8
|
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
9
9
|
import { formatProviderModel, getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
|
|
10
|
-
import { resolveGoodVibesHomeOwnership
|
|
11
|
-
import { RuntimeEventBus, GlobalNetworkTransportInstaller } from '@/runtime/index.ts';
|
|
10
|
+
import { resolveGoodVibesHomeOwnership } from '../config/goodvibes-home.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,10 @@ 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';
|
|
39
|
+
import { runSendCommand } from './send/command.ts';
|
|
40
|
+
import { createSendStack } from './send/composition.ts';
|
|
41
|
+
import { readAllStdin } from './send/stdin.ts';
|
|
37
42
|
import { isDaemonServiceSubcommand, resolveInstalledDaemonBinary, runDaemonServiceCli } from './service-commands.ts';
|
|
38
43
|
import { runClusterCommand } from '../cluster/commands.ts';
|
|
39
44
|
import { resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
|
|
@@ -59,6 +64,8 @@ type DaemonCliOwnership = {
|
|
|
59
64
|
readonly homeDirectory: string;
|
|
60
65
|
/** The daemon's OWN identity home (auth users, operator tokens, daemon settings). */
|
|
61
66
|
readonly daemonHomeDirectory: string;
|
|
67
|
+
/** True when GOODVIBES_HOME or GOODVIBES_DAEMON_HOME named an override — see goodvibes-home.ts. */
|
|
68
|
+
readonly isOverridden: boolean;
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
// CLI flag parsing delegated to shared module — see src/cli-flags.ts
|
|
@@ -87,11 +94,12 @@ function resolveDaemonCliOwnership(): DaemonCliOwnership {
|
|
|
87
94
|
// point also uses. They were resolved independently, and the client's copy
|
|
88
95
|
// simply did not read GOODVIBES_HOME — so a redirected client wrote into the
|
|
89
96
|
// real tree while the daemon honoured the redirect.
|
|
90
|
-
const { homeDirectory, daemonHomeDirectory } = resolveGoodVibesHomeOwnership();
|
|
97
|
+
const { homeDirectory, daemonHomeDirectory, isOverridden } = resolveGoodVibesHomeOwnership();
|
|
91
98
|
return {
|
|
92
99
|
workingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
|
|
93
100
|
homeDirectory,
|
|
94
101
|
daemonHomeDirectory,
|
|
102
|
+
isOverridden,
|
|
95
103
|
};
|
|
96
104
|
}
|
|
97
105
|
|
|
@@ -137,6 +145,63 @@ async function main(): Promise<void> {
|
|
|
137
145
|
process.exit(result.exitCode);
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
// `send …` is intercepted here for both of the reasons `cluster` is, plus one
|
|
149
|
+
// of its own.
|
|
150
|
+
//
|
|
151
|
+
// Before the parser, because the message is arbitrary operator text: a
|
|
152
|
+
// message beginning with a dash, or one carrying `--port` inside it, must
|
|
153
|
+
// reach the channel rather than be eaten as a daemon flag.
|
|
154
|
+
//
|
|
155
|
+
// Before the runtime, because this composes only the services a delivery
|
|
156
|
+
// needs (see send/composition.ts) and must not start a second copy of the
|
|
157
|
+
// pollers, cluster election and LAN scan a running daemon already owns.
|
|
158
|
+
//
|
|
159
|
+
// And it must work when NO daemon is running, which is much of the point: the
|
|
160
|
+
// reason to message the owner is usually that something stopped.
|
|
161
|
+
if (rawArgs[0] === 'send') {
|
|
162
|
+
const ownership = resolveDaemonCliOwnership();
|
|
163
|
+
runDaemonConfigMigration(ownership.homeDirectory);
|
|
164
|
+
const stack = createSendStack({
|
|
165
|
+
workingDirectory: ownership.workingDirectory,
|
|
166
|
+
homeDirectory: ownership.homeDirectory,
|
|
167
|
+
daemonHomeDirectory: ownership.daemonHomeDirectory,
|
|
168
|
+
});
|
|
169
|
+
const result = await runSendCommand(rawArgs.slice(1), {
|
|
170
|
+
configManager: stack.configManager,
|
|
171
|
+
deliver: stack.deliver,
|
|
172
|
+
readStdin: readAllStdin,
|
|
173
|
+
stdinIsTty: process.stdin.isTTY === true,
|
|
174
|
+
});
|
|
175
|
+
for (const line of result.lines) {
|
|
176
|
+
// eslint-disable-next-line no-console
|
|
177
|
+
if (result.exitCode === 0) console.log(line);
|
|
178
|
+
else console.error(line);
|
|
179
|
+
}
|
|
180
|
+
// The activity log holds the OUTBOUND_HTTP record for the send that just
|
|
181
|
+
// happened (or did not); a process exiting this promptly would drop it.
|
|
182
|
+
flushActivityLogSync();
|
|
183
|
+
process.exit(result.exitCode);
|
|
184
|
+
}
|
|
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
|
+
|
|
140
205
|
// Parse CLI flags first so --daemon-home and --working-dir env vars are set
|
|
141
206
|
// before resolveDaemonCliOwnership() reads them.
|
|
142
207
|
const cli = parseGoodVibesCli(process.argv.slice(2), 'goodvibes-daemon');
|
|
@@ -169,7 +234,7 @@ async function main(): Promise<void> {
|
|
|
169
234
|
logger.info('daemon: --working-dir flag applied', { workingDir: cliFlags.workingDir });
|
|
170
235
|
}
|
|
171
236
|
|
|
172
|
-
const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory } = resolveDaemonCliOwnership();
|
|
237
|
+
const { workingDirectory: workingDir, homeDirectory, daemonHomeDirectory, isOverridden: hasOverriddenHome } = resolveDaemonCliOwnership();
|
|
173
238
|
runDaemonConfigMigration(homeDirectory);
|
|
174
239
|
const config = new ConfigManager({ workingDir, homeDir: homeDirectory, surfaceRoot: 'tui' });
|
|
175
240
|
new GlobalNetworkTransportInstaller().install(config);
|
|
@@ -299,6 +364,9 @@ async function main(): Promise<void> {
|
|
|
299
364
|
}
|
|
300
365
|
}
|
|
301
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)));
|
|
302
370
|
const runtimeBus = new RuntimeEventBus();
|
|
303
371
|
const runtimeStore = createRuntimeStore();
|
|
304
372
|
// Gate states derive from the domain settings keys; the bridge keeps live
|
|
@@ -330,6 +398,13 @@ async function main(): Promise<void> {
|
|
|
330
398
|
// the non-spawning unavailable seam; only daemon compositions opt in. Mirrors
|
|
331
399
|
// the SDK daemon cli's createHostPowerSeam() (sdk commit 3a5ea26d).
|
|
332
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,
|
|
333
408
|
});
|
|
334
409
|
|
|
335
410
|
// Load persisted providers from disk so the provider registry is pre-populated
|
|
@@ -371,7 +446,7 @@ async function main(): Promise<void> {
|
|
|
371
446
|
// found the unit not running, wrote its own scratchpad ExecStart into
|
|
372
447
|
// ~/.config/systemd/user/goodvibes.service and exited, and systemd then
|
|
373
448
|
// supervised the throwaway as the machine's daemon for five hours.
|
|
374
|
-
hasOverriddenHome
|
|
449
|
+
hasOverriddenHome,
|
|
375
450
|
// The SAME coordinator this repository's inbox poller registered with (see
|
|
376
451
|
// runtime/cluster-composition.ts). The facade must reuse it rather than
|
|
377
452
|
// compose its own: two coordinators in one process are two nodes in the
|
|
@@ -416,6 +491,12 @@ async function main(): Promise<void> {
|
|
|
416
491
|
// box that has been off for months re-keys itself with no operator action.
|
|
417
492
|
await runtimeServices.startCluster();
|
|
418
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
|
+
|
|
419
500
|
await Promise.all([
|
|
420
501
|
daemon.start(),
|
|
421
502
|
config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
-
import type { SecretsManager } from '../../config/secrets.ts';
|
|
2
|
+
import type { SecretsManager, SecretScope } from '../../config/secrets.ts';
|
|
3
3
|
import {
|
|
4
4
|
buildGoodVibesSecretKey,
|
|
5
5
|
isSecretReferenceValue,
|
|
@@ -16,11 +16,21 @@ export interface DaemonCredentialStore {
|
|
|
16
16
|
* (e.g. 'surfaces.slack.botToken' → GOODVIBES_SURFACES_SLACK_BOT_TOKEN).
|
|
17
17
|
*/
|
|
18
18
|
resolveConfigSecret(configKey: string): Promise<string | null>;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Store a daemon-owned credential. scope defaults 'daemon'.
|
|
21
|
+
*
|
|
22
|
+
* This is the DAEMON's own credential store: every caller is daemon code, and
|
|
23
|
+
* every value written through it is one the daemon itself has to read back —
|
|
24
|
+
* including its own generated draft encryption key (createAtRestCipher below,
|
|
25
|
+
* which calls put() with no scope at all). Defaulting to 'user' filed those
|
|
26
|
+
* in a surface-scoped tier, so the daemon's own material lived somewhere it
|
|
27
|
+
* only found by accident, and a value written on one surface's behalf was
|
|
28
|
+
* invisible to the next.
|
|
29
|
+
*/
|
|
20
30
|
put(
|
|
21
31
|
secretKey: string,
|
|
22
32
|
value: string,
|
|
23
|
-
opts?: { scope?:
|
|
33
|
+
opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
|
|
24
34
|
): Promise<void>;
|
|
25
35
|
has(secretKey: string): Promise<boolean>;
|
|
26
36
|
}
|
|
@@ -45,10 +55,10 @@ export function createDaemonCredentialStore(secrets: SecretsManager): DaemonCred
|
|
|
45
55
|
async put(
|
|
46
56
|
secretKey: string,
|
|
47
57
|
value: string,
|
|
48
|
-
opts?: { scope?:
|
|
58
|
+
opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
|
|
49
59
|
): Promise<void> {
|
|
50
60
|
await secrets.set(secretKey, value, {
|
|
51
|
-
scope: opts?.scope ?? '
|
|
61
|
+
scope: opts?.scope ?? 'daemon',
|
|
52
62
|
medium: opts?.medium ?? 'secure',
|
|
53
63
|
});
|
|
54
64
|
},
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.ts — which channels `goodvibes-daemon send` can reach, and which one
|
|
3
|
+
* it uses when the operator names none.
|
|
4
|
+
*
|
|
5
|
+
* The surface list, labels and required-setup keys are IMPORTED from
|
|
6
|
+
* `src/cli/surface-command.ts` rather than restated, so `goodvibes surfaces
|
|
7
|
+
* list` and `goodvibes-daemon send --list` can never disagree about what a
|
|
8
|
+
* channel is called or what it needs. What this module adds on top is the two
|
|
9
|
+
* facts that command needs and `SURFACE_CONFIGS` does not carry: the routable
|
|
10
|
+
* `ChannelDeliverySurfaceKind` for each surface id (they differ in case —
|
|
11
|
+
* `googleChat` vs `google-chat`), and the settings key holding each surface's
|
|
12
|
+
* default destination.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ConfigKey, ConfigManager } from '../../config/index.ts';
|
|
16
|
+
import type { ChannelDeliverySurfaceKind } from '@pellux/goodvibes-sdk/platform/channels';
|
|
17
|
+
import { SURFACE_CONFIGS } from '../../cli/surface-command.ts';
|
|
18
|
+
import { canRenderInert } from './inert-text.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The settings key holding each supported surface's default destination — the
|
|
22
|
+
* chat, channel, topic or URL a message goes to when `--to` is not given.
|
|
23
|
+
*
|
|
24
|
+
* These are the SAME keys the delivery strategies in the SDK's
|
|
25
|
+
* `strategies-core.ts` fall back to (`surfaces.telegram.defaultChatId`,
|
|
26
|
+
* `surfaces.discord.defaultChannelId`, …). Only surfaces with a verified inert
|
|
27
|
+
* transform appear; see inert-text.ts for why the rest are refused rather than
|
|
28
|
+
* sent to.
|
|
29
|
+
*/
|
|
30
|
+
const DESTINATION_KEY_BY_SURFACE_ID = {
|
|
31
|
+
telegram: 'surfaces.telegram.defaultChatId',
|
|
32
|
+
ntfy: 'surfaces.ntfy.topic',
|
|
33
|
+
discord: 'surfaces.discord.defaultChannelId',
|
|
34
|
+
slack: 'surfaces.slack.defaultChannel',
|
|
35
|
+
googleChat: 'surfaces.googleChat.webhookUrl',
|
|
36
|
+
webhook: 'surfaces.webhook.defaultTarget',
|
|
37
|
+
signal: 'surfaces.signal.defaultRecipient',
|
|
38
|
+
whatsapp: 'surfaces.whatsapp.defaultRecipient',
|
|
39
|
+
imessage: 'surfaces.imessage.defaultChatId',
|
|
40
|
+
msteams: 'surfaces.msteams.defaultConversationId',
|
|
41
|
+
bluebubbles: 'surfaces.bluebubbles.defaultChatGuid',
|
|
42
|
+
mattermost: 'surfaces.mattermost.defaultChannelId',
|
|
43
|
+
matrix: 'surfaces.matrix.defaultRoomId',
|
|
44
|
+
// Not cast: every value is checked against the real ConfigKey union, so a
|
|
45
|
+
// key that is renamed or misspelled in the schema fails the build here
|
|
46
|
+
// instead of reading `undefined` at send time and reporting the channel as
|
|
47
|
+
// unconfigured.
|
|
48
|
+
} as const satisfies Readonly<Record<string, ConfigKey>>;
|
|
49
|
+
|
|
50
|
+
/** Surface ids whose routable kind is not simply the id itself. */
|
|
51
|
+
const SURFACE_KIND_BY_ID: Readonly<Record<string, ChannelDeliverySurfaceKind>> = {
|
|
52
|
+
googleChat: 'google-chat',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `--to` addresses on each channel, in that channel's own vocabulary.
|
|
57
|
+
*
|
|
58
|
+
* This is naming, not a scheme: `--to` becomes `ChannelDeliveryTarget.address`,
|
|
59
|
+
* which is the first thing every strategy in `strategies-core.ts`,
|
|
60
|
+
* `strategies-bridge.ts` and `strategies-enterprise.ts` already checks before
|
|
61
|
+
* falling back to its configured default. Nothing new is invented — the label
|
|
62
|
+
* exists so `--list` and the help can say "topic" where the channel says topic
|
|
63
|
+
* and "chat id" where it says chat id, rather than making the operator work out
|
|
64
|
+
* what an "address" is for ntfy.
|
|
65
|
+
*/
|
|
66
|
+
const ADDRESS_LABEL_BY_SURFACE_ID: Readonly<Record<string, string>> = {
|
|
67
|
+
telegram: 'chat id',
|
|
68
|
+
ntfy: 'topic',
|
|
69
|
+
discord: 'channel id',
|
|
70
|
+
slack: 'channel id',
|
|
71
|
+
googleChat: 'webhook URL',
|
|
72
|
+
webhook: 'URL',
|
|
73
|
+
signal: 'recipient',
|
|
74
|
+
whatsapp: 'recipient',
|
|
75
|
+
imessage: 'chat id',
|
|
76
|
+
msteams: 'conversation id',
|
|
77
|
+
bluebubbles: 'chat GUID',
|
|
78
|
+
mattermost: 'channel id',
|
|
79
|
+
matrix: 'room id',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface SendChannel {
|
|
83
|
+
/** The id an operator types after `--channel`, e.g. `telegram`. */
|
|
84
|
+
readonly id: string;
|
|
85
|
+
/** Human label, shared with `goodvibes surfaces list`. */
|
|
86
|
+
readonly label: string;
|
|
87
|
+
/** The kind the channel delivery router routes on. */
|
|
88
|
+
readonly surfaceKind: ChannelDeliverySurfaceKind;
|
|
89
|
+
/** `surfaces.<id>.enabled`. */
|
|
90
|
+
readonly enabledKey: ConfigKey;
|
|
91
|
+
/** The settings key holding this channel's default destination. */
|
|
92
|
+
readonly destinationKey: ConfigKey;
|
|
93
|
+
/** What `--to` names on this channel, in the channel's own words ("topic"). */
|
|
94
|
+
readonly addressLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every channel this command can deliver to. Derived, not restated: a surface
|
|
99
|
+
* that gains a delivery strategy and an inert transform appears here by adding
|
|
100
|
+
* its destination key above, and a surface that loses its entry in
|
|
101
|
+
* `SURFACE_CONFIGS` disappears from both commands at once.
|
|
102
|
+
*/
|
|
103
|
+
const destinationKeys: Readonly<Record<string, ConfigKey>> = DESTINATION_KEY_BY_SURFACE_ID;
|
|
104
|
+
|
|
105
|
+
export const SEND_CHANNELS: readonly SendChannel[] = SURFACE_CONFIGS
|
|
106
|
+
.flatMap(([id, label]): SendChannel[] => {
|
|
107
|
+
const destinationKey = destinationKeys[id];
|
|
108
|
+
if (!destinationKey) return [];
|
|
109
|
+
const surfaceKind = SURFACE_KIND_BY_ID[id] ?? (id as ChannelDeliverySurfaceKind);
|
|
110
|
+
if (!canRenderInert(surfaceKind)) return [];
|
|
111
|
+
return [{
|
|
112
|
+
id,
|
|
113
|
+
label,
|
|
114
|
+
surfaceKind,
|
|
115
|
+
enabledKey: `surfaces.${id}.enabled` as ConfigKey,
|
|
116
|
+
destinationKey,
|
|
117
|
+
addressLabel: ADDRESS_LABEL_BY_SURFACE_ID[id] ?? 'address',
|
|
118
|
+
}];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Look a channel up by the id an operator typed, accepting either the settings
|
|
123
|
+
* id (`googleChat`) or the routable kind (`google-chat`) — the two spellings
|
|
124
|
+
* are both visible in this product's own output, and making the operator
|
|
125
|
+
* remember which one this command wants would be a trap.
|
|
126
|
+
*/
|
|
127
|
+
export function findSendChannel(name: string): SendChannel | undefined {
|
|
128
|
+
const wanted = name.trim().toLowerCase();
|
|
129
|
+
return SEND_CHANNELS.find((channel) =>
|
|
130
|
+
channel.id.toLowerCase() === wanted || channel.surfaceKind.toLowerCase() === wanted);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ChannelReadiness {
|
|
134
|
+
readonly channel: SendChannel;
|
|
135
|
+
/** `surfaces.<id>.enabled` is true. */
|
|
136
|
+
readonly enabled: boolean;
|
|
137
|
+
/** The configured default destination, or null when the key is blank. */
|
|
138
|
+
readonly destination: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSetting(config: Pick<ConfigManager, 'get'>, key: ConfigKey): string {
|
|
142
|
+
const value = config.get(key);
|
|
143
|
+
return typeof value === 'string' ? value.trim() : value === undefined || value === null ? '' : String(value).trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read every channel's live state from config.
|
|
148
|
+
*
|
|
149
|
+
* The `ConfigManager` handed in must be one built with a `homeDir`, because
|
|
150
|
+
* every `surfaces.*` key is daemon-owned: the manager overlays
|
|
151
|
+
* `<home>/.goodvibes/daemon/settings.json` LAST, which is the only reason a
|
|
152
|
+
* client process can see a bot token the daemon owns. A manager built without
|
|
153
|
+
* that overlay reads a surface silo holding nothing but `setupVersion` and
|
|
154
|
+
* reports every channel as unconfigured.
|
|
155
|
+
*/
|
|
156
|
+
export function readChannelReadiness(config: Pick<ConfigManager, 'get'>): readonly ChannelReadiness[] {
|
|
157
|
+
return SEND_CHANNELS.map((channel) => {
|
|
158
|
+
const destination = readSetting(config, channel.destinationKey);
|
|
159
|
+
return {
|
|
160
|
+
channel,
|
|
161
|
+
enabled: config.get(channel.enabledKey) === true,
|
|
162
|
+
destination: destination.length > 0 ? destination : null,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type DefaultChannelResolution =
|
|
168
|
+
| { readonly kind: 'resolved'; readonly channel: SendChannel; readonly destination: string; readonly reason: string }
|
|
169
|
+
| { readonly kind: 'none'; readonly candidates: readonly ChannelReadiness[] }
|
|
170
|
+
| { readonly kind: 'ambiguous'; readonly candidates: readonly ChannelReadiness[] };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Which channel a `send` with no `--channel` goes to.
|
|
174
|
+
*
|
|
175
|
+
* A channel qualifies when it is switched on AND has a destination configured —
|
|
176
|
+
* "enabled" alone is not enough, because an enabled surface with a blank
|
|
177
|
+
* destination is a channel that would throw at the provider rather than deliver.
|
|
178
|
+
*
|
|
179
|
+
* **Ambiguity refuses; it never picks.** With two qualifying channels there is
|
|
180
|
+
* no non-arbitrary winner, and this command has an outward effect: sending the
|
|
181
|
+
* owner's message to the wrong one of his channels is worse than printing the
|
|
182
|
+
* list and exiting non-zero. There is deliberately no priority order here to
|
|
183
|
+
* silently break that tie — a preference ordering baked into this file would be
|
|
184
|
+
* an invisible decision about where his messages go.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveDefaultChannel(config: Pick<ConfigManager, 'get'>): DefaultChannelResolution {
|
|
187
|
+
const readiness = readChannelReadiness(config);
|
|
188
|
+
const qualifying = readiness.filter((entry) => entry.enabled && entry.destination !== null);
|
|
189
|
+
if (qualifying.length === 1) {
|
|
190
|
+
const only = qualifying[0]!;
|
|
191
|
+
return {
|
|
192
|
+
kind: 'resolved',
|
|
193
|
+
channel: only.channel,
|
|
194
|
+
destination: only.destination!,
|
|
195
|
+
reason: 'the only channel that is switched on and has a destination configured',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (qualifying.length === 0) return { kind: 'none', candidates: readiness };
|
|
199
|
+
return { kind: 'ambiguous', candidates: qualifying };
|
|
200
|
+
}
|