@huynle/hub 0.8.2 → 0.8.3

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 (67) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +35 -7
  6. package/packages/cli/package.json +1 -1
  7. package/packages/codex/package.json +1 -1
  8. package/packages/node/package.json +1 -1
  9. package/packages/preset/package.json +1 -1
  10. package/packages/protocol/package.json +1 -1
  11. package/packages/protocol/src/index.ts +9 -1
  12. package/packages/protocol/src/voice-recording.ts +107 -0
  13. package/packages/runtime-openshell/package.json +1 -1
  14. package/packages/runtime-sdk/package.json +1 -1
  15. package/packages/sdk/package.json +1 -1
  16. package/packages/spoke-sdk/package.json +1 -1
  17. package/src/central/agent/routes.ts +7 -1
  18. package/src/central/artifacts/blob-store.ts +6 -1
  19. package/src/central/artifacts/blob-support.ts +3 -1
  20. package/src/central/concierge/board-render.ts +34 -11
  21. package/src/central/concierge/index.ts +1 -1
  22. package/src/central/concierge/notifier.ts +2 -1
  23. package/src/central/config.ts +4 -0
  24. package/src/central/csp.ts +55 -0
  25. package/src/central/http.ts +85 -9
  26. package/src/central/profiles/content.ts +16 -0
  27. package/src/central/recording/clock.ts +76 -0
  28. package/src/central/recording/concierge-taps.ts +104 -0
  29. package/src/central/recording/export.ts +215 -0
  30. package/src/central/recording/index.ts +658 -0
  31. package/src/central/recording/ingest.ts +209 -0
  32. package/src/central/recording/retention.ts +71 -0
  33. package/src/central/recording/routes.ts +281 -0
  34. package/src/central/recording/schema.ts +232 -0
  35. package/src/central/recording/settings.ts +35 -0
  36. package/src/central/recording/store.ts +316 -0
  37. package/src/central/recording/taps.ts +105 -0
  38. package/src/central/recording/zip.ts +138 -0
  39. package/src/central/registry.ts +68 -3
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/spoke/session-context.ts +28 -8
  42. package/src/spoke/spoke.ts +3 -0
  43. package/src/spoke/tracker.ts +6 -1
  44. package/src/status.ts +31 -5
  45. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
  46. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
  47. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  48. package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
  49. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  50. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
  51. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  52. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  53. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  54. package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
  55. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  56. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
  57. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
  60. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  61. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  62. package/web/dist/index.html +5 -16
  63. package/web/dist/theme-boot.js +12 -0
  64. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  65. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  66. package/web/dist/assets/index-BabrNDB3.css +0 -1
  67. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -0,0 +1,658 @@
1
+ // REC-01 voice & Concierge recording mode, server half (docs/plans/2026-11-05-voice-recording-contract.md,
2
+ // docs/voice-recording.md). The RecordingService owns the setting, the recordings (records, index, blobs under the
3
+ // `rec~<userID>` tenant), console and hub events, audio, export, delete and the retention sweep. The hub wires its
4
+ // routes (user-only console routes), the voice route hooks, the registry and Concierge taps and the leader.
5
+ //
6
+ // Rules kept everywhere: a recording belongs to one user, one session and one device; every lookup is by
7
+ // (user.id, id) and anything else answers 404 Not found; appends need the recording's own device; hub taps record
8
+ // only while a recording is active for that session, only into the session owner's recordings, and never change
9
+ // what the voice path answers (failures are logged without content or IDs).
10
+ import { randomBytes } from "node:crypto"
11
+ import {
12
+ RECORDING_AUDIO_TYPE,
13
+ RECORDING_HEADER,
14
+ RECORDING_LIMITS,
15
+ RECORDING_REF_HEADER,
16
+ RECORDING_SEGMENT_HEADER,
17
+ type HubEventKind,
18
+ type RecordingAudioResult,
19
+ type RecordingEndReason,
20
+ type RecordingKind,
21
+ type RecordingSettings,
22
+ type RecordingUsage,
23
+ } from "../../protocol"
24
+ import { type BlobStore, BlobQuotaError } from "../artifacts/blob-store"
25
+ import type { Decision } from "../agent/decisions"
26
+ import { partyKey } from "../agent/envelope"
27
+ import type { Bus, Clock, StateStore } from "../backend/types"
28
+ import { redactCredentials } from "../profiles/content"
29
+ import type { ConciergeCallFields } from "./concierge-taps"
30
+ import { HubEventWriter, ingestConsole, PAGE_QUOTA_BYTES } from "./ingest"
31
+ import { RecordingRetention } from "./retention"
32
+ import { RecordingSettingsStore } from "./settings"
33
+ import {
34
+ type AudioEntry,
35
+ audioKey,
36
+ ended,
37
+ isSegmentNumber,
38
+ newRecord,
39
+ newRecordingID,
40
+ type RecordingRecord,
41
+ RecordingStore,
42
+ type SpeechFacts,
43
+ summaryOf,
44
+ } from "./store"
45
+ import { parseTtsRef, RegistryTaps } from "./taps"
46
+
47
+ const L = RECORDING_LIMITS
48
+ export const RECORDINGS_TOPIC = "recordings.changed"
49
+ /** How long the per-pod active index is trusted without a bus refresh. */
50
+ export const ACTIVE_TTL_MS = 30_000
51
+ /** The grace after a hub-initiated conversation_off end during which the console's last batches are accepted. */
52
+ export const END_GRACE_MS = 10_000
53
+ /** /api/transcribe bodies up to this size are stored before answering (so a failure can be retried with PUT). */
54
+ export const SYNC_AUDIO_BYTES = 512 * 1024
55
+ /** Exports per user at once. */
56
+ export const EXPORTS_PER_USER = 2
57
+
58
+ /** A console SSE message (IDs, state and counts only). */
59
+ export type RecordingMessage = { type: "recording"; [key: string]: unknown }
60
+
61
+ export interface RecordingServiceOptions {
62
+ readonly state: StateStore
63
+ readonly blobs: BlobStore
64
+ readonly bus?: Bus
65
+ readonly now?: Clock
66
+ /** The cluster pod (page keys and hub event IDs get a per-process tag after it). */
67
+ readonly podID?: string
68
+ readonly version?: string
69
+ /** The session is the user's (registry.owns). */
70
+ readonly owns: (userID: string, sessionID: string) => boolean
71
+ readonly sessionTitle: (sessionID: string) => string | undefined
72
+ /** The session is in conversation mode (an idle recording of it is not ended). */
73
+ readonly conversation: (sessionID: string) => boolean
74
+ readonly isConcierge: (userID: string, sessionID: string) => Promise<boolean>
75
+ /** STT/TTS provider facts of the user (no keys, no base URLs). */
76
+ readonly speech: (userID: string) => SpeechFacts
77
+ /** SSE to the user's devices (except one). */
78
+ readonly notify: (userID: string, message: RecordingMessage, exceptDevice?: string) => void
79
+ /** Hub audit (no content). */
80
+ readonly audit: (entry: { actor: string; action: string; target?: string; detail?: string }) => Promise<void>
81
+ readonly users: () => Promise<readonly string[]>
82
+ readonly log?: (message: string) => void
83
+ readonly sweepMs?: number
84
+ readonly hubFlushMs?: number
85
+ }
86
+
87
+ interface ActiveRef {
88
+ readonly id: string
89
+ readonly sessionID: string
90
+ readonly kind: RecordingKind
91
+ readonly deviceID: string
92
+ }
93
+
94
+ 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
+
96
+ 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>
99
+ }
100
+
101
+ export interface SpeakTap {
102
+ /** Wraps the TTS answer: tts.synth {ref, chars, firstByteMs, ms} is recorded when it ends. */
103
+ wrap<T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T
104
+ }
105
+
106
+ export class RecordingService {
107
+ readonly store: RecordingStore
108
+ readonly settings: RecordingSettingsStore
109
+ readonly retention: RecordingRetention
110
+ /** The registry's recording tap (set on Registry.recordingTap). */
111
+ readonly registryTap: RegistryTaps
112
+ private readonly writer: HubEventWriter
113
+ private readonly now: Clock
114
+ private readonly podTag: string
115
+ private readonly active = new Map<string, { at: number; refs: ActiveRef[] }>()
116
+ private readonly tails = new Map<string, Promise<void>>()
117
+ private readonly exports = new Map<string, number>()
118
+ private readonly background = new Set<Promise<unknown>>()
119
+ private readonly opened = new Set<string>()
120
+ private unsubscribe?: () => Promise<void>
121
+
122
+ constructor(private readonly options: RecordingServiceOptions) {
123
+ this.now = options.now ?? Date.now
124
+ // Unique per process: a restarted pod never reuses another's page keys.
125
+ this.podTag = `${(options.podID ?? "hub").replace(/[^A-Za-z0-9]/g, "").slice(0, 12) || "hub"}${randomBytes(3).toString("hex")}`
126
+ this.store = new RecordingStore(options.state, options.blobs, this.now)
127
+ this.settings = new RecordingSettingsStore(options.state, this.now)
128
+ this.writer = new HubEventWriter({
129
+ store: this.store,
130
+ now: this.now,
131
+ podTag: this.podTag,
132
+ ...(options.hubFlushMs !== undefined ? { flushMs: options.hubFlushMs } : {}),
133
+ failed: (cause) => this.log(`[hub] recording hub events not written: ${(cause as Error).message}`),
134
+ overLimit: (userID, id, scope) =>
135
+ void (async () => {
136
+ const record = await this.store.visible(userID, id)
137
+ if (record) await this.capReached(userID, record, "events", scope, scope === "user" ? PAGE_QUOTA_BYTES : L.eventsPerRecording)
138
+ await this.endByHub(userID, id, "limit")
139
+ })().catch(() => undefined),
140
+ })
141
+ 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) })
143
+ if (options.bus) {
144
+ options.bus
145
+ .subscribe(RECORDINGS_TOPIC, (message) => {
146
+ const userID = (message as { userID?: unknown } | undefined)?.userID
147
+ if (typeof userID === "string") this.active.delete(userID)
148
+ })
149
+ .then((off) => (this.unsubscribe = off))
150
+ .catch((cause) => this.log(`[hub] recordings bus subscription failed: ${(cause as Error).message}`))
151
+ }
152
+ }
153
+
154
+ private log(message: string): void {
155
+ ;(this.options.log ?? console.error)(message)
156
+ }
157
+
158
+ /** Tracks a background promise (settled() waits for them). */
159
+ private track<T>(promise: Promise<T>): Promise<T> {
160
+ this.background.add(promise)
161
+ void promise.finally(() => this.background.delete(promise)).catch(() => undefined)
162
+ return promise
163
+ }
164
+
165
+ // ---- the active index ------------------------------------------------------------------------------------
166
+
167
+ /** The user's active recordings (per pod, refreshed by the bus and after ACTIVE_TTL_MS). */
168
+ private async activeOf(userID: string): Promise<ActiveRef[]> {
169
+ const cached = this.active.get(userID)
170
+ 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 }))
172
+ this.active.set(userID, { at: this.now(), refs })
173
+ if (this.active.size > 10_000) this.active.delete(this.active.keys().next().value as string)
174
+ return refs
175
+ }
176
+
177
+ /** False only when the user surely has no active recording. */
178
+ mayRecord(userID: string): boolean {
179
+ const cached = this.active.get(userID)
180
+ return !(cached && this.now() - cached.at < ACTIVE_TTL_MS && cached.refs.length === 0)
181
+ }
182
+
183
+ /** A recording of the user started or ended: this pod's index and every other pod's are refreshed. */
184
+ private changed(userID: string, sessionID: string): void {
185
+ this.active.delete(userID)
186
+ void this.options.bus?.publish(RECORDINGS_TOPIC, { userID, sessionID }).catch(() => undefined)
187
+ }
188
+
189
+ // ---- hub events ------------------------------------------------------------------------------------------
190
+
191
+ /** Records a hub event into every active recording of (user, session), in call order per user. */
192
+ hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>, only?: RecordingKind): void {
193
+ void this.hubNow(userID, sessionID, kind, fields, only)
194
+ }
195
+
196
+ /** hub(), resolving once the event is buffered. */
197
+ hubNow(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>, only?: RecordingKind): Promise<void> {
198
+ const at = this.now()
199
+ const add = (refs: readonly ActiveRef[]) => {
200
+ for (const ref of refs) if (ref.sessionID === sessionID && (only === undefined || ref.kind === only)) this.writer.add(userID, ref.id, kind, fields, at)
201
+ }
202
+ const cached = this.active.get(userID)
203
+ if (!this.tails.has(userID) && cached && this.now() - cached.at < ACTIVE_TTL_MS) {
204
+ add(cached.refs)
205
+ return Promise.resolve()
206
+ }
207
+ const run = (this.tails.get(userID) ?? Promise.resolve())
208
+ .then(async () => add(await this.activeOf(userID)))
209
+ .catch((cause) => this.log(`[hub] recording tap failed: ${(cause as Error).message}`))
210
+ this.tails.set(userID, run)
211
+ void run.finally(() => {
212
+ if (this.tails.get(userID) === run) this.tails.delete(userID)
213
+ })
214
+ return run
215
+ }
216
+
217
+ /** Writes every buffered hub event now. */
218
+ async flush(): Promise<void> {
219
+ await Promise.all([...this.tails.values()])
220
+ await this.writer.flush()
221
+ }
222
+
223
+ /** Waits for background work (large audio writes, delete-all) and flushes. */
224
+ async settled(): Promise<void> {
225
+ while (this.background.size) await Promise.allSettled([...this.background])
226
+ await this.flush()
227
+ }
228
+
229
+ async stop(): Promise<void> {
230
+ this.retention.stop()
231
+ await this.settled().catch(() => undefined)
232
+ await this.unsubscribe?.().catch(() => undefined)
233
+ }
234
+
235
+ // ---- settings --------------------------------------------------------------------------------------------
236
+
237
+ async usage(userID: string): Promise<RecordingUsage> {
238
+ const [bytes, records] = await Promise.all([this.store.usage(userID), this.store.list(userID)])
239
+ return { bytes, recordings: records.filter((r) => r.state !== "deleting").length, capBytes: L.userBytes }
240
+ }
241
+
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)
244
+ if (!written.ok) return written
245
+ 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")
249
+ }
250
+ return { ok: true, settings: written.settings }
251
+ }
252
+
253
+ // ---- start and end ---------------------------------------------------------------------------------------
254
+
255
+ /** Ends a recording (unchanged when it already ended): recording.ended, the pages written, SSE, the index refreshed. */
256
+ async end(userID: string, id: string, reason: RecordingEndReason, graceUntil?: number): Promise<RecordingRecord | undefined> {
257
+ const at = this.now()
258
+ const result = await this.store.update(userID, id, (current) => ended(current, reason, at, graceUntil))
259
+ if (!result) return undefined
260
+ if (result.changed) {
261
+ this.writer.add(userID, id, "recording.ended", { reason }, at)
262
+ await this.writer.flush(userID, id)
263
+ this.changed(userID, result.record.sessionID)
264
+ this.options.notify(userID, { type: "recording", recordingID: id, state: "ended", reason })
265
+ }
266
+ return result.record
267
+ }
268
+
269
+ private endByHub(userID: string, id: string, reason: RecordingEndReason, graceUntil?: number) {
270
+ return this.end(userID, id, reason, graceUntil)
271
+ }
272
+
273
+ async start(userID: string, device: { id: string; name?: string }, input: StartInput): Promise<StartResult> {
274
+ if (!(await this.settings.get(userID)).enabled) return { kind: "off" }
275
+ if (!this.options.owns(userID, input.sessionID)) return { kind: "not_found" }
276
+ const records = await this.store.list(userID)
277
+ const same = records.find((r) => r.clientID === input.clientID && r.deviceID === device.id && r.state !== "deleting")
278
+ if (same) return { kind: "existing", record: same }
279
+ const superseded = records.filter((r) => r.state === "active" && r.deviceID === device.id && r.sessionID === input.sessionID)
280
+ if (records.filter((r) => r.state === "active").length - superseded.length >= L.activePerUser) return { kind: "rate_limited" }
281
+ if (records.filter((r) => r.state !== "deleting").length >= L.recordingsStored) return { kind: "limit" }
282
+ for (const r of superseded) await this.end(userID, r.id, "superseded")
283
+ const at = this.now()
284
+ const kind: RecordingKind = (await this.options.isConcierge(userID, input.sessionID).catch(() => false)) ? "concierge" : "voice"
285
+ const audio = (await this.store.usage(userID)) < L.userBytes
286
+ const title = this.options.sessionTitle(input.sessionID)?.trim().slice(0, 80)
287
+ const speech = this.options.speech(userID)
288
+ const record = newRecord(
289
+ {
290
+ id: newRecordingID(),
291
+ kind,
292
+ sessionID: input.sessionID,
293
+ ...(title ? { sessionTitle: title } : {}),
294
+ deviceID: device.id,
295
+ ...(device.name ? { deviceName: device.name.slice(0, 80) } : {}),
296
+ clientID: input.clientID,
297
+ client: input.client ?? {},
298
+ anchor: input.anchor ?? { perf: 0, epoch: at },
299
+ audio,
300
+ at,
301
+ },
302
+ speech,
303
+ )
304
+ const created = audio ? record : { ...record, capNoted: ["audio:user"] }
305
+ if (!(await this.store.create(userID, created))) throw new Error("recording ID collision")
306
+ // `recordingKind`: the contract's `kind` field would collide with the event envelope's `kind`.
307
+ this.writer.add(userID, record.id, "recording.started", { recordingKind: kind, sessionID: input.sessionID, deviceID: device.id, ...(speech.stt ? { stt: speech.stt } : {}), ...(speech.tts ? { tts: speech.tts } : {}) }, at)
308
+ if (!audio) this.writer.add(userID, record.id, "cap.reached", { what: "audio", scope: "user", limit: L.userBytes }, at)
309
+ this.changed(userID, input.sessionID)
310
+ return { kind: "created", record: created }
311
+ }
312
+
313
+ // ---- appends ---------------------------------------------------------------------------------------------
314
+
315
+ /** The recording when this device may append to it now: active, or ended within its grace. */
316
+ async appendable(userID: string, deviceID: string, id: string): Promise<{ kind: "ok"; record: RecordingRecord } | { kind: "not_found" } | { kind: "ended"; reason: RecordingEndReason }> {
317
+ const record = await this.store.visible(userID, id)
318
+ if (!record || record.deviceID !== deviceID) return { kind: "not_found" }
319
+ const now = this.now()
320
+ if (record.state === "active" && now - record.startedAt >= L.maxDurationMs) {
321
+ const after = await this.end(userID, id, "max_duration")
322
+ return { kind: "ended", reason: after?.endReason ?? "max_duration" }
323
+ }
324
+ if (record.state === "active") return { kind: "ok", record }
325
+ if (record.graceUntil !== undefined && now <= record.graceUntil) return { kind: "ok", record }
326
+ return { kind: "ended", reason: record.endReason ?? "stopped" }
327
+ }
328
+
329
+ async appendConsole(userID: string, record: RecordingRecord, events: Parameters<typeof ingestConsole>[3]): Promise<{ kind: "ok"; through: number; audio: boolean } | { kind: "limit" } | { kind: "not_found" }> {
330
+ const ingested = await ingestConsole(this.store, userID, record, events, this.now)
331
+ if (ingested.kind === "gone") return { kind: "not_found" }
332
+ if (ingested.kind === "limit" || ingested.kind === "quota") {
333
+ if (ingested.kind === "quota") await this.capReached(userID, record, "events", "user", PAGE_QUOTA_BYTES)
334
+ else await this.capReached(userID, record, "events", "recording", L.eventsPerRecording)
335
+ await this.end(userID, record.id, "limit")
336
+ return { kind: "limit" }
337
+ }
338
+ return { kind: "ok", through: ingested.record.consoleThrough, audio: ingested.record.audio }
339
+ }
340
+
341
+ /** cap.reached once per (what, scope); an audio cap also turns the recording's audio off (SSE audio:false). */
342
+ private async capReached(userID: string, record: RecordingRecord, what: "audio" | "events", scope: "user" | "recording", limit: number): Promise<void> {
343
+ const key = `${what}:${scope}`
344
+ let note = false
345
+ let audioOff = false
346
+ const result = await this.store.update(userID, record.id, (current) => {
347
+ note = !current.capNoted.includes(key)
348
+ audioOff = what === "audio" && current.audio
349
+ if (!note && !audioOff) return undefined
350
+ return { ...current, capNoted: note ? [...current.capNoted, key] : current.capNoted, ...(what === "audio" ? { audio: false } : {}) }
351
+ })
352
+ if (!result?.changed) return
353
+ if (note) this.writer.add(userID, record.id, "cap.reached", { what, scope, limit })
354
+ if (audioOff) this.options.notify(userID, { type: "recording", recordingID: record.id, audio: false })
355
+ }
356
+
357
+ /** The user cap for a new segment of `bytes` (cap.reached / audio off when hit); the blob quota enforces it atomically. */
358
+ private async audioAllowed(userID: string, record: RecordingRecord, bytes: number): Promise<boolean> {
359
+ if (bytes > L.segmentBytes) return false
360
+ if (!record.audio) return false
361
+ if ((await this.store.usage(userID)) + bytes > L.userBytes) {
362
+ await this.capReached(userID, record, "audio", "user", L.userBytes)
363
+ return false
364
+ }
365
+ return true
366
+ }
367
+
368
+ /**
369
+ * Reserves a segment's bytes and count in the record (CAS), so parallel writes never pass recordingBytes or
370
+ * segmentsPerRecording: "ok", "cap" (cap.reached, audio off), or "gone" (deleted meanwhile).
371
+ */
372
+ private async reserveAudio(userID: string, record: RecordingRecord, bytes: number): Promise<"ok" | "cap" | "gone"> {
373
+ let verdict = "gone" as "ok" | "cap" | "over" | "gone"
374
+ const at = this.now()
375
+ await this.store.update(userID, record.id, (current) => {
376
+ if (current.state === "deleting") return void (verdict = "gone")
377
+ if (!current.audio) return void (verdict = "cap")
378
+ if (current.segments >= L.segmentsPerRecording || current.bytes + bytes > L.recordingBytes) return void (verdict = "over")
379
+ verdict = "ok"
380
+ return { ...current, segments: current.segments + 1, bytes: current.bytes + bytes, lastEventAt: at }
381
+ })
382
+ if (verdict === "over") {
383
+ await this.capReached(userID, record, "audio", "recording", L.recordingBytes)
384
+ return "cap"
385
+ }
386
+ return verdict
387
+ }
388
+
389
+ /** Gives a reservation back (the write failed or lost the first-write race). */
390
+ private async releaseAudio(userID: string, id: string, bytes: number): Promise<void> {
391
+ await this.store.update(userID, id, (current) => (current.state === "deleting" ? undefined : { ...current, segments: Math.max(0, current.segments - 1), bytes: Math.max(0, current.bytes - bytes) }))
392
+ }
393
+
394
+ /** Stores a segment's WAV (first write wins); the caps are checked (and reserved) first. */
395
+ async storeAudio(userID: string, record: RecordingRecord, n: number, audio: Uint8Array, via: AudioEntry["via"], redacted = false): Promise<AudioOutcome> {
396
+ const existing = await this.store.indexEntry<AudioEntry>(userID, record.id, audioKey(n))
397
+ if (existing) return { kind: "stored", entry: existing, fresh: false }
398
+ if (!(await this.audioAllowed(userID, record, audio.length))) return { kind: "cap" }
399
+ const reserved = await this.reserveAudio(userID, record, audio.length)
400
+ if (reserved === "cap") return { kind: "cap" }
401
+ if (reserved === "gone") return { kind: "gone" }
402
+ let ref
403
+ try {
404
+ ref = await this.store.putBlob(userID, audio, RECORDING_AUDIO_TYPE, L.segmentBytes, L.userBytes)
405
+ } catch (cause) {
406
+ await this.releaseAudio(userID, record.id, audio.length)
407
+ if (cause instanceof BlobQuotaError) {
408
+ await this.capReached(userID, record, "audio", "user", L.userBytes)
409
+ return { kind: "cap" }
410
+ }
411
+ return { kind: "failed", cause }
412
+ }
413
+ const at = this.now()
414
+ const entry: AudioEntry = { hash: ref.hash, bytes: ref.bytes, at, via, ...(redacted ? { redacted: true as const } : {}) }
415
+ if (!(await this.store.putIndex(userID, record.id, audioKey(n), entry))) {
416
+ await this.releaseAudio(userID, record.id, audio.length)
417
+ const first = await this.store.indexEntry<AudioEntry>(userID, record.id, audioKey(n))
418
+ if (first?.hash !== ref.hash) await this.store.dropIfUnindexed(userID, ref.hash, at)
419
+ return first ? { kind: "stored", entry: first, fresh: false } : { kind: "failed", cause: new Error("segment index lost") }
420
+ }
421
+ // A deletion that ran meanwhile did not see this segment: undo it (delete is real).
422
+ if (await this.store.undoIfDeleted(userID, record.id, audioKey(n), ref.hash)) return { kind: "gone" }
423
+ return { kind: "stored", entry, fresh: true }
424
+ }
425
+
426
+ // ---- voice route taps ------------------------------------------------------------------------------------
427
+
428
+ /** /api/transcribe with the console's headers: undefined without them (dictation is unchanged). */
429
+ async transcribeTap(userID: string, deviceID: string, headers: Headers, audio: Uint8Array, format: string): Promise<TranscribeTap | undefined> {
430
+ const id = headers.get(RECORDING_HEADER)
431
+ if (id === null) return undefined
432
+ const off: TranscribeTap = { finish: async () => "off" }
433
+ const segmentText = headers.get(RECORDING_SEGMENT_HEADER) ?? ""
434
+ const n = /^\d{1,6}$/.test(segmentText) ? Number(segmentText) : -1
435
+ let record: RecordingRecord | undefined
436
+ try {
437
+ record = await this.store.visible(userID, id)
438
+ } catch {
439
+ return off
440
+ }
441
+ // Unknown, foreign, ended, another device's, or not a WAV/segment: the headers are ignored (no oracle).
442
+ if (!record || record.state !== "active" || record.deviceID !== deviceID || !isSegmentNumber(n) || format !== "wav") return off
443
+ const live = record
444
+ const at = this.now()
445
+ this.writer.add(userID, id, "stt.request", { segment: n, bytes: audio.length, format }, at)
446
+ const model = live.stt?.model
447
+ return {
448
+ finish: async (text, providerMs) => {
449
+ // (USER CHANGE vs the contract text) A transcript matching a secret pattern: the text is scrubbed (the
450
+ // hub record of stt.result is), the audio kept, and the segment marked redacted.
451
+ const redacted = redactCredentials(text).kinds.length > 0
452
+ let result: RecordingAudioResult
453
+ try {
454
+ if (!(await this.audioAllowedQuick(userID, live, audio.length))) result = "skipped-cap"
455
+ else if (audio.length <= SYNC_AUDIO_BYTES) result = this.audioResult(userID, id, n, await this.storeAudio(userID, live, n, audio, "transcribe", redacted))
456
+ else {
457
+ result = "stored"
458
+ void this.track(this.storeAudio(userID, live, n, audio, "transcribe", redacted).then((outcome) => this.audioResult(userID, id, n, outcome)))
459
+ }
460
+ } catch (cause) {
461
+ result = this.audioResult(userID, id, n, { kind: "failed", cause })
462
+ }
463
+ this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result })
464
+ return result
465
+ },
466
+ }
467
+ }
468
+
469
+ /** The cap checks of a transcribe body (the store re-checks them). */
470
+ private async audioAllowedQuick(userID: string, record: RecordingRecord, bytes: number): Promise<boolean> {
471
+ const current = (await this.store.get(userID, record.id))?.value ?? record
472
+ return this.audioAllowed(userID, current, bytes)
473
+ }
474
+
475
+ private audioResult(userID: string, id: string, n: number, outcome: AudioOutcome): RecordingAudioResult {
476
+ if (outcome.kind === "stored") return "stored"
477
+ if (outcome.kind === "cap") return "skipped-cap"
478
+ // Deleted meanwhile: like an unknown recording.
479
+ if (outcome.kind === "gone") return "off"
480
+ this.log(`[hub] recording audio not stored: ${(outcome.cause as Error)?.message ?? "error"}`)
481
+ this.writer.add(userID, id, "error", { where: "audio", segment: n, message: (outcome.cause as Error)?.message ?? "write failed" })
482
+ return "failed"
483
+ }
484
+
485
+ /** /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing; the reply audio is never stored. */
486
+ async speakTap(userID: string, deviceID: string, headers: Headers, chars: number): Promise<SpeakTap | undefined> {
487
+ const id = headers.get(RECORDING_HEADER)
488
+ if (id === null) return undefined
489
+ const record = await this.store.visible(userID, id).catch(() => undefined)
490
+ if (!record || record.state !== "active" || record.deviceID !== deviceID) return undefined
491
+ const ref = parseTtsRef(headers.get(RECORDING_REF_HEADER))
492
+ 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 })
494
+ return {
495
+ wrap: <T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T => {
496
+ if (audio instanceof Uint8Array) {
497
+ const ms = this.now() - started
498
+ done(ms)
499
+ return audio
500
+ }
501
+ let first: number | undefined
502
+ const timed = (audio as ReadableStream<Uint8Array>).pipeThrough(
503
+ new TransformStream<Uint8Array, Uint8Array>({
504
+ transform: (chunk, controller) => {
505
+ first ??= this.now() - started
506
+ controller.enqueue(chunk)
507
+ },
508
+ flush: () => done(first),
509
+ }),
510
+ )
511
+ return timed as T
512
+ },
513
+ }
514
+ }
515
+
516
+ /** POST …/conversation: the event, and (off) the session's recordings end with a grace for the last batches. */
517
+ async conversationChanged(userID: string, sessionID: string, on: boolean): Promise<void> {
518
+ await this.hubNow(userID, sessionID, "conversation", { on })
519
+ if (on) return
520
+ for (const ref of await this.activeOf(userID)) {
521
+ if (ref.sessionID === sessionID) await this.end(userID, ref.id, "conversation_off", this.now() + END_GRACE_MS)
522
+ }
523
+ }
524
+
525
+ /** A session was deleted: its recordings end. */
526
+ async sessionDeleted(userID: string, sessionID: string): Promise<void> {
527
+ for (const ref of await this.activeOf(userID)) if (ref.sessionID === sessionID) await this.end(userID, ref.id, "session_deleted")
528
+ }
529
+
530
+ /** Decisions: opened (asked by the recorded session) and answered (with via). */
531
+ decisionChanged(userID: string, decision: Decision): void {
532
+ const from = decision.from
533
+ if (from.kind !== "session") return
534
+ const sessionID = from.sessionID
535
+ if (decision.state === "open" && !this.opened.has(decision.id)) {
536
+ this.opened.add(decision.id)
537
+ if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
538
+ this.hub(userID, sessionID, "decision.opened", { decisionID: decision.id, from: partyKey(from), tier: decision.tier, urgency: decision.urgency, options: decision.options.map((o) => o.id), text: decision.question })
539
+ } else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
540
+ this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), via: decision.answer.via ?? "tap" })
541
+ }
542
+ }
543
+
544
+ /** concierge.tool: an agent call of a recorded Concierge session (no body, no response). */
545
+ conciergeCall(userID: string, sessionID: string, fields: ConciergeCallFields): void {
546
+ if (!this.mayRecord(userID)) return
547
+ this.hub(userID, sessionID, "concierge.tool", { ...fields }, "concierge")
548
+ }
549
+
550
+ /** wake.arrived: the notifier's send to the recorded Concierge session succeeded. */
551
+ wake(userID: string, sessionID: string, fields: Record<string, unknown>): void {
552
+ this.hub(userID, sessionID, "wake.arrived", fields, "concierge")
553
+ }
554
+
555
+ // ---- reads -----------------------------------------------------------------------------------------------
556
+
557
+ async visible(userID: string, id: string): Promise<RecordingRecord | undefined> {
558
+ return this.store.visible(userID, id)
559
+ }
560
+
561
+ async list(userID: string): Promise<RecordingRecord[]> {
562
+ return (await this.store.list(userID)).filter((r) => r.state !== "deleting").sort((a, b) => b.startedAt - a.startedAt || (a.id < b.id ? 1 : -1))
563
+ }
564
+
565
+ /** One export slot of the user, or undefined at EXPORTS_PER_USER; release it when the stream ends. */
566
+ exportSlot(userID: string): (() => void) | undefined {
567
+ const running = this.exports.get(userID) ?? 0
568
+ if (running >= EXPORTS_PER_USER) return undefined
569
+ this.exports.set(userID, running + 1)
570
+ let released = false
571
+ return () => {
572
+ if (released) return
573
+ released = true
574
+ const left = (this.exports.get(userID) ?? 1) - 1
575
+ if (left <= 0) this.exports.delete(userID)
576
+ else this.exports.set(userID, left)
577
+ }
578
+ }
579
+
580
+ // ---- delete ----------------------------------------------------------------------------------------------
581
+
582
+ /** Step 1: ends an active recording (stopped), then marks it deleting (invisible to every route from then on). */
583
+ private async markDeleting(userID: string, id: string): Promise<boolean> {
584
+ const record = await this.store.visible(userID, id)
585
+ if (!record) return false
586
+ if (record.state === "active") await this.end(userID, id, "stopped")
587
+ await this.writer.flush(userID, id)
588
+ const at = this.now()
589
+ await this.store.tombstone(userID, id, at)
590
+ const result = await this.store.update(userID, id, (current) => (current.state === "deleting" ? undefined : { ...current, state: "deleting", deletingAt: at }))
591
+ if (!result?.changed) return false
592
+ this.writer.discard(userID, id)
593
+ this.options.notify(userID, { type: "recording", recordingID: id, state: "deleted" })
594
+ return true
595
+ }
596
+
597
+ async deleteOne(userID: string, id: string): Promise<boolean> {
598
+ if (!(await this.markDeleting(userID, id))) return false
599
+ await this.store.finishDelete(userID, id)
600
+ await this.options.audit({ actor: userID, action: "recording.delete", target: id, detail: "1" })
601
+ return true
602
+ }
603
+
604
+ /** Every recording of the user: marked now, finished in the background (the sweep finishes what a crash left). */
605
+ async deleteAll(userID: string): Promise<number> {
606
+ const ids: string[] = []
607
+ for (const record of await this.store.list(userID)) if (record.state !== "deleting" && (await this.markDeleting(userID, record.id))) ids.push(record.id)
608
+ await this.options.audit({ actor: userID, action: "recording.delete", target: "all", detail: String(ids.length) })
609
+ void this.track(
610
+ (async () => {
611
+ for (const id of ids) await this.store.finishDelete(userID, id).catch((cause) => this.log(`[hub] recording delete not finished: ${(cause as Error).message}`))
612
+ })(),
613
+ )
614
+ return ids.length
615
+ }
616
+
617
+ // ---- retention -------------------------------------------------------------------------------------------
618
+
619
+ /** One user's sweep: finish deletions, delete expired, end idle and over-long active recordings. */
620
+ async sweepUser(userID: string): Promise<number> {
621
+ const now = this.now()
622
+ let expired = 0
623
+ for (const record of await this.store.list(userID)) {
624
+ if (record.state === "deleting") {
625
+ await this.store.finishDelete(userID, record.id)
626
+ continue
627
+ }
628
+ if (record.expiresAt <= now) {
629
+ if (record.state === "active") await this.end(userID, record.id, "stale")
630
+ if (await this.markDeleting(userID, record.id)) {
631
+ await this.store.finishDelete(userID, record.id)
632
+ expired++
633
+ }
634
+ continue
635
+ }
636
+ if (record.state !== "active") continue
637
+ if (now - record.startedAt >= L.maxDurationMs) await this.end(userID, record.id, "max_duration")
638
+ else if (now - record.lastEventAt >= L.idleEndMs && !this.options.conversation(record.sessionID)) await this.end(userID, record.id, "stale")
639
+ }
640
+ // Leftovers of writes that raced a deletion (index keys of gone recordings, unindexed blobs past the grace).
641
+ await this.store.sweepOrphans(userID)
642
+ if (expired) await this.options.audit({ actor: "system", action: "recording.purge", detail: `${expired} expired` })
643
+ return expired
644
+ }
645
+ }
646
+
647
+ export interface StartInput {
648
+ readonly sessionID: string
649
+ readonly clientID: string
650
+ readonly anchor?: RecordingRecord["anchor"]
651
+ readonly client?: RecordingRecord["client"]
652
+ }
653
+
654
+ export type StartResult =
655
+ | { readonly kind: "off" | "not_found" | "rate_limited" | "limit" }
656
+ | { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
657
+
658
+ export { summaryOf }