@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,381 @@
1
+ /**
2
+ * profile-types.ts
3
+ *
4
+ * Response shapes for the owner-profile control-plane verbs (`profile.*`), and
5
+ * the runtime checks that turn an untyped daemon response into one of them.
6
+ *
7
+ * ## The shapes come from the contract, not from a copy of it
8
+ *
9
+ * Every type below is derived from `OperatorMethodOutput<'profile.…'>` — the
10
+ * types generated from the SDK's own output schemas
11
+ * (`platform/control-plane/method-catalog-owner-profile.ts`). Nothing here
12
+ * restates a field name, so this file cannot drift from the contract: if a
13
+ * verb's shape changes, the checkers stop compiling against it.
14
+ *
15
+ * ## Why the responses are still checked at runtime
16
+ *
17
+ * A generated type is a compile-time claim about what the wire *should* carry,
18
+ * and nothing enforces it at runtime here. The operator client's
19
+ * `validateResponses` defaults on, but it only validates method ids with a
20
+ * matching exported Zod schema and `buildSchemaRegistry` silently skips the
21
+ * rest. Measured against the installed contracts package: 5 of 452 method ids
22
+ * have one, and no `profile.*` verb is among them. So response validation is
23
+ * not a safety net this command can lean on — for these verbs or for most
24
+ * others.
25
+ *
26
+ * So a daemon on a different version — or a 200 from something that is not this
27
+ * daemon at all — would otherwise reach a renderer and throw on a missing array,
28
+ * which in a slash command means a stack trace instead of an answer. Each `toX`
29
+ * function does the cast to `Record<string, unknown>` in ONE place, checks every
30
+ * property a renderer will read, and returns {@link MALFORMED} when anything is
31
+ * off. The command then prints an honest "this build does not recognise that
32
+ * response" line.
33
+ *
34
+ * A malformed *member* fails the whole response rather than being filtered out.
35
+ * Silently dropping a section or a line the checker did not recognise would be
36
+ * the same failure §4.4 forbids in the parser: content disappearing without
37
+ * anyone being told.
38
+ */
39
+ import type { OperatorMethodInput, OperatorMethodOutput } from '@pellux/goodvibes-sdk';
40
+
41
+ /** Returned by every checker when the response does not match the contract. */
42
+ export const MALFORMED: unique symbol = Symbol('malformed-profile-response');
43
+ export type Checked<T> = T | typeof MALFORMED;
44
+
45
+ /** The verbs this command calls. Typing the ids is what types the inputs. */
46
+ export type ProfileReadVerb = 'profile.read' | 'profile.provenance' | 'profile.status';
47
+ export type ProfileWriteVerb = 'profile.set' | 'profile.append' | 'profile.forget' | 'profile.undo';
48
+ export type ProfileVerb = ProfileReadVerb | ProfileWriteVerb;
49
+
50
+ /** The contract's own input shape for a verb, so a wrong field name will not compile. */
51
+ export type ProfileInput<TVerb extends ProfileVerb> = OperatorMethodInput<TVerb>;
52
+
53
+ /**
54
+ * Every key of every member of a union. `keyof` on a union yields only the keys
55
+ * the members SHARE, so a union target would be checked against that
56
+ * intersection and would wave through a key belonging to a sibling verb.
57
+ */
58
+ type UnionKeys<T> = T extends unknown ? keyof T : never;
59
+
60
+ /**
61
+ * Reject a key the contract does not declare, including on a body that is not a
62
+ * fresh object literal.
63
+ *
64
+ * DO NOT "simplify" this away to plain parameter typing. It is load-bearing,
65
+ * not a belt-and-braces backstop, and the measurements say which cases it
66
+ * carries alone. Four ways a stale key can arrive, all compiled against this
67
+ * repo's TypeScript with a `profile.forget`-shaped input:
68
+ *
69
+ * 1. written inline in a fresh literal — parameter typing already rejects
70
+ * 2. written inline BESIDE a spread — parameter typing already rejects
71
+ * 3. body built as a variable first — SLIPS PAST parameter typing
72
+ * 4. carried in by the spread source's type — SLIPS PAST parameter typing
73
+ *
74
+ * A spread literal is not uniformly unchecked, which is the easy thing to get
75
+ * wrong: anything written inline in one is still checked normally, and only what
76
+ * arrives THROUGH the spread escapes, because it belongs to the source's type
77
+ * rather than to the literal. Rows 3 and 4 are the two nothing else catches, and
78
+ * this guard rejects both with TS2345 — measured, not reasoned about.
79
+ *
80
+ * That is not hypothetical: `profile.forget` really did retire `lineIndex`, and
81
+ * a body built one line earlier would have carried it silently past a correctly
82
+ * typed parameter. Seeding inline literals proves only the cases someone thought
83
+ * to seed; this closes the shape.
84
+ *
85
+ * There is a second reason it earns its place here. The SDK's `invoke` is
86
+ * OVERLOADED — a typed signature plus a loose
87
+ * `invoke<T = unknown>(methodId: string, input?: Record<string, unknown>)`. At a
88
+ * bare call site a body the typed overload rejects does not raise an
89
+ * excess-property error at all; overload resolution simply falls through to the
90
+ * loose one and the result degrades to `unknown`. Any error is then incidental,
91
+ * downstream, and disappears entirely if the caller ignores or casts the result.
92
+ * `ProfileInvoke` is a single generic signature with no such fallback, so a bad
93
+ * body fails here, at the call, naming the offending key.
94
+ *
95
+ * Intersecting the parameter with this maps every undeclared key to `never`, so
96
+ * no real value satisfies it however the object was built. Distributed over the
97
+ * target union via {@link UnionKeys} so each member is checked rather than only
98
+ * the keys they have in common.
99
+ */
100
+ export type NoExcessKeys<TBody, TShape> = Record<Exclude<keyof TBody, UnionKeys<TShape>>, never>;
101
+
102
+ /** A body that conforms to a verb's declared input and carries nothing extra. */
103
+ export type ExactProfileInput<TVerb extends ProfileVerb, TBody> =
104
+ TBody & NoExcessKeys<TBody, ProfileInput<TVerb>>;
105
+
106
+ /** What `profile.read` answers: the whole document, by section. */
107
+ export type ProfileDocumentView = OperatorMethodOutput<'profile.read'>;
108
+
109
+ /**
110
+ * Load state — what `profile.status` answers, and the `state` half of a read.
111
+ *
112
+ * `kind` is `loaded` | `unavailable` | `disabled`. The counts belong to
113
+ * `loaded` and `reason` to `unavailable`. There is no value property anywhere
114
+ * in this shape, which is what makes the status output safe to show in a
115
+ * diagnostic context (§11.3).
116
+ */
117
+ export type ProfileStateView = OperatorMethodOutput<'profile.status'>;
118
+
119
+ /** What `profile.provenance` answers for one field. */
120
+ export type ProfileProvenanceReportView = OperatorMethodOutput<'profile.provenance'>;
121
+
122
+ /** What every write verb answers. `ok: false` always carries a reason. */
123
+ export type ProfileWriteResultView = OperatorMethodOutput<'profile.set'>;
124
+
125
+ /** One `## ` section, with the tier its content belongs to. */
126
+ export type ProfileSectionView = ProfileDocumentView['sections'][number];
127
+ /** One mechanical field. `valid: false` still carries the value — see §4.3. */
128
+ export type ProfileFieldView = ProfileSectionView['fields'][number];
129
+ /** One prose line, preserved as written. */
130
+ export type ProfileLineView = ProfileSectionView['prose'][number];
131
+ /** Where a line came from: which surface, when, and the owner's exact words. */
132
+ export type ProfileProvenanceView = NonNullable<ProfileFieldView['provenance']>;
133
+ /** A mechanical value that did not validate, and why. Never fails the file. */
134
+ export type ProfileInvalidFieldView = NonNullable<ProfileStateView['invalidFields']>[number];
135
+ /** A retained `<!-- was: … -->` predecessor, so a wrong correction is recoverable. */
136
+ export type ProfileSupersededView = ProfileProvenanceReportView['superseded'][number];
137
+ /** One thing a write did. Names the field; never repeats the value. */
138
+ export type ProfileChangeView = ProfileWriteResultView['changes'][number];
139
+
140
+ /**
141
+ * Compile-time proof of two things this file assumes: that all four write verbs
142
+ * really do answer one shape, and that `profile.status` really is the same
143
+ * `state` a read carries.
144
+ *
145
+ * One write checker serves four verbs and `toProfileState` checks both the
146
+ * status response and a read's `state`. If the contract ever splits them,
147
+ * `Exact` collapses to `never` and this assignment stops compiling, rather than
148
+ * the difference going unnoticed until a response renders wrong.
149
+ */
150
+ type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
151
+ export const PROFILE_CONTRACT_SHAPES_AGREE: Exact<OperatorMethodOutput<'profile.append'>, ProfileWriteResultView>
152
+ & Exact<OperatorMethodOutput<'profile.forget'>, ProfileWriteResultView>
153
+ & Exact<OperatorMethodOutput<'profile.undo'>, ProfileWriteResultView>
154
+ & Exact<ProfileDocumentView['state'], ProfileStateView> = true;
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Primitive checks
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /** The single place an untyped response is narrowed to an indexable object. */
161
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
162
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
163
+ return value as unknown as Record<string, unknown>;
164
+ }
165
+
166
+ function isString(value: unknown): value is string {
167
+ return typeof value === 'string';
168
+ }
169
+
170
+ function isBoolean(value: unknown): value is boolean {
171
+ return typeof value === 'boolean';
172
+ }
173
+
174
+ function isFiniteNumber(value: unknown): value is number {
175
+ return typeof value === 'number' && Number.isFinite(value);
176
+ }
177
+
178
+ /** Absent and null both mean "not supplied"; anything else must be a string. */
179
+ function optionalString(value: unknown): Checked<string | undefined> {
180
+ if (value === undefined || value === null) return undefined;
181
+ return isString(value) ? value : MALFORMED;
182
+ }
183
+
184
+ function optionalBoolean(value: unknown): Checked<boolean | undefined> {
185
+ if (value === undefined || value === null) return undefined;
186
+ return isBoolean(value) ? value : MALFORMED;
187
+ }
188
+
189
+ function optionalNumber(value: unknown): Checked<number | undefined> {
190
+ if (value === undefined || value === null) return undefined;
191
+ return isFiniteNumber(value) ? value : MALFORMED;
192
+ }
193
+
194
+ /** An array whose every member checks out, or MALFORMED. Never a filtered subset. */
195
+ function checkedArray<T>(value: unknown, check: (entry: unknown) => Checked<T>): Checked<T[]> {
196
+ if (!Array.isArray(value)) return MALFORMED;
197
+ const out: T[] = [];
198
+ for (const entry of value) {
199
+ const parsed = check(entry);
200
+ if (parsed === MALFORMED) return MALFORMED;
201
+ out.push(parsed);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function checkedStringArray(value: unknown): Checked<string[]> {
207
+ return checkedArray(value, (entry) => (isString(entry) ? entry : MALFORMED));
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Response checks
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function toProvenance(value: unknown): Checked<ProfileProvenanceView | undefined> {
215
+ if (value === undefined || value === null) return undefined;
216
+ const record = asRecord(value);
217
+ if (!record) return MALFORMED;
218
+ const { surface, date, said } = record;
219
+ if (!isString(surface) || !isString(date) || !isString(said)) return MALFORMED;
220
+ return { surface, date, said };
221
+ }
222
+
223
+ function toLine(value: unknown): Checked<ProfileLineView> {
224
+ const record = asRecord(value);
225
+ if (!record) return MALFORMED;
226
+ const { lineIndex, section, text } = record;
227
+ if (!isFiniteNumber(lineIndex) || !isString(section) || !isString(text)) return MALFORMED;
228
+ const provenance = toProvenance(record.provenance);
229
+ if (provenance === MALFORMED) return MALFORMED;
230
+ return { lineIndex, section, text, ...(provenance ? { provenance } : {}) };
231
+ }
232
+
233
+ function toField(value: unknown): Checked<ProfileFieldView> {
234
+ const record = asRecord(value);
235
+ if (!record) return MALFORMED;
236
+ const { fieldId, label, value: fieldValue, valid } = record;
237
+ if (!isString(fieldId) || !isString(label) || !isString(fieldValue) || !isBoolean(valid)) return MALFORMED;
238
+ const invalidReason = optionalString(record.invalidReason);
239
+ if (invalidReason === MALFORMED) return MALFORMED;
240
+ const provenance = toProvenance(record.provenance);
241
+ if (provenance === MALFORMED) return MALFORMED;
242
+ return {
243
+ fieldId,
244
+ label,
245
+ value: fieldValue,
246
+ valid,
247
+ ...(invalidReason === undefined ? {} : { invalidReason }),
248
+ ...(provenance ? { provenance } : {}),
249
+ };
250
+ }
251
+
252
+ function toSection(value: unknown): Checked<ProfileSectionView> {
253
+ const record = asRecord(value);
254
+ if (!record) return MALFORMED;
255
+ const { heading, tier } = record;
256
+ if (!isString(heading) || !isString(tier)) return MALFORMED;
257
+ const fields = checkedArray(record.fields, toField);
258
+ if (fields === MALFORMED) return MALFORMED;
259
+ const prose = checkedArray(record.prose, toLine);
260
+ if (prose === MALFORMED) return MALFORMED;
261
+ return { heading, tier, fields, prose };
262
+ }
263
+
264
+ function toInvalidField(value: unknown): Checked<ProfileInvalidFieldView> {
265
+ const record = asRecord(value);
266
+ if (!record) return MALFORMED;
267
+ const { fieldId, reason } = record;
268
+ if (!isString(fieldId) || !isString(reason)) return MALFORMED;
269
+ return { fieldId, reason };
270
+ }
271
+
272
+ /** `profile.status`, and the `state` half of `profile.read`. */
273
+ export function toProfileState(value: unknown): Checked<ProfileStateView> {
274
+ const record = asRecord(value);
275
+ if (!record) return MALFORMED;
276
+ const { kind, path } = record;
277
+ if (!isString(kind) || !isString(path)) return MALFORMED;
278
+ const exists = optionalBoolean(record.exists);
279
+ if (exists === MALFORMED) return MALFORMED;
280
+ const lineCount = optionalNumber(record.lineCount);
281
+ if (lineCount === MALFORMED) return MALFORMED;
282
+ const fieldCount = optionalNumber(record.fieldCount);
283
+ if (fieldCount === MALFORMED) return MALFORMED;
284
+ const proseLineCount = optionalNumber(record.proseLineCount);
285
+ if (proseLineCount === MALFORMED) return MALFORMED;
286
+ const reason = optionalString(record.reason);
287
+ if (reason === MALFORMED) return MALFORMED;
288
+ const sections = record.sections === undefined || record.sections === null
289
+ ? undefined
290
+ : checkedStringArray(record.sections);
291
+ if (sections === MALFORMED) return MALFORMED;
292
+ const invalidFields = record.invalidFields === undefined || record.invalidFields === null
293
+ ? undefined
294
+ : checkedArray(record.invalidFields, toInvalidField);
295
+ if (invalidFields === MALFORMED) return MALFORMED;
296
+ return {
297
+ kind,
298
+ path,
299
+ ...(exists === undefined ? {} : { exists }),
300
+ ...(lineCount === undefined ? {} : { lineCount }),
301
+ ...(fieldCount === undefined ? {} : { fieldCount }),
302
+ ...(proseLineCount === undefined ? {} : { proseLineCount }),
303
+ ...(sections === undefined ? {} : { sections }),
304
+ ...(invalidFields === undefined ? {} : { invalidFields }),
305
+ ...(reason === undefined ? {} : { reason }),
306
+ };
307
+ }
308
+
309
+ /** `profile.read`. */
310
+ export function toProfileDocument(value: unknown): Checked<ProfileDocumentView> {
311
+ const record = asRecord(value);
312
+ if (!record) return MALFORMED;
313
+ const state = toProfileState(record.state);
314
+ if (state === MALFORMED) return MALFORMED;
315
+ const sections = checkedArray(record.sections, toSection);
316
+ if (sections === MALFORMED) return MALFORMED;
317
+ return { state, sections };
318
+ }
319
+
320
+ function toSuperseded(value: unknown): Checked<ProfileSupersededView> {
321
+ const record = asRecord(value);
322
+ if (!record) return MALFORMED;
323
+ const { lineIndex, fieldId, section, text, value: supersededValue, supersededOn, previousLine } = record;
324
+ if (
325
+ !isFiniteNumber(lineIndex)
326
+ || !isString(fieldId)
327
+ || !isString(section)
328
+ || !isString(text)
329
+ || !isString(supersededValue)
330
+ || !isString(supersededOn)
331
+ || !isString(previousLine)
332
+ ) return MALFORMED;
333
+ const provenance = toProvenance(record.provenance);
334
+ if (provenance === MALFORMED) return MALFORMED;
335
+ return {
336
+ lineIndex,
337
+ fieldId,
338
+ section,
339
+ text,
340
+ value: supersededValue,
341
+ supersededOn,
342
+ previousLine,
343
+ ...(provenance ? { provenance } : {}),
344
+ };
345
+ }
346
+
347
+ /** `profile.provenance`. */
348
+ export function toProfileProvenanceReport(value: unknown): Checked<ProfileProvenanceReportView> {
349
+ const record = asRecord(value);
350
+ if (!record) return MALFORMED;
351
+ const { fieldId, present, handEdited } = record;
352
+ if (!isString(fieldId) || !isBoolean(present) || !isBoolean(handEdited)) return MALFORMED;
353
+ const provenance = toProvenance(record.provenance);
354
+ if (provenance === MALFORMED) return MALFORMED;
355
+ const superseded = checkedArray(record.superseded, toSuperseded);
356
+ if (superseded === MALFORMED) return MALFORMED;
357
+ return { fieldId, present, handEdited, ...(provenance ? { provenance } : {}), superseded };
358
+ }
359
+
360
+ function toChange(value: unknown): Checked<ProfileChangeView> {
361
+ const record = asRecord(value);
362
+ if (!record) return MALFORMED;
363
+ const { kind, section, label, superseded } = record;
364
+ if (!isString(kind) || !isString(section) || !isString(label) || !isBoolean(superseded)) return MALFORMED;
365
+ const fieldId = optionalString(record.fieldId);
366
+ if (fieldId === MALFORMED) return MALFORMED;
367
+ return { kind, section, label, superseded, ...(fieldId === undefined ? {} : { fieldId }) };
368
+ }
369
+
370
+ /** `profile.set`, `profile.append`, `profile.forget`, `profile.undo`. */
371
+ export function toProfileWriteResult(value: unknown): Checked<ProfileWriteResultView> {
372
+ const record = asRecord(value);
373
+ if (!record) return MALFORMED;
374
+ const { ok, disclosure } = record;
375
+ if (!isBoolean(ok) || !isString(disclosure)) return MALFORMED;
376
+ const reason = optionalString(record.reason);
377
+ if (reason === MALFORMED) return MALFORMED;
378
+ const changes = checkedArray(record.changes, toChange);
379
+ if (changes === MALFORMED) return MALFORMED;
380
+ return { ok, disclosure, changes, ...(reason === undefined ? {} : { reason }) };
381
+ }
@@ -11,6 +11,7 @@ import { registerConfigCommand } from './commands/config.ts';
11
11
  import { registerDiscoveryRuntimeCommands } from './commands/discovery-runtime.ts';
12
12
  import { registerPlanningRuntimeCommands } from './commands/planning-runtime.ts';
13
13
  import { registerSecretRuntimeCommands } from './commands/secret-runtime.ts';
14
+ import { registerPaymentCardCommands } from './commands/payment-card-intake.ts';
14
15
  import { registerScheduleRuntimeCommands } from './commands/schedule-runtime.ts';
15
16
  import { registerBranchRuntimeCommands } from './commands/branch-runtime.ts';
16
17
  import { registerOperatorRuntimeCommands } from './commands/operator-runtime.ts';
@@ -82,6 +83,7 @@ import { registerCheckinRuntimeCommands } from './commands/checkin-runtime.ts';
82
83
  import { registerMailRuntimeCommands } from './commands/mail-runtime.ts';
83
84
  import { registerCalendarRuntimeCommands } from './commands/calendar-runtime.ts';
84
85
  import { registerPrincipalsRuntimeCommands } from './commands/principals-runtime.ts';
86
+ import { registerProfileRuntimeCommands } from './commands/profile-runtime.ts';
85
87
 
86
88
  /**
87
89
  * A named group of built-in commands. The `category` label is the single
@@ -162,6 +164,7 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
162
164
  { category: 'Discovery', register: registerDiscoveryRuntimeCommands },
163
165
  { category: 'Planning', register: registerPlanningRuntimeCommands },
164
166
  { category: 'Secrets', register: registerSecretRuntimeCommands },
167
+ { category: 'Payments', register: registerPaymentCardCommands },
165
168
  { category: 'Scheduling', register: registerScheduleRuntimeCommands },
166
169
  { category: 'Branches', register: registerBranchRuntimeCommands },
167
170
  { category: 'Session Content', register: registerSessionContentCommands },
@@ -178,6 +181,10 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
178
181
  { category: 'Mail & Calendar', register: registerMailRuntimeCommands },
179
182
  { category: 'Mail & Calendar', register: registerCalendarRuntimeCommands },
180
183
  { category: 'Principals', register: registerPrincipalsRuntimeCommands },
184
+ // "Owner Profile", not "Profiles": the Profiles category above is
185
+ // /profile-sync's named-config-preset bundles, which have nothing to do with
186
+ // what the platform knows about the person who owns it.
187
+ { category: 'Owner Profile', register: registerProfileRuntimeCommands },
181
188
  { category: 'Policy', register: (registry) => registry.register(policyCommand) },
182
189
  { category: 'Providers', register: (registry) => registry.register(providerCommand) },
183
190
  { category: 'Eval', register: (registry) => registry.register(evalCommand) },
@@ -66,6 +66,10 @@ export function openComposerInEditor(deps: ComposerEditorDeps): void {
66
66
  return;
67
67
  }
68
68
  const draft = deps.readDraft();
69
+ // Real production scratch (runs on the end user's machine, not test
70
+ // infrastructure), so this stays rooted at the real OS temp dir rather
71
+ // than the test-only makeProjectTempDir helper. Cleaned up in the
72
+ // `finally` below on every exit path.
69
73
  const dir = mkdtempSync(join(tmpdir(), 'goodvibes-composer-'));
70
74
  const file = join(dir, 'COMPOSER.md');
71
75
  const spawn = deps.spawn ?? defaultSpawn;
@@ -132,6 +132,14 @@ export function handleGlobalShortcutToken(
132
132
  state.commandContext?.toggleMemoryProvenance?.();
133
133
  return true;
134
134
 
135
+ case 'voice-input':
136
+ // Start recording, or stop the recording already running. Routed globally so
137
+ // the key works whether focus is on the composer or the panel workspace —
138
+ // dictation is not a composer-only act. Still consumed when voice input is
139
+ // not wired, so a stray Alt+V never lands in the prompt as text.
140
+ state.commandContext?.toggleVoiceInput?.();
141
+ return true;
142
+
135
143
  case 'toggle-keep-awake': {
136
144
  // Flip the daemon-held keep-awake toggle; the always-visible "sleep
137
145
  // disabled" chip is the confirmation. Fire-and-forget (the seam renders on
@@ -71,7 +71,8 @@ export type KeyAction =
71
71
  | 'yank-pop'
72
72
  | 'cancel-tool-call'
73
73
  | 'toggle-keep-awake'
74
- | 'toggle-memory-provenance';
74
+ | 'toggle-memory-provenance'
75
+ | 'voice-input';
75
76
 
76
77
  /** Human-readable description for each action (used in /keybindings display). */
77
78
  export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
@@ -117,6 +118,10 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
117
118
  'cancel-tool-call': 'Cancel the running tool call (the turn continues)',
118
119
  'toggle-keep-awake': 'Toggle keep-awake (the "sleep disabled" chip)',
119
120
  'toggle-memory-provenance': 'List/hide the memories a turn used (provenance chip drill-in)',
121
+ // Named for what it actually does. A terminal delivers key EVENTS, not held-key
122
+ // state — there is no release event to watch — so this is two discrete presses,
123
+ // and the description says so instead of implying a hold that cannot exist.
124
+ 'voice-input': 'Voice input: press to start recording, press again to stop and transcribe into the composer',
120
125
  };
121
126
 
122
127
  /** Default key bindings for all actions. */
@@ -207,6 +212,10 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
207
212
  // Alt+M: expand/collapse the "used N memories" provenance chip's drill-in.
208
213
  // Alt+M is unused by any other default. A no-op when the chip is not showing.
209
214
  'toggle-memory-provenance': [{ key: 'm', alt: true }],
215
+ // Alt+V: start/stop voice input. Alt+V is unused by any other default (Ctrl+V
216
+ // is paste and stays paste). Two presses rather than a hold: see the action's
217
+ // description — a terminal has no key-release event to hold against.
218
+ 'voice-input': [{ key: 'v', alt: true }],
210
219
  };
211
220
 
212
221
  /** Resolved overrides type: each key can be a single combo or array. */
@@ -231,7 +231,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
231
231
  {
232
232
  flagId: 'otel-remote-export',
233
233
  label: 'OTLP remote export',
234
- hint: 'Export spans over OTLP/gRPC to a collector. Enabling this also turns on the OpenTelemetry foundation.',
234
+ hint: 'Export spans over OTLP/HTTP JSON to a collector. Enabling this also turns on the OpenTelemetry foundation. Span export reads the OTEL_EXPORTER_OTLP_* environment variables; decision export reads telemetry.decisionOtlpEndpoint — this unit sets the latter.',
235
235
  requiresFlags: ['otel-foundation'],
236
236
  impliedConfig: [{ key: 'telemetry.decisionOtlpEnabled' as ConfigKey, value: true }],
237
237
  subOptions: [
@@ -239,10 +239,10 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
239
239
  key: 'endpoint',
240
240
  configKey: 'telemetry.decisionOtlpEndpoint' as ConfigKey,
241
241
  label: 'Collector endpoint',
242
- hint: 'OTLP collector URL (e.g. http://localhost:4317). Leave blank to set it later in /settings.',
242
+ hint: 'OTLP collector URL (e.g. http://localhost:4318). Leave blank to set it later in /settings.',
243
243
  valueType: 'text',
244
244
  defaultValue: '',
245
- placeholder: 'http://localhost:4317',
245
+ placeholder: 'http://localhost:4318',
246
246
  },
247
247
  ],
248
248
  },
@@ -326,7 +326,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
326
326
  stepId: 'features-devices',
327
327
  title: 'Paired devices and voice input',
328
328
  shortLabel: 'Devices',
329
- description: 'What a paired phone may be asked to do on your behalf, and whether GoodVibes listens for a spoken wake phrase. Every phone capability asks the person holding the phone before it runs; the wake word is off and states plainly that it is not wired up in this build.',
329
+ description: 'What a paired phone may be asked to do on your behalf, and whether GoodVibes listens for a spoken wake phrase. Every phone capability asks the person holding the phone before it runs; the wake word is off by default because holding a microphone open must be an explicit act, and turning it on does open one on this terminal.',
330
330
  units: [
331
331
  {
332
332
  flagId: 'paired-device-capabilities',
@@ -349,7 +349,7 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
349
349
  flagId: 'wake-word-detection',
350
350
  label: 'Wake-word detection',
351
351
  hint: 'Listen continuously on a capture device for the pinned "hey goodvibes" phrase and hand the utterance that follows to speech-to-text. Off by default because holding a microphone open must be an explicit act.',
352
- note: 'NOT AVAILABLE IN THIS BUILD — turning this on does nothing yet. The detector is complete, but no surface captures microphone audio or supplies it an inference runtime, so nothing is listening. Your choice is remembered and takes effect in the release that adds capture.',
352
+ note: 'On this terminal, turning this on starts a recorder (pw-record, parecord, arecord, ffmpeg or sox — whichever is installed), scores every frame with the pinned classifier, plays a short chime the moment a wake confirms, and shows a persistent "listening" row in the footer for as long as it runs. What follows a wake goes to speech-to-text and lands in the composer, or is sent straight away if you turn voice.wake.autoSubmit on. The model it scores with is already here — installing GoodVibes downloads and checksum-verifies it, and a running daemon retries at boot if the install could not reach the network — so turning this on normally needs no setup step. What it will NOT do on its own is download anything at the moment you turn it on: a host whose artifacts are missing or fail verification says which and names /voice wake setup, instead of pretending to listen. That same provisioning fetches the speech gate, so voice.wake.vadThreshold above 0 screens frames once it has run and refuses to start before it has, rather than scoring frames it claims to be screening. voice.wake.noiseSuppression: speex runs here too — the filter travels with the platform, so there is nothing to install for it.',
353
353
  },
354
354
  ],
