@huynle/hub 0.10.1 → 0.11.1

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -8
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +851 -466
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/grants-mcp.ts +65 -0
  12. package/packages/cli/src/mcp-operations.ts +49 -0
  13. package/packages/cli/src/mcp.ts +26 -4
  14. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  15. package/packages/cli/src/team-intake-mcp.ts +2 -1
  16. package/packages/cli/src/teams-mcp.ts +9 -3
  17. package/packages/codex/package.json +1 -1
  18. package/packages/codex/src/sessions.ts +14 -3
  19. package/packages/node/package.json +1 -1
  20. package/packages/preset/package.json +1 -1
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/protocol/schema/api/v1/_root.json +119 -0
  23. package/packages/protocol/schema/api/v1/account.json +419 -0
  24. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  25. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  26. package/packages/protocol/schema/api/v1/events.json +679 -0
  27. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  28. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  29. package/packages/protocol/schema/api/v1/speech.json +492 -0
  30. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  31. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  32. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  33. package/packages/protocol/src/api-v1.ts +8 -0
  34. package/packages/protocol/src/index.ts +2 -0
  35. package/packages/protocol/src/teams.ts +16 -0
  36. package/packages/protocol/src/voice-recording.ts +39 -2
  37. package/packages/runtime-openshell/image/entry.sh +3 -1
  38. package/packages/runtime-openshell/image/start.sh +3 -1
  39. package/packages/runtime-openshell/package.json +1 -1
  40. package/packages/runtime-sdk/package.json +1 -1
  41. package/packages/sdk/package.json +1 -1
  42. package/packages/sdk/src/app-front.ts +2 -2
  43. package/packages/sdk/src/ask-fields.ts +76 -0
  44. package/packages/sdk/src/client.ts +24 -2
  45. package/packages/sdk/src/envelope.ts +3 -2
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/team-intake.ts +6 -0
  48. package/packages/sdk/src/teams.ts +4 -0
  49. package/packages/sdk/src/types.ts +36 -4
  50. package/packages/sdk/src/wake.ts +9 -4
  51. package/packages/spoke-sdk/package.json +1 -1
  52. package/src/central/agent/agent-limits.ts +185 -0
  53. package/src/central/agent/app-decisions.ts +6 -5
  54. package/src/central/agent/decisions.ts +136 -9
  55. package/src/central/agent/delivery.ts +164 -22
  56. package/src/central/agent/errors.ts +2 -1
  57. package/src/central/agent/events.ts +20 -2
  58. package/src/central/agent/grant-routes.ts +14 -1
  59. package/src/central/agent/grants.ts +23 -0
  60. package/src/central/agent/launch-engine.ts +9 -6
  61. package/src/central/agent/message-limits.ts +6 -0
  62. package/src/central/agent/message-routes.ts +33 -6
  63. package/src/central/agent/messages.ts +7 -0
  64. package/src/central/agent/placement.ts +4 -2
  65. package/src/central/agent/read-routes.ts +5 -0
  66. package/src/central/agent/routes.ts +13 -0
  67. package/src/central/agent/subscriptions.ts +15 -4
  68. package/src/central/api-v1.ts +115 -0
  69. package/src/central/artifacts/store.ts +4 -2
  70. package/src/central/concierge/board-results.ts +2 -1
  71. package/src/central/concierge/notifier.ts +4 -2
  72. package/src/central/config.ts +24 -3
  73. package/src/central/health/classify.ts +209 -0
  74. package/src/central/http.ts +380 -36
  75. package/src/central/recording/export.ts +57 -3
  76. package/src/central/recording/index.ts +236 -17
  77. package/src/central/recording/ingest.ts +11 -2
  78. package/src/central/recording/routes.ts +61 -7
  79. package/src/central/recording/schema.ts +68 -3
  80. package/src/central/recording/settings.ts +19 -5
  81. package/src/central/recording/store.ts +19 -0
  82. package/src/central/recording/taps.ts +45 -1
  83. package/src/central/registry.ts +34 -3
  84. package/src/central/speech/openai.ts +4 -4
  85. package/src/central/speech/openrouter.ts +4 -4
  86. package/src/central/speech/provider.ts +32 -1
  87. package/src/central/speech/service.ts +6 -5
  88. package/src/central/teams/budgets.ts +144 -0
  89. package/src/central/teams/hop-stalls.ts +80 -0
  90. package/src/central/teams/intake.ts +38 -6
  91. package/src/central/teams/involvement.ts +95 -0
  92. package/src/central/teams/item-decisions.ts +60 -0
  93. package/src/central/teams/item-eligibility.ts +35 -0
  94. package/src/central/teams/item-stalls.ts +87 -0
  95. package/src/central/teams/loops.ts +40 -0
  96. package/src/central/teams/policy.ts +10 -0
  97. package/src/central/teams/progress.ts +154 -0
  98. package/src/central/teams/routes.ts +2 -0
  99. package/src/central/teams/seats.ts +3 -3
  100. package/src/central/teams/staffing.ts +5 -0
  101. package/src/central/teams/store.ts +20 -1
  102. package/src/central/teams/validation.ts +16 -2
  103. package/src/central/workspaces/coordinator.ts +3 -1
  104. package/src/spoke/hub-tool.ts +26 -11
  105. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
  106. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
  107. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  108. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
  109. package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
  110. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
  111. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  112. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  113. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  114. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  115. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  116. package/web/dist/assets/live-B2IfKm37.js +5 -0
  117. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  118. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  119. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
  120. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
  121. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
  122. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  123. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  124. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  125. package/web/dist/index.html +5 -5
  126. package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
  127. package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
  128. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  129. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -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,7 +84,58 @@ 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) },
73
- "decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
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 },
137
+ // DC-1: a choices Decision is answered with picks (one per group) instead of an option.
138
+ "decision.voice": { decisionID: id, "option?": id, "picks?": list(object({ group: label, option: label }), 12), outcome: oneOf("answered", "refused", "failed") },
74
139
  "clock.sync": { t0: num, t1: num, hubAt: num },
75
140
  error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
76
141
  }
@@ -7,7 +7,15 @@ export const SETTINGS_COLLECTION = "voice-recording-settings"
7
7
  const KEY = "settings"
8
8
  export const DEFAULT_SETTINGS: RecordingSettings = { enabled: false, revision: 0 }
9
9
 
10
- export type SettingsWrite = { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean } | { readonly ok: false; readonly currentRevision: number }
10
+ export type SettingsWrite =
11
+ | { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean; readonly developerChanged: boolean }
12
+ | { readonly ok: false; readonly currentRevision: number }
13
+
14
+ /** A PUT's change: either or both (never enabled: false with developer: true, the route refuses it). */
15
+ export interface SettingsChange {
16
+ readonly enabled?: boolean
17
+ readonly developer?: boolean
18
+ }
11
19
 
12
20
  export class RecordingSettingsStore {
13
21
  constructor(
@@ -19,15 +27,21 @@ export class RecordingSettingsStore {
19
27
  return (await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY))?.value ?? DEFAULT_SETTINGS
20
28
  }
21
29
 
22
- /** Sets `enabled` when the stored revision is `expectedRevision`; a stale one answers the current revision. */
23
- async set(userID: string, enabled: boolean, expectedRevision: number): Promise<SettingsWrite> {
30
+ /**
31
+ * Sets `enabled` and/or `developer` when the stored revision is `expectedRevision`; a stale one answers the current
32
+ * revision. Developer mode implies recording: developer on turns recording on, recording off turns developer off;
33
+ * a change of `enabled` alone (an older console) keeps developer as it is.
34
+ */
35
+ async set(userID: string, change: SettingsChange, expectedRevision: number): Promise<SettingsWrite> {
24
36
  for (let attempt = 0; attempt < 8; attempt++) {
25
37
  const stored = await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY)
26
38
  const current = stored?.value ?? DEFAULT_SETTINGS
27
39
  if (current.revision !== expectedRevision) return { ok: false, currentRevision: current.revision }
28
- const next: RecordingSettings = { enabled, revision: current.revision + 1, updatedAt: this.now() }
40
+ const developer = change.developer ?? (change.enabled === false ? false : current.developer === true)
41
+ const enabled = developer || (change.enabled ?? current.enabled)
42
+ const next: RecordingSettings = { enabled, ...(current.developer !== undefined || developer ? { developer } : {}), revision: current.revision + 1, updatedAt: this.now() }
29
43
  const written = await this.state.put(userID, SETTINGS_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
30
- if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled }
44
+ if (written.ok) return { ok: true, settings: next, changed: current.enabled !== enabled, developerChanged: (current.developer === true) !== developer }
31
45
  }
32
46
  const current = await this.get(userID)
33
47
  return { ok: false, currentRevision: current.revision }
@@ -81,6 +81,19 @@ export interface AudioEntry {
81
81
  readonly redacted?: true
82
82
  }
83
83
 
84
+ /** Developer mode: an audio clip (k) or a reply sentence's audio (r), numbered per recording. */
85
+ export interface ExtraEntry {
86
+ readonly hash: string
87
+ readonly bytes: number
88
+ readonly at: number
89
+ /** Replies: the sentence's ref. */
90
+ readonly ref?: { readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly i: number }
91
+ }
92
+ export const clipKey = (n: number) => `k${String(n).padStart(6, "0")}`
93
+ export const replyKey = (n: number) => `r${String(n).padStart(6, "0")}`
94
+ /** Index keys of event pages (c…, h…); a… are segments, k… clips, r… reply audio. */
95
+ export const isPageKey = (key: string) => key.startsWith("c") || key.startsWith("h")
96
+
84
97
  export const consoleKey = (firstSeq: number) => `c${String(firstSeq).padStart(10, "0")}`
85
98
  export const hubKey = (podTag: string, n: number) => `h${podTag}-${String(n).padStart(8, "0")}`
86
99
  export const audioKey = (n: number) => `a${String(n).padStart(6, "0")}`
@@ -116,6 +129,9 @@ export function summaryOf(r: RecordingRecord): RecordingSummary {
116
129
  events: r.events,
117
130
  audio: r.audio,
118
131
  redactions: r.redactions,
132
+ ...(r.developer ? { developer: true as const } : {}),
133
+ ...(r.clips ? { clips: r.clips } : {}),
134
+ ...(r.replies ? { replies: r.replies } : {}),
119
135
  }
120
136
  }
121
137
 
@@ -274,6 +290,8 @@ export interface StartFacts {
274
290
  readonly anchor: RecordingAnchor
275
291
  readonly audio: boolean
276
292
  readonly at: number
293
+ /** Developer mode was on at start. */
294
+ readonly developer?: boolean
277
295
  }
278
296
 
279
297
  export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingRecord {
@@ -303,6 +321,7 @@ export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingReco
303
321
  lastEventAt: facts.at,
304
322
  capNoted: [],
305
323
  skipped: { cap: 0, failed: 0 },
324
+ ...(facts.developer ? { developer: true as const } : {}),
306
325
  ...speech,
307
326
  }
308
327
  }
@@ -10,6 +10,25 @@ export interface TapSink {
10
10
  hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
11
11
  /** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
12
12
  mayRecord(userID: string): boolean
13
+ /** (Developer mode) A turn ended: its timings, for the session's developer recordings (turn.detail). */
14
+ turnDetail?(userID: string, sessionID: string, detail: TurnTimings): void
15
+ }
16
+
17
+ /** A turn's timings (ms from its start), for Developer mode's turn.detail. Tool names only, never their input. */
18
+ export interface TurnTimings {
19
+ readonly outcome: "succeeded" | "failed" | "interrupted"
20
+ readonly ms: number
21
+ readonly firstTokenMs?: number
22
+ readonly tools: ReadonlyArray<{ readonly callID: string; readonly tool?: string; readonly startMs: number; readonly endMs?: number; readonly state: "called" | "success" | "failed" }>
23
+ }
24
+
25
+ /** Tool calls kept per turn in turn.detail. */
26
+ const TURN_TOOLS_MAX = 50
27
+
28
+ interface TurnTrack {
29
+ readonly at: number
30
+ firstTokenMs?: number
31
+ readonly tools: Map<string, { callID: string; tool?: string; startMs: number; endMs?: number; state: "called" | "success" | "failed" }>
13
32
  }
14
33
 
15
34
  const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
@@ -34,8 +53,16 @@ export class RegistryTaps implements RecordingTap {
34
53
  private readonly firstToken = new Set<string>()
35
54
  /** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
36
55
  private readonly tools = new Map<string, string>()
56
+ /** (Developer mode) The running turn of each root session: its start, first token and tool calls. */
57
+ private readonly turns = new Map<string, TurnTrack>()
58
+ private readonly now: () => number
37
59
 
38
- constructor(private readonly sink: TapSink) {}
60
+ constructor(
61
+ private readonly sink: TapSink,
62
+ now: () => number = Date.now,
63
+ ) {
64
+ this.now = now
65
+ }
39
66
 
40
67
  wants(tenant: string, sessionID: string, type: string): boolean {
41
68
  if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
@@ -47,6 +74,7 @@ export class RegistryTaps implements RecordingTap {
47
74
  if (type === "session.execution.started") {
48
75
  this.firstToken.add(sessionID)
49
76
  if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
77
+ if (this.sink.turnDetail) remember(this.turns, sessionID, { at: this.now(), tools: new Map() })
50
78
  this.sink.hub(tenant, sessionID, "turn.started", {})
51
79
  return
52
80
  }
@@ -54,10 +82,17 @@ export class RegistryTaps implements RecordingTap {
54
82
  if (outcome) {
55
83
  this.firstToken.delete(sessionID)
56
84
  this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
85
+ const track = this.turns.get(sessionID)
86
+ if (track) {
87
+ this.turns.delete(sessionID)
88
+ this.sink.turnDetail?.(tenant, sessionID, { outcome, ms: this.now() - track.at, ...(track.firstTokenMs !== undefined ? { firstTokenMs: track.firstTokenMs } : {}), tools: [...track.tools.values()] })
89
+ }
57
90
  return
58
91
  }
59
92
  if (type === "session.text.delta") {
60
93
  if (!this.firstToken.delete(sessionID)) return
94
+ const track = this.turns.get(sessionID)
95
+ if (track) track.firstTokenMs = this.now() - track.at
61
96
  const { assistantMessageID: messageID, ordinal } = data
62
97
  this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
63
98
  return
@@ -71,6 +106,15 @@ export class RegistryTaps implements RecordingTap {
71
106
  if (!state || !callID) return
72
107
  const tool = this.tools.get(callID)
73
108
  if (state !== "called") this.tools.delete(callID)
109
+ const track = this.turns.get(sessionID)
110
+ if (track) {
111
+ const ms = this.now() - track.at
112
+ const call = track.tools.get(callID)
113
+ if (call) {
114
+ call.state = state
115
+ if (state !== "called") call.endMs = ms
116
+ } else if (track.tools.size < TURN_TOOLS_MAX) track.tools.set(callID, { callID, ...(tool ? { tool } : {}), startMs: ms, ...(state !== "called" ? { endMs: ms } : {}), state })
117
+ }
74
118
  // The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
75
119
  const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
76
120
  this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
@@ -19,6 +19,7 @@ import { DoneWatch } from "./done"
19
19
  import { FrontRelays, RelayRefused } from "./front-relays"
20
20
  import { SentenceStream } from "./say-stream"
21
21
  import type { ActivityStep, NotificationKind } from "./push"
22
+ import { classifyHealth, type SessionHealth } from "./health/classify"
22
23
 
23
24
  export { ACTIVITY_INTERVAL_MINUTES } from "./store"
24
25
  /** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
@@ -54,8 +55,12 @@ export interface MachineRef {
54
55
  }
55
56
 
56
57
  /** A status tree entry as the hub reports it: working sessions carry when their current run started. */
57
- /** `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen. */
58
- export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number }
58
+ /**
59
+ * `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen;
60
+ * (SS-1) `health`: why the session is not making progress (absent: ok). The state itself is unchanged (a retrying
61
+ * session stays "working").
62
+ */
63
+ export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number; readonly health?: SessionHealth }
59
64
 
60
65
  /** Timer functions (injectable for tests). */
61
66
  export interface Timers {
@@ -590,6 +595,9 @@ export class Registry {
590
595
  private readonly since = new Map<string, number>()
591
596
  /** (Session findability) Blocker ID → when this process first saw it (the "waiting 12m" of Needs you). */
592
597
  private readonly blockerSeen = new Map<string, number>()
598
+ /** (SS-1) When each session's current provider retry was first seen, and its health kind with when it began. */
599
+ private readonly retrySeen = new Map<string, number>()
600
+ private readonly healthSeen = new Map<string, { kind: SessionHealth["kind"]; since: number; detail?: string; resetAt?: number }>()
593
601
  /** Session → streamed text waiting for its send window. */
594
602
  private readonly streams = new Map<string, Stream>()
595
603
  /** Machine → its latest terminals frame and the connection that sent it. */
@@ -1645,10 +1653,33 @@ export class Registry {
1645
1653
  })
1646
1654
  : s.blockers
1647
1655
  const entry: LiveStatus = recommended ? { ...s, blockers } : s
1648
- return since === undefined && blockedSince === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}) }
1656
+ const health = this.health(s)
1657
+ return since === undefined && blockedSince === undefined && health === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}), ...(health ? { health } : {}) }
1649
1658
  })
1650
1659
  }
1651
1660
 
1661
+ /** (SS-1) The entry's health with the time its kind began; undefined while ok (the history is then forgotten). */
1662
+ private health(s: SessionStatus): SessionHealth | undefined {
1663
+ const now = this.now()
1664
+ if (s.retry && s.state === "working") {
1665
+ if (!this.retrySeen.has(s.id)) remember(this.retrySeen, s.id, now, BLOCKERS_SEEN)
1666
+ } else this.retrySeen.delete(s.id)
1667
+ const health = classifyHealth(s, { now, ...(this.retrySeen.has(s.id) ? { retrySince: this.retrySeen.get(s.id)! } : {}) })
1668
+ if (health.kind === "ok") {
1669
+ this.healthSeen.delete(s.id)
1670
+ return undefined
1671
+ }
1672
+ let seen = this.healthSeen.get(s.id)
1673
+ if (seen?.kind !== health.kind) remember(this.healthSeen, s.id, (seen = { kind: health.kind, since: now }), BLOCKERS_SEEN)
1674
+ // A relative hint ("reset after 118h") is anchored when its message is first seen: re-parsing it at every read
1675
+ // would move resetAt forward each time (and journal a change for every status update).
1676
+ if (health.resetAt !== undefined) {
1677
+ if (seen!.detail !== health.detail || seen!.resetAt === undefined) Object.assign(seen!, { detail: health.detail, resetAt: health.resetAt })
1678
+ return { ...health, resetAt: seen!.resetAt, since: seen!.since }
1679
+ }
1680
+ return { ...health, since: seen!.since }
1681
+ }
1682
+
1652
1683
  catalog(): CatalogMachine[] {
1653
1684
  const machines = new Map<string, { id: string; name: string; processes: CatalogProcess[] }>()
1654
1685
  for (const conn of this.all().sort((a, b) => a.seq - b.seq)) {
@@ -1,7 +1,7 @@
1
1
  // OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
2
2
  // STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
3
3
  // POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
4
- import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
4
+ import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
5
5
 
6
6
  export interface OpenAICompatibleOptions {
7
7
  /** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
@@ -25,7 +25,7 @@ export class OpenAICompatible implements SpeechProvider {
25
25
  this.verbose = wantsVerbose(options.sttModel)
26
26
  }
27
27
 
28
- async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
28
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
29
29
  const headers = await this.headers()
30
30
  const send = (verbose: boolean) => {
31
31
  const form = new FormData()
@@ -36,9 +36,9 @@ export class OpenAICompatible implements SpeechProvider {
36
36
  if (verbose) form.append("response_format", "verbose_json")
37
37
  return this.fetch(`${this.options.baseURL}/audio/transcriptions`, { method: "POST", headers, body: form, signal })
38
38
  }
39
- const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
39
+ const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
40
40
  if (!response.ok) throw await failure("transcription", response)
41
- return transcriptText(await response.json())
41
+ return transcriptText(await json())
42
42
  }
43
43
 
44
44
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
@@ -1,6 +1,6 @@
1
1
  // OpenRouter speech-to-text (/audio/transcriptions as OpenAI-style multipart: the audio goes up as binary,
2
2
  // not as base64 JSON, a third smaller and no encoding) and text-to-speech (/audio/speech).
3
- import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
3
+ import { audioBody, audioMime, collect, SpeechError, timedTranscription, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider, type TranscribeStats } from "./provider"
4
4
 
5
5
  export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
6
 
@@ -33,7 +33,7 @@ export class OpenRouter implements SpeechProvider {
33
33
  this.verbose = wantsVerbose(options.sttModel)
34
34
  }
35
35
 
36
- async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
36
+ async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string> {
37
37
  const { sttModel: model, language } = this.options
38
38
  // OpenRouter documents multipart file/model/language (up to 25 MB; an utterance is at most ~2 MB of WAV), and
39
39
  // response_format verbose_json (segments with Whisper's no_speech_prob) for OpenAI-compatible providers.
@@ -45,9 +45,9 @@ export class OpenRouter implements SpeechProvider {
45
45
  if (verbose) form.append("response_format", "verbose_json")
46
46
  return this.post("/audio/transcriptions", form, signal)
47
47
  }
48
- const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
48
+ const { response, json } = await timedTranscription(() => withVerboseRetry(send, this.verbose, () => (this.verbose = false)), stats)
49
49
  if (!response.ok) throw await failure("transcription", response)
50
- return transcriptText(await response.json())
50
+ return transcriptText(await json())
51
51
  }
52
52
 
53
53
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
@@ -1,15 +1,46 @@
1
1
  // Speech provider interface shared by OpenRouter and OpenAI-compatible endpoints, the typed upstream
2
2
  // error (mapped to 502 by the hub), audio format detection, and the speed-retry rule.
3
3
 
4
+ /**
5
+ * (Recording) What a transcription's upstream request looked like, filled in by the provider when asked: the HTTP
6
+ * status, the response's bytes and the time to its headers (ms, from the first request: a verbose retry included).
7
+ */
8
+ export interface TranscribeStats {
9
+ status?: number
10
+ bytes?: number
11
+ headersMs?: number
12
+ }
13
+
4
14
  /** Speech-to-text and text-to-speech against one configured provider. */
5
15
  export interface SpeechProvider {
6
- transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
16
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, stats?: TranscribeStats): Promise<string>
7
17
  /** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
8
18
  speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
9
19
  /** The same MP3 as a stream, passed through as the provider sends it (errors are thrown before any byte). */
10
20
  speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
11
21
  }
12
22
 
23
+ /**
24
+ * Sends a transcription (`send`, with its retries) noting the status and the time to the headers in `stats`, then
25
+ * reads the JSON body noting its size. A failed status is returned as is (the caller turns it into its error).
26
+ */
27
+ export async function timedTranscription(send: () => Promise<Response>, stats: TranscribeStats | undefined): Promise<{ response: Response; json: () => Promise<unknown> }> {
28
+ const started = performance.now()
29
+ const response = await send()
30
+ if (stats) {
31
+ stats.status = response.status
32
+ stats.headersMs = Math.round(performance.now() - started)
33
+ }
34
+ return {
35
+ response,
36
+ json: async () => {
37
+ const raw = new Uint8Array(await response.arrayBuffer())
38
+ if (stats) stats.bytes = raw.byteLength
39
+ return JSON.parse(new TextDecoder().decode(raw))
40
+ },
41
+ }
42
+ }
43
+
13
44
  /** A successful upstream response's body (an empty stream when it has none). */
14
45
  export function audioBody(response: Response): ReadableStream<Uint8Array> {
15
46
  return response.body ?? new ReadableStream({ start: (controller) => controller.close() })
@@ -3,7 +3,7 @@
3
3
  import { OpenAICompatible } from "./openai"
4
4
  import { OpenRouter } from "./openrouter"
5
5
  import { speechModelList, withCurrent, type SpeechModelInfo, type SpeechModelList } from "./models"
6
- import { SpeechError, upstreamDetail, type SpeechProvider } from "./provider"
6
+ import { SpeechError, upstreamDetail, type SpeechProvider, type TranscribeStats } from "./provider"
7
7
  import {
8
8
  lowerLayers,
9
9
  resolveStt,
@@ -34,7 +34,8 @@ export interface SpeechSettingsStore {
34
34
 
35
35
  /** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
36
36
  export interface ScopedSpeech {
37
- transcribe(audio: Uint8Array, format: string): Promise<string>
37
+ /** `stats`: the upstream request's status, bytes and time to headers (recording mode). */
38
+ transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
38
39
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
39
40
  /** The audio as the provider streams it; `signal` aborts the upstream request (the phone moved on). */
40
41
  speakStream(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
@@ -94,14 +95,14 @@ export class SpeechService {
94
95
  /** Speech with a user's effective settings (undefined: the server default). */
95
96
  forUser(userID: string | undefined): ScopedSpeech {
96
97
  return {
97
- transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
98
+ transcribe: (audio, format, stats) => this.transcribe(audio, format, undefined, userID, stats),
98
99
  speak: (text) => this.speak(text, undefined, userID),
99
100
  speakStream: (text, signal) => this.speakStream(text, signal, userID),
100
101
  }
101
102
  }
102
103
 
103
- transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string): Promise<string> {
104
- return this.provider("stt", userID).transcribe(audio, format, signal)
104
+ transcribe(audio: Uint8Array, format: string, signal?: AbortSignal, userID?: string, stats?: TranscribeStats): Promise<string> {
105
+ return this.provider("stt", userID).transcribe(audio, format, signal, stats)
105
106
  }
106
107
 
107
108
  speak(text: string, signal?: AbortSignal, userID?: string): Promise<Uint8Array<ArrayBuffer>> {