@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
@@ -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, hasOverriddenGoodVibesHome } from '../config/goodvibes-home.ts';
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: hasOverriddenGoodVibesHome(),
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
- /** Store a daemon-owned credential. scope defaults 'user'. */
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?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
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?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
58
+ opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
49
59
  ): Promise<void> {
50
60
  await secrets.set(secretKey, value, {
51
- scope: opts?.scope ?? 'user',
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
+ }