355
355
  },
@@ -52,6 +52,18 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
52
52
  acknowledged: controller.getBooleanFieldValue(fieldId, false),
53
53
  });
54
54
  };
55
+ // Every credential the first-run wizard captures is one the DAEMON executes
56
+ // with: model provider API keys (the daemon runs the model), the Cloudflare
57
+ // API token, and the masked fields of the external surfaces — all of which
58
+ // live under daemon-owned `surfaces.*` config paths. So they all go to the
59
+ // daemon tier, which is the one tier every surface AND the daemon read.
60
+ //
61
+ // 'project' — what this used to pass — filed them under whatever directory
62
+ // the wizard happened to be run from. Run the wizard in ~/code/foo and the
63
+ // daemon, which has no idea that directory exists, found nothing; run it
64
+ // again elsewhere and the first setup silently stopped applying. First-run
65
+ // setup is exactly the moment where "configure once, works everywhere" has
66
+ // to hold.
55
67
  const setSecret = (key: string, value: string): void => {
56
68
  if (value.length === 0) return;
57
69
  const medium = controller.getSelectedSecretMedium();
@@ -59,7 +71,7 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
59
71
  kind: 'set-secret',
60
72
  key,
61
73
  value,
62
- scope: 'project',
74
+ scope: 'daemon',
63
75
  medium,
64
76
  });
65
77
  };