@huynle/hub 0.10.0 → 0.11.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 +78 -0
  2. package/README.md +20 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +625 -395
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/src/index.ts +13 -1
  17. package/packages/protocol/src/teams.ts +4 -0
  18. package/packages/protocol/src/voice-recording.ts +39 -2
  19. package/packages/runtime-openshell/image/entry.sh +3 -1
  20. package/packages/runtime-openshell/image/start.sh +3 -1
  21. package/packages/runtime-openshell/package.json +1 -1
  22. package/packages/runtime-sdk/package.json +1 -1
  23. package/packages/sdk/package.json +1 -1
  24. package/packages/sdk/src/client.ts +13 -0
  25. package/packages/sdk/src/teams.ts +2 -0
  26. package/packages/spoke-sdk/package.json +1 -1
  27. package/src/central/agent/decisions.ts +7 -1
  28. package/src/central/agent/delivery.ts +39 -9
  29. package/src/central/agent/grant-routes.ts +14 -1
  30. package/src/central/agent/grants.ts +23 -0
  31. package/src/central/agent/launch-engine.ts +3 -3
  32. package/src/central/agent/message-limits.ts +3 -0
  33. package/src/central/agent/routes.ts +11 -0
  34. package/src/central/agent/subscriptions.ts +3 -1
  35. package/src/central/config.ts +9 -2
  36. package/src/central/http.ts +135 -24
  37. package/src/central/recording/export.ts +57 -3
  38. package/src/central/recording/index.ts +234 -16
  39. package/src/central/recording/ingest.ts +11 -2
  40. package/src/central/recording/routes.ts +61 -7
  41. package/src/central/recording/schema.ts +66 -2
  42. package/src/central/recording/settings.ts +19 -5
  43. package/src/central/recording/store.ts +19 -0
  44. package/src/central/recording/taps.ts +45 -1
  45. package/src/central/speech/openai.ts +4 -4
  46. package/src/central/speech/openrouter.ts +4 -4
  47. package/src/central/speech/provider.ts +32 -1
  48. package/src/central/speech/service.ts +6 -5
  49. package/src/central/teams/hop-stalls.ts +72 -0
  50. package/src/central/teams/policy.ts +10 -0
  51. package/src/central/teams/routes.ts +2 -0
  52. package/src/central/teams/seats.ts +3 -3
  53. package/src/central/teams/staffing.ts +5 -0
  54. package/src/central/teams/store.ts +5 -2
  55. package/src/central/teams/validation.ts +8 -5
  56. package/src/spoke/spoke.ts +19 -0
  57. package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
  58. package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  59. package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  60. package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
  61. package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
  62. package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  63. package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
  64. package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
  65. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  66. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  67. package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
  68. package/web/dist/assets/live-BmqLASSo.js +5 -0
  69. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  70. package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
  71. package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
  72. package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
  73. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  74. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  75. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  76. package/web/dist/index.html +5 -5
  77. package/web/dist/assets/index-BS6lA6fN.js +0 -33
  78. package/web/dist/assets/live-CVUFIdUX.js +0 -5
@@ -8,8 +8,11 @@
8
8
  // only while a recording is active for that session, only into the session owner's recordings, and never change
9
9
  // what the voice path answers (failures are logged without content or IDs).
10
10
  import { randomBytes } from "node:crypto"
11
+ import type { TranscribeStats } from "../speech/provider"
11
12
  import {
13
+ DEV_LIMITS,
12
14
  RECORDING_AUDIO_TYPE,
15
+ RECORDING_REPLY_TYPE,
13
16
  RECORDING_HEADER,
14
17
  RECORDING_LIMITS,
15
18
  RECORDING_REF_HEADER,
@@ -29,10 +32,13 @@ import { redactCredentials } from "../profiles/content"
29
32
  import type { ConciergeCallFields } from "./concierge-taps"
30
33
  import { HubEventWriter, ingestConsole, PAGE_QUOTA_BYTES } from "./ingest"
31
34
  import { RecordingRetention } from "./retention"
32
- import { RecordingSettingsStore } from "./settings"
35
+ import { RecordingSettingsStore, type SettingsChange } from "./settings"
33
36
  import {
34
37
  type AudioEntry,
35
38
  audioKey,
39
+ clipKey,
40
+ type ExtraEntry,
41
+ replyKey,
36
42
  ended,
37
43
  isSegmentNumber,
38
44
  newRecord,
@@ -42,7 +48,7 @@ import {
42
48
  type SpeechFacts,
43
49
  summaryOf,
44
50
  } from "./store"
45
- import { parseTtsRef, RegistryTaps } from "./taps"
51
+ import { parseTtsRef, RegistryTaps, type TurnTimings } from "./taps"
46
52
 
47
53
  const L = RECORDING_LIMITS
48
54
  export const RECORDINGS_TOPIC = "recordings.changed"
@@ -82,20 +88,57 @@ export interface RecordingServiceOptions {
82
88
  readonly log?: (message: string) => void
83
89
  readonly sweepMs?: number
84
90
  readonly hubFlushMs?: number
91
+ /** Developer mode's limits (tests shrink them). */
92
+ readonly devLimits?: Partial<DevLimits>
93
+ /** This pod's name for request lines (Developer mode). */
94
+ readonly pod?: string
95
+ /**
96
+ * (Developer mode) What ran a session's turn: the model and its provider, the agent, the context size (token
97
+ * counts only). Asked only when a developer recording of the session is active; never text.
98
+ */
99
+ readonly turnFacts?: (userID: string, sessionID: string) => Promise<TurnFacts>
100
+ }
101
+
102
+ export interface TurnFacts {
103
+ readonly provider?: string
104
+ readonly model?: string
105
+ readonly variant?: string
106
+ readonly agent?: string
107
+ readonly contextTokens?: number
108
+ readonly contextLimit?: number
109
+ }
110
+
111
+ /** How long a turn detail waits for the session's facts before it is recorded without them. */
112
+ export const TURN_FACTS_MS = 3000
113
+
114
+ export type DevLimits = { -readonly [K in keyof typeof DEV_LIMITS]: number }
115
+
116
+ /** One voice request of the user (Developer mode's request lines): what route, its status and time. Never a body. */
117
+ export interface RequestLine {
118
+ readonly route: "transcribe" | "speak" | "events"
119
+ readonly method: string
120
+ readonly status: number
121
+ readonly ms: number
85
122
  }
86
123
 
124
+ export type ExtraOutcome = { readonly kind: "stored"; readonly entry: ExtraEntry; readonly n: number; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "off" } | { readonly kind: "failed"; readonly cause: unknown }
125
+
87
126
  interface ActiveRef {
88
127
  readonly id: string
89
128
  readonly sessionID: string
90
129
  readonly kind: RecordingKind
91
130
  readonly deviceID: string
131
+ readonly developer?: true
92
132
  }
93
133
 
94
134
  export type AudioOutcome = { readonly kind: "stored"; readonly entry: AudioEntry; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "failed"; readonly cause: unknown }
95
135
 
96
136
  export interface TranscribeTap {
97
- /** The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). */
98
- finish(text: string, providerMs: number): Promise<RecordingAudioResult>
137
+ /**
138
+ * The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). `stats`: the
139
+ * provider's HTTP status, response bytes and time to headers, recorded in stt.result when known.
140
+ */
141
+ finish(text: string, providerMs: number, stats?: TranscribeStats): Promise<RecordingAudioResult>
99
142
  }
100
143
 
101
144
  export interface SpeakTap {
@@ -118,9 +161,11 @@ export class RecordingService {
118
161
  private readonly background = new Set<Promise<unknown>>()
119
162
  private readonly opened = new Set<string>()
120
163
  private unsubscribe?: () => Promise<void>
164
+ readonly devLimits: DevLimits
121
165
 
122
166
  constructor(private readonly options: RecordingServiceOptions) {
123
167
  this.now = options.now ?? Date.now
168
+ this.devLimits = { ...DEV_LIMITS, ...options.devLimits }
124
169
  // Unique per process: a restarted pod never reuses another's page keys.
125
170
  this.podTag = `${(options.podID ?? "hub").replace(/[^A-Za-z0-9]/g, "").slice(0, 12) || "hub"}${randomBytes(3).toString("hex")}`
126
171
  this.store = new RecordingStore(options.state, options.blobs, this.now)
@@ -139,7 +184,14 @@ export class RecordingService {
139
184
  })().catch(() => undefined),
140
185
  })
141
186
  this.retention = new RecordingRetention({ users: options.users, sweepUser: (userID) => this.sweepUser(userID), ...(options.sweepMs !== undefined ? { sweepMs: options.sweepMs } : {}), log: (m) => this.log(m) })
142
- this.registryTap = new RegistryTaps({ hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields), mayRecord: (userID) => this.mayRecord(userID) })
187
+ this.registryTap = new RegistryTaps(
188
+ {
189
+ hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields),
190
+ mayRecord: (userID) => this.mayRecord(userID),
191
+ turnDetail: (userID, sessionID, detail) => void this.track(this.turnDetail(userID, sessionID, detail)).catch((cause) => this.log(`[hub] recording turn detail failed: ${(cause as Error).message}`)),
192
+ },
193
+ this.now,
194
+ )
143
195
  if (options.bus) {
144
196
  options.bus
145
197
  .subscribe(RECORDINGS_TOPIC, (message) => {
@@ -168,7 +220,7 @@ export class RecordingService {
168
220
  private async activeOf(userID: string): Promise<ActiveRef[]> {
169
221
  const cached = this.active.get(userID)
170
222
  if (cached && this.now() - cached.at < ACTIVE_TTL_MS) return cached.refs
171
- const refs = (await this.store.list(userID)).filter((r) => r.state === "active").map((r) => ({ id: r.id, sessionID: r.sessionID, kind: r.kind, deviceID: r.deviceID }))
223
+ const refs = (await this.store.list(userID)).filter((r) => r.state === "active").map((r) => ({ id: r.id, sessionID: r.sessionID, kind: r.kind, deviceID: r.deviceID, ...(r.developer ? { developer: true as const } : {}) }))
172
224
  this.active.set(userID, { at: this.now(), refs })
173
225
  if (this.active.size > 10_000) this.active.delete(this.active.keys().next().value as string)
174
226
  return refs
@@ -214,6 +266,29 @@ export class RecordingService {
214
266
  return run
215
267
  }
216
268
 
269
+ /**
270
+ * (Developer mode) turn.detail into the session's active developer recordings: the turn's timings and what ran it
271
+ * (asked of the session only then, at most TURN_FACTS_MS). Only the whitelisted facts: never text.
272
+ */
273
+ private async turnDetail(userID: string, sessionID: string, detail: TurnTimings): Promise<void> {
274
+ if (!this.mayRecord(userID)) return
275
+ const refs = (await this.activeOf(userID)).filter((r) => r.sessionID === sessionID && r.developer)
276
+ if (!refs.length) return
277
+ let facts: TurnFacts = {}
278
+ if (this.options.turnFacts) {
279
+ const timeout = new Promise<TurnFacts>((resolve) => setTimeout(() => resolve({}), TURN_FACTS_MS).unref?.())
280
+ facts = await Promise.race([this.options.turnFacts(userID, sessionID).catch(() => ({})), timeout])
281
+ }
282
+ const at = this.now()
283
+ const label = (v: unknown) => (typeof v === "string" && v ? v.slice(0, 120) : undefined)
284
+ const count = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : undefined)
285
+ const picked = Object.fromEntries(
286
+ Object.entries({ provider: label(facts.provider), model: label(facts.model), variant: label(facts.variant), agent: label(facts.agent), contextTokens: count(facts.contextTokens), contextLimit: count(facts.contextLimit) }).filter(([, v]) => v !== undefined),
287
+ )
288
+ const tools = detail.tools.map((t) => ({ callID: t.callID.slice(0, 120), ...(t.tool ? { tool: t.tool } : {}), startMs: t.startMs, ...(t.endMs !== undefined ? { endMs: t.endMs } : {}), state: t.state }))
289
+ for (const ref of refs) this.writer.add(userID, ref.id, "turn.detail", { outcome: detail.outcome, ms: detail.ms, ...(detail.firstTokenMs !== undefined ? { firstTokenMs: detail.firstTokenMs } : {}), ...picked, tools }, at)
290
+ }
291
+
217
292
  /** Writes every buffered hub event now. */
218
293
  async flush(): Promise<void> {
219
294
  await Promise.all([...this.tails.values()])
@@ -239,13 +314,28 @@ export class RecordingService {
239
314
  return { bytes, recordings: records.filter((r) => r.state !== "deleting").length, capBytes: L.userBytes }
240
315
  }
241
316
 
242
- async setEnabled(userID: string, deviceID: string, enabled: boolean, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
243
- const written = await this.settings.set(userID, enabled, expectedRevision)
317
+ /** The setting: recording and Developer mode (developer implies enabled; see RecordingSettingsStore.set). */
318
+ async setSettings(userID: string, deviceID: string, change: SettingsChange, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
319
+ const written = await this.settings.set(userID, change, expectedRevision)
244
320
  if (!written.ok) return written
321
+ const { enabled } = written.settings
322
+ const developer = written.settings.developer === true
245
323
  this.options.notify(userID, { type: "recording", settings: written.settings }, deviceID)
246
- if (written.changed) {
247
- await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
248
- if (!enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
324
+ if (written.changed) await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
325
+ if (written.developerChanged) await this.options.audit({ actor: userID, action: developer ? "recording.developer.enable" : "recording.developer.disable" })
326
+ if (written.changed && !enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
327
+ // Developer mode off: the running recordings go on without their extras from now on (on: from the next start).
328
+ if (written.developerChanged && !developer && enabled) {
329
+ for (const r of await this.store.list(userID)) {
330
+ if (r.state !== "active" || !r.developer) continue
331
+ await this.store.update(userID, r.id, (current) => {
332
+ if (!current.developer) return undefined
333
+ const { developer: _off, ...rest } = current
334
+ return rest as RecordingRecord
335
+ })
336
+ this.changed(userID, r.sessionID)
337
+ this.options.notify(userID, { type: "recording", recordingID: r.id, developer: false })
338
+ }
249
339
  }
250
340
  return { ok: true, settings: written.settings }
251
341
  }
@@ -271,7 +361,8 @@ export class RecordingService {
271
361
  }
272
362
 
273
363
  async start(userID: string, device: { id: string; name?: string }, input: StartInput): Promise<StartResult> {
274
- if (!(await this.settings.get(userID)).enabled) return { kind: "off" }
364
+ const setting = await this.settings.get(userID)
365
+ if (!setting.enabled) return { kind: "off" }
275
366
  if (!this.options.owns(userID, input.sessionID)) return { kind: "not_found" }
276
367
  const records = await this.store.list(userID)
277
368
  const same = records.find((r) => r.clientID === input.clientID && r.deviceID === device.id && r.state !== "deleting")
@@ -298,6 +389,7 @@ export class RecordingService {
298
389
  anchor: input.anchor ?? { perf: 0, epoch: at },
299
390
  audio,
300
391
  at,
392
+ developer: setting.developer === true,
301
393
  },
302
394
  speech,
303
395
  )
@@ -445,7 +537,7 @@ export class RecordingService {
445
537
  this.writer.add(userID, id, "stt.request", { segment: n, bytes: audio.length, format }, at)
446
538
  const model = live.stt?.model
447
539
  return {
448
- finish: async (text, providerMs) => {
540
+ finish: async (text, providerMs, stats) => {
449
541
  // (USER CHANGE vs the contract text) A transcript matching a secret pattern: the text is scrubbed (the
450
542
  // hub record of stt.result is), the audio kept, and the segment marked redacted.
451
543
  const redacted = redactCredentials(text).kinds.length > 0
@@ -460,7 +552,7 @@ export class RecordingService {
460
552
  } catch (cause) {
461
553
  result = this.audioResult(userID, id, n, { kind: "failed", cause })
462
554
  }
463
- this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result })
555
+ this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result, ...providerStats(stats) })
464
556
  return result
465
557
  },
466
558
  }
@@ -482,19 +574,34 @@ export class RecordingService {
482
574
  return "failed"
483
575
  }
484
576
 
485
- /** /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing; the reply audio is never stored. */
577
+ /**
578
+ * /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing. In a developer recording (with a ref) the
579
+ * reply audio is also kept, as the client received it (copied while streaming, no second upload).
580
+ */
486
581
  async speakTap(userID: string, deviceID: string, headers: Headers, chars: number): Promise<SpeakTap | undefined> {
487
582
  const id = headers.get(RECORDING_HEADER)
488
583
  if (id === null) return undefined
489
584
  const record = await this.store.visible(userID, id).catch(() => undefined)
490
585
  if (!record || record.state !== "active" || record.deviceID !== deviceID) return undefined
491
586
  const ref = parseTtsRef(headers.get(RECORDING_REF_HEADER))
587
+ const keep = record.developer === true && ref !== undefined
492
588
  const started = this.now()
493
- const done = (firstByteMs: number | undefined) => this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
589
+ const chunks: Uint8Array[] = []
590
+ let kept = 0
591
+ const collect = (chunk: Uint8Array) => {
592
+ if (!keep || kept > this.devLimits.replyBytes) return
593
+ kept += chunk.length
594
+ if (kept <= this.devLimits.replyBytes) chunks.push(chunk)
595
+ }
596
+ const done = (firstByteMs: number | undefined) => {
597
+ this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
598
+ if (keep) void this.track(this.keepReply(userID, id, ref!, kept > this.devLimits.replyBytes ? undefined : chunks))
599
+ }
494
600
  return {
495
601
  wrap: <T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T => {
496
602
  if (audio instanceof Uint8Array) {
497
603
  const ms = this.now() - started
604
+ collect(audio)
498
605
  done(ms)
499
606
  return audio
500
607
  }
@@ -503,6 +610,7 @@ export class RecordingService {
503
610
  new TransformStream<Uint8Array, Uint8Array>({
504
611
  transform: (chunk, controller) => {
505
612
  first ??= this.now() - started
613
+ collect(chunk)
506
614
  controller.enqueue(chunk)
507
615
  },
508
616
  flush: () => done(first),
@@ -513,6 +621,96 @@ export class RecordingService {
513
621
  }
514
622
  }
515
623
 
624
+ /** A reply sentence's audio (Developer mode): stored under the next reply number; reply.audio says what happened. */
625
+ private async keepReply(userID: string, id: string, ref: NonNullable<ExtraEntry["ref"]>, chunks: Uint8Array[] | undefined): Promise<void> {
626
+ const bytes = chunks ? concat(chunks) : undefined
627
+ const record = await this.store.visible(userID, id)
628
+ if (!record) return
629
+ const outcome: ExtraOutcome = bytes?.length ? await this.storeExtra(userID, record, "reply", undefined, bytes, ref) : { kind: "cap" }
630
+ const audio = outcome.kind === "stored" ? "stored" : outcome.kind === "cap" ? "skipped-cap" : outcome.kind === "failed" ? "failed" : "off"
631
+ this.writer.add(userID, id, "reply.audio", { ref, ...(outcome.kind === "stored" ? { n: outcome.n } : {}), bytes: bytes?.length ?? 0, audio })
632
+ }
633
+
634
+ /**
635
+ * (Developer mode) Stores an audio clip (`n` from the console) or a reply's audio (the next number). Only for a
636
+ * developer recording; the extras' audio stops first: never past (cap − headroom) of the user's or the recording's
637
+ * cap, so segments keep the rest (cap.reached {what: "extras"} once).
638
+ */
639
+ async storeExtra(userID: string, record: RecordingRecord, what: "clip" | "reply", n: number | undefined, audio: Uint8Array, ref?: ExtraEntry["ref"]): Promise<ExtraOutcome> {
640
+ const D = this.devLimits
641
+ if (!record.developer) return { kind: "off" }
642
+ if (what === "clip" && n !== undefined) {
643
+ const existing = await this.store.indexEntry<ExtraEntry>(userID, record.id, clipKey(n))
644
+ if (existing) return { kind: "stored", entry: existing, n, fresh: false }
645
+ }
646
+ if ((await this.store.usage(userID)) + audio.length > L.userBytes - D.userHeadroomBytes) {
647
+ await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
648
+ return { kind: "cap" }
649
+ }
650
+ // Reserve the bytes (and a reply's number) in the record, so parallel writes never pass the limits.
651
+ let verdict = "gone" as "ok" | "over" | "gone" | "off"
652
+ let number = n ?? 0
653
+ await this.store.update(userID, record.id, (current) => {
654
+ if (current.state === "deleting") return void (verdict = "gone")
655
+ if (!current.developer) return void (verdict = "off")
656
+ const count = what === "clip" ? (current.clips ?? 0) : (current.replies ?? 0)
657
+ const max = what === "clip" ? D.clipsPerRecording : D.repliesPerRecording
658
+ if (count >= max || current.bytes + audio.length > L.recordingBytes - D.recordingHeadroomBytes) return void (verdict = "over")
659
+ verdict = "ok"
660
+ if (what === "reply") number = count + 1
661
+ return { ...current, bytes: current.bytes + audio.length, ...(what === "clip" ? { clips: count + 1 } : { replies: count + 1 }) }
662
+ })
663
+ if (verdict === "gone" || verdict === "off") return { kind: verdict }
664
+ if (verdict === "over") {
665
+ await this.extrasCap(userID, record, "recording", L.recordingBytes - D.recordingHeadroomBytes)
666
+ return { kind: "cap" }
667
+ }
668
+ const release = () =>
669
+ this.store.update(userID, record.id, (current) =>
670
+ current.state === "deleting" ? undefined : { ...current, bytes: Math.max(0, current.bytes - audio.length), ...(what === "clip" ? { clips: Math.max(0, (current.clips ?? 1) - 1) } : {}) },
671
+ )
672
+ let blob
673
+ try {
674
+ blob = await this.store.putBlob(userID, audio, what === "clip" ? RECORDING_AUDIO_TYPE : RECORDING_REPLY_TYPE, what === "clip" ? D.clipBytes : D.replyBytes, L.userBytes - D.userHeadroomBytes)
675
+ } catch (cause) {
676
+ await release()
677
+ if (cause instanceof BlobQuotaError) {
678
+ await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
679
+ return { kind: "cap" }
680
+ }
681
+ return { kind: "failed", cause }
682
+ }
683
+ const at = this.now()
684
+ const key = what === "clip" ? clipKey(number) : replyKey(number)
685
+ const entry: ExtraEntry = { hash: blob.hash, bytes: blob.bytes, at, ...(ref ? { ref } : {}) }
686
+ if (!(await this.store.putIndex(userID, record.id, key, entry))) {
687
+ await release()
688
+ const first = await this.store.indexEntry<ExtraEntry>(userID, record.id, key)
689
+ if (first?.hash !== blob.hash) await this.store.dropIfUnindexed(userID, blob.hash, at)
690
+ return first ? { kind: "stored", entry: first, n: number, fresh: false } : { kind: "failed", cause: new Error("extra index lost") }
691
+ }
692
+ if (await this.store.undoIfDeleted(userID, record.id, key, blob.hash)) return { kind: "gone" }
693
+ return { kind: "stored", entry, n: number, fresh: true }
694
+ }
695
+
696
+ /** cap.reached {what: "extras"} once per scope; audio (segments) goes on. */
697
+ private async extrasCap(userID: string, record: RecordingRecord, scope: "user" | "recording", limit: number): Promise<void> {
698
+ const key = `extras:${scope}`
699
+ const result = await this.store.update(userID, record.id, (current) => (current.capNoted.includes(key) ? undefined : { ...current, capNoted: [...current.capNoted, key] }))
700
+ if (result?.changed) this.writer.add(userID, record.id, "cap.reached", { what: "extras", scope, limit })
701
+ }
702
+
703
+ /**
704
+ * (Developer mode) A request line for one of the user's voice requests naming a recording (transcribe, speak:
705
+ * x-hub-recording; events: the path): only into an active developer recording of that device. Never a body.
706
+ */
707
+ async requestLine(userID: string, deviceID: string, recordingID: string | null | undefined, line: RequestLine): Promise<void> {
708
+ if (!recordingID || !this.mayRecord(userID)) return
709
+ const record = await this.store.visible(userID, recordingID).catch(() => undefined)
710
+ if (!record?.developer || record.deviceID !== deviceID || (record.state !== "active" && !(record.graceUntil !== undefined && this.now() <= record.graceUntil))) return
711
+ this.writer.add(userID, record.id, "request", { route: line.route, method: line.method, status: line.status, ms: Math.round(line.ms), pod: this.options.pod ?? "hub" })
712
+ }
713
+
516
714
  /** POST …/conversation: the event, and (off) the session's recordings end with a grace for the last batches. */
517
715
  async conversationChanged(userID: string, sessionID: string, on: boolean): Promise<void> {
518
716
  await this.hubNow(userID, sessionID, "conversation", { on })
@@ -656,3 +854,23 @@ export type StartResult =
656
854
  | { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
657
855
 
658
856
  export { summaryOf }
857
+
858
+ const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
859
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
860
+ let at = 0
861
+ for (const c of chunks) {
862
+ out.set(c, at)
863
+ at += c.length
864
+ }
865
+ return out
866
+ }
867
+
868
+ /** stt.result's provider fields (capture.v2): the upstream HTTP status, response bytes and time to headers, when known. */
869
+ function providerStats(stats: TranscribeStats | undefined): Record<string, number> {
870
+ const out: Record<string, number> = {}
871
+ const finite = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x)
872
+ if (finite(stats?.status)) out.httpStatus = stats.status
873
+ if (finite(stats?.bytes)) out.responseBytes = stats.bytes
874
+ if (finite(stats?.headersMs)) out.headersMs = Math.round(stats.headersMs)
875
+ return out
876
+ }
@@ -3,7 +3,7 @@
3
3
  // ifVersion 0 (an existing key is a page stored before a crash: treated as stored), then the record CAS-updated.
4
4
  // Hub events: buffered per pod and recording, written as pages of up to hubFlushEvents every hubFlushMs (and at
5
5
  // end and drain) under h<podTag>-<n>; a pod that dies loses at most its last hubFlushMs of hub events.
6
- import { type ConsoleEvent, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
6
+ import { type ConsoleEvent, DEV_CONSOLE_KINDS, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
7
7
  import { BlobQuotaError } from "../artifacts/blob-store"
8
8
  import type { Clock, Timers } from "../backend/types"
9
9
  import { consoleRecord, hubRecord } from "./schema"
@@ -15,6 +15,7 @@ export const PAGE_QUOTA_BYTES = L.userBytes + L.activePerUser * L.eventBytesPerR
15
15
  /** Room past PAGE_QUOTA_BYTES for the end markers of a recording whose user filled the page quota. */
16
16
  export const CONTROL_HEADROOM_BYTES = 1024 * 1024
17
17
  const CONTROL_KINDS = new Set<string>(["cap.reached", "recording.ended", "error"])
18
+ const DEV_KINDS: ReadonlySet<string> = new Set(DEV_CONSOLE_KINDS)
18
19
  /** A page is the batch plus the envelope the hub adds (≈ 50 bytes per event). */
19
20
  const PAGE_MAX_BYTES = L.batchBytes + L.eventsPerBatch * 128
20
21
 
@@ -33,7 +34,15 @@ export async function ingestConsole(store: RecordingStore, userID: string, recor
33
34
  const fresh = events.filter((e) => e.seq > record.consoleThrough)
34
35
  if (!fresh.length) return { kind: "stored", record }
35
36
  const at = now()
36
- const records = fresh.map((e) => consoleRecord(e, at))
37
+ // Developer mode's kinds only in a developer recording (a console that has not seen it turned off yet): dropped
38
+ // here, their seqs still count as stored (no gap).
39
+ const kept = record.developer ? fresh : fresh.filter((e) => !DEV_KINDS.has(e.kind))
40
+ if (!kept.length) {
41
+ const last = fresh.at(-1)!.seq
42
+ const updated = await store.update(userID, record.id, (current) => (current.state === "deleting" || current.consoleThrough >= last ? undefined : { ...current, consoleThrough: last, consoleGaps: current.consoleGaps + Math.max(0, fresh[0]!.seq - (current.consoleThrough + 1)), lastEventAt: at }))
43
+ return updated ? { kind: "stored", record: updated.record } : { kind: "gone" }
44
+ }
45
+ const records = kept.map((e) => consoleRecord(e, at))
37
46
  const page = ndjson(records)
38
47
  if (record.events + records.length > L.eventsPerRecording || record.eventBytes + page.length > L.eventBytesPerRecording) return { kind: "limit" }
39
48
  const first = fresh[0]!.seq
@@ -2,10 +2,10 @@
2
2
  // is a console route with access "user": the user's cookie or device token only (the hub refuses session, app and
3
3
  // act tokens before these run), so no agent, not even the Concierge, can read or export a recording, and there is
4
4
  // no admin route. Every lookup is by (user.id, id); another user's, an unknown and a deleted ID answer 404 alike.
5
- import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, type RecordingClient } from "../../protocol"
5
+ import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, RECORDING_REPLY_TYPE, type RecordingClient } from "../../protocol"
6
6
  import { readBounded } from "../agent/body"
7
7
  import { checkConsoleBatch } from "./schema"
8
- import { audioKey, blobTenant, isSegmentNumber, summaryOf, type AudioEntry } from "./store"
8
+ import { audioKey, blobTenant, clipKey, type ExtraEntry, isSegmentNumber, replyKey, summaryOf, type AudioEntry } from "./store"
9
9
  import { exportEntries } from "./export"
10
10
  import type { RecordingService, StartInput } from "./index"
11
11
  import { zipStream } from "./zip"
@@ -169,6 +169,25 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
169
169
  return n
170
170
  }
171
171
 
172
+ /** A clip's or reply's number: 1 to the recording's limit. */
173
+ const extraNumber = (raw: string | undefined, max: number, what: string): number => {
174
+ const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0
175
+ if (n < 1 || n > max) throw new BadInput(`${what} must be a number 1–${max}`)
176
+ return n
177
+ }
178
+
179
+ /** GET of a stored extra (a clip or a reply's audio): owner-only, private, as a download. */
180
+ const extraResponse = async (userID: string, id: string, key: string, type: string, file: string): Promise<Response> => {
181
+ const record = await service.visible(userID, id)
182
+ if (!record) return notFound()
183
+ const entry = await service.store.indexEntry<ExtraEntry>(userID, record.id, key)
184
+ const blob = entry ? await service.store.blobs.get(blobTenant(userID), entry.hash) : undefined
185
+ if (!blob) return notFound()
186
+ return new Response(blob.body, {
187
+ headers: { ...PRIVATE, "content-type": type, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${file}"` },
188
+ })
189
+ }
190
+
172
191
  return [
173
192
  route("GET", "/api/settings/recording", async ({ user }) =>
174
193
  Response.json({ settings: await service.settings.get(user.id), usage: await service.usage(user.id), limits: { retentionDays: L.retentionMs / 86_400_000, userBytes: L.userBytes, recordingBytes: L.recordingBytes } }, { headers: NO_STORE }),
@@ -176,11 +195,14 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
176
195
  route("PUT", "/api/settings/recording", async ({ request, user, device }) => {
177
196
  const input = await jsonOf(request)
178
197
  if (input instanceof Response) return input
179
- only(input, ["enabled", "expectedRevision"])
180
- if (typeof input.enabled !== "boolean" || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) {
181
- throw new BadInput("Expected {enabled: boolean, expectedRevision: number}")
198
+ only(input, ["enabled", "developer", "expectedRevision"])
199
+ const flag = (v: unknown) => v === undefined || typeof v === "boolean"
200
+ if (!flag(input.enabled) || !flag(input.developer) || (input.enabled === undefined && input.developer === undefined) || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) {
201
+ throw new BadInput("Expected {enabled?: boolean, developer?: boolean, expectedRevision: number}")
182
202
  }
183
- const result = await service.setEnabled(user.id, device.id, input.enabled, input.expectedRevision as number)
203
+ if (input.enabled === false && input.developer === true) throw new BadInput("Developer mode needs recording on")
204
+ const change = { ...(input.enabled !== undefined ? { enabled: input.enabled as boolean } : {}), ...(input.developer !== undefined ? { developer: input.developer as boolean } : {}) }
205
+ const result = await service.setSettings(user.id, device.id, change, input.expectedRevision as number)
184
206
  if (!result.ok) return fail(409, "The setting changed meanwhile; reload it", "conflict", { currentRevision: result.currentRevision })
185
207
  return Response.json({ settings: result.settings }, { headers: NO_STORE })
186
208
  }),
@@ -199,7 +221,10 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
199
221
  return fail(429, `At most ${L.recordingsStored} recordings are kept; delete some in Settings → Voice`, "limit", { limit: L.recordingsStored })
200
222
  }
201
223
  const record = started.record
202
- return Response.json({ recording: summaryOf(record), audio: record.audio, hubAt: options.now(), features: [...RECORDING_FEATURES] }, { status: started.kind === "created" ? 201 : 200, headers: NO_STORE })
224
+ return Response.json(
225
+ { recording: summaryOf(record), audio: record.audio, hubAt: options.now(), features: [...RECORDING_FEATURES], ...(record.developer ? { developer: true } : {}) },
226
+ { status: started.kind === "created" ? 201 : 200, headers: NO_STORE },
227
+ )
203
228
  }),
204
229
  route("GET", "/api/recordings", async ({ url, user }) => {
205
230
  const limitRaw = url.searchParams.get("limit")
@@ -235,6 +260,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
235
260
  return after ? Response.json({ recording: summaryOf(after) }, { headers: NO_STORE }) : notFound()
236
261
  }),
237
262
  route("POST", "/api/recordings/:id/events", async ({ request, params, user, device }) => {
263
+ const startedAt = options.now()
238
264
  const appendable = await service.appendable(user.id, device.id, params.id!)
239
265
  if (appendable.kind === "not_found") return notFound()
240
266
  if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
@@ -244,6 +270,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
244
270
  const checked = checkConsoleBatch(input.events)
245
271
  if (!checked.ok) return checked.limit ? fail(413, checked.problem, "limit") : fail(400, checked.problem, "invalid", checked.seq !== undefined ? { seq: checked.seq } : {})
246
272
  const appended = await service.appendConsole(user.id, appendable.record, checked.events)
273
+ void service.requestLine(user.id, device.id, appendable.record.id, { route: "events", method: "POST", status: appended.kind === "ok" ? 200 : appended.kind === "limit" ? 413 : 404, ms: options.now() - startedAt }).catch(() => undefined)
247
274
  if (appended.kind === "not_found") return notFound()
248
275
  if (appended.kind === "limit") return fail(413, "This recording reached its event limit and has ended", "limit")
249
276
  return Response.json({ through: appended.through, hubAt: options.now(), audio: appended.audio }, { headers: NO_STORE })
@@ -275,6 +302,33 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
275
302
  headers: { ...PRIVATE, "content-type": RECORDING_AUDIO_TYPE, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${n}.wav"` },
276
303
  })
277
304
  }),
305
+ // Developer mode: audio clips around near-misses and marks (uploaded by the recording's device) and reply audio.
306
+ route("PUT", "/api/recordings/:id/clips/:n", async ({ request, params, user, device }) => {
307
+ const appendable = await service.appendable(user.id, device.id, params.id!)
308
+ if (appendable.kind === "not_found") return notFound()
309
+ const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
310
+ if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
311
+ if (!appendable.record.developer) return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
312
+ const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
313
+ if (type !== RECORDING_AUDIO_TYPE) return fail(415, `Clips are ${RECORDING_AUDIO_TYPE}`, "unsupported")
314
+ const bytes = await bytesOf(request, service.devLimits.clipBytes)
315
+ if (bytes instanceof Response) return bytes
316
+ if (!bytes.length) throw new BadInput("Empty clip")
317
+ const stored = await service.storeExtra(user.id, appendable.record, "clip", n, bytes)
318
+ if (stored.kind === "cap") return fail(413, "Developer mode's share of the storage is full: the clip is not saved", "limit")
319
+ if (stored.kind === "gone") return notFound()
320
+ if (stored.kind === "off") return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
321
+ if (stored.kind === "failed") return fail(503, "The clip could not be stored; retry", "unavailable")
322
+ return Response.json({ clip: { n, bytes: stored.entry.bytes, hash: stored.entry.hash } }, { status: stored.fresh ? 201 : 200, headers: NO_STORE })
323
+ }),
324
+ route("GET", "/api/recordings/:id/clips/:n", async ({ params, user }) => {
325
+ const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
326
+ return extraResponse(user.id, params.id!, clipKey(n), RECORDING_AUDIO_TYPE, `clip-${n}.wav`)
327
+ }),
328
+ route("GET", "/api/recordings/:id/replies/:n", async ({ params, user }) => {
329
+ const n = extraNumber(params.n, service.devLimits.repliesPerRecording, "reply")
330
+ return extraResponse(user.id, params.id!, replyKey(n), RECORDING_REPLY_TYPE, `reply-${n}.mp3`)
331
+ }),
278
332
  route("GET", "/api/recordings/:id/export", async ({ params, user }) => ((await service.visible(user.id, params.id!)) ? exportOf(user.id, params.id!) : notFound())),
279
333
  route("DELETE", "/api/recordings/:id", async ({ params, user }) => ((await service.deleteOne(user.id, params.id!)) ? new Response(null, { status: 204 }) : notFound())),
280
334
  ]
@@ -32,6 +32,8 @@ const object = (fields: Fields): FieldSpec => ({ t: "object", fields })
32
32
  const L = RECORDING_LIMITS
33
33
  const ref = object({ messageID: id, ordinal: int, "seq?": int, i: int })
34
34
  const AUDIO_RESULTS = ["stored", "skipped-cap", "failed", "off"]
35
+ /** A device label (enumerateDevices, a track's label): ✂ like any text. */
36
+ const DEVICE_LABEL = 120
35
37
 
36
38
  /** The console kinds (REC-02 emits exactly these fields). */
37
39
  export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
@@ -42,13 +44,25 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
42
44
  "segment.closed": { segment: int, bytes: int, durationMs: num, speechMs: num, forced: bool },
43
45
  "stt.sent": { segment: int, bytes: int },
44
46
  // `text` is optional: a failed request has none (failed names why).
45
- "stt.received": { segment: int, "text?": text(L.textChars), "junk?": yes, "empty?": yes, "failed?": text(L.messageChars), "audio?": oneOf(...AUDIO_RESULTS) },
47
+ // Feature "capture.v2" adds junkReason (junkTranscript's reason), held (the text held at that moment) and speechMs.
48
+ "stt.received": {
49
+ segment: int,
50
+ "text?": text(L.textChars),
51
+ "junk?": yes,
52
+ "empty?": yes,
53
+ "failed?": text(L.messageChars),
54
+ "audio?": oneOf(...AUDIO_RESULTS),
55
+ "junkReason?": label,
56
+ "held?": text(L.utteranceChars),
57
+ "speechMs?": num,
58
+ },
46
59
  "utterance.held": { text: text(L.utteranceChars) },
47
60
  "utterance.merged": { segments: list(int, 1000), pauseMs: num },
48
61
  "utterance.sent": { text: text(L.utteranceChars), segments: list(int, 1000), reason: oneOf("finished", "hold", "max", "command", "typed", "smart", "smart-fallback"), holdMs: num },
49
62
  "utterance.accepted": { inboxID: id },
50
63
  "utterance.failed": { "status?": int, message: text(L.messageChars) },
51
- "utterance.command": { command: oneOf("pause", "interrupt"), text: text(L.textChars) },
64
+ // "mark": feature "capture.v2".
65
+ "utterance.command": { command: oneOf("pause", "interrupt", "mark"), text: text(L.textChars) },
52
66
  heard: { inboxID: id },
53
67
  // The contract's `seq?` (the say's seq) collides with the envelope's `seq`: a flat event carries only the latter.
54
68
  "say.received": { messageID: id, ordinal: int, chars: int, sentences: int, muted: bool },
@@ -70,6 +84,56 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
70
84
  "turn.timing": { "speechEnd?": num, "transcript?": num, "accepted?": num, "firstSay?": num, "firstByte?": num, firstAudio: num },
71
85
  // Feature "turn.predict" (Smart Turn): a score (none when the model failed or was slow) and what it decided.
72
86
  "turn.predict": { "p?": num, ms: num, silenceMs: num, decision: oneOf("send", "hold", "fallback", "timer-fallback"), "error?": text(L.messageChars) },
87
+ // Feature "capture.v2": what the detector almost took for speech, its 10 s summary, a slow STT answer, a bookmark.
88
+ "vad.near": {
89
+ ms: num,
90
+ "voicedMs?": num,
91
+ "peakProb?": num,
92
+ "peakDb?": num,
93
+ threshold: num,
94
+ echoGuard: bool,
95
+ mode: oneOf("silero", "energy"),
96
+ reason: oneOf("short-onset", "below-threshold", "voiced-ratio"),
97
+ },
98
+ "vad.summary": {
99
+ frames: int,
100
+ "maxProb?": num,
101
+ "p90Prob?": num,
102
+ "maxDb?": num,
103
+ "noiseFloorDb?": num,
104
+ framesAboveStart: int,
105
+ framesAboveSpeakingStart: int,
106
+ msEchoGuard: num,
107
+ "nearDropped?": int,
108
+ mode: oneOf("silero", "energy"),
109
+ sensitivity: oneOf("low", "normal", "high"),
110
+ },
111
+ "stt.slow": { segment: int, ms: num },
112
+ mark: { via: oneOf("voice", "button"), "note?": text(L.textChars) },
113
+ // Feature "dev.v1" (Developer mode; stored only in a developer recording, see ingest).
114
+ "vad.trace": {
115
+ reason: oneOf("speech.start", "speech.end", "vad.near"),
116
+ mode: oneOf("silero", "energy"),
117
+ frameMs: num,
118
+ // p100: probabilities × 100; db: dBFS, both rounded to integers. `guard`: one "0"/"1" per frame (echo guard up).
119
+ scale: oneOf("p100", "db"),
120
+ values: list(int, 128),
121
+ threshold: int,
122
+ guard: { t: "str", max: 128 },
123
+ },
124
+ "audio.clip": { clip: int, for: object({ kind: oneOf("vad.near", "mark"), seq: int }), preMs: num, postMs: num, bytes: int },
125
+ "device.info": {
126
+ reason: oneOf("start", "change"),
127
+ "outputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
128
+ "inputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
129
+ "track?": object({ "echoCancellation?": bool, "noiseSuppression?": bool, "autoGainControl?": bool, "sampleRate?": num, "channelCount?": num, "label?": text(DEVICE_LABEL) }),
130
+ "network?": object({ "effectiveType?": label, "rtt?": num, "downlink?": num, "saveData?": bool }),
131
+ "visibility?": label,
132
+ "wakeLock?": bool,
133
+ "battery?": object({ "level?": num, "charging?": bool }),
134
+ },
135
+ "resource.timing": { route: oneOf("transcribe", "speak", "events", "utterance", "recording"), n: int, medianMs: num, maxMs: num, "medianTtfbMs?": num, "bytes?": int },
136
+ "console.log": { level: oneOf("error", "warn", "unhandled"), message: text(L.messageChars), "count?": int, "source?": label },
73
137
  "decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
74
138
  "clock.sync": { t0: num, t1: num, hubAt: num },
75
139
  error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },