@huynle/hub 0.8.1 → 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 (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -0,0 +1,232 @@
1
+ // REC-01 recording event schema (docs/plans/2026-11-05-voice-recording-contract.md, Event schema).
2
+ // The console kinds and exactly their fields: a batch with an unknown kind, an unknown field (also inside nested
3
+ // objects), a wrong type or a missing required field is refused whole, naming the event's seq. Every ✂ text field,
4
+ // from either source, is scrubbed (redactCredentials) and then cut at its limit before it is stored.
5
+ import { type ConsoleEvent, type ConsoleEventKind, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
6
+ import { redactCredentials } from "../profiles/content"
7
+
8
+ /** A field's shape. `text` fields are the ✂ ones: scrubbed and cut at `max`, never refused for length. */
9
+ export type FieldSpec =
10
+ | { readonly t: "int" | "num" | "bool" | "true" }
11
+ | { readonly t: "str"; readonly max: number }
12
+ | { readonly t: "text"; readonly max: number }
13
+ | { readonly t: "enum"; readonly values: readonly string[] }
14
+ | { readonly t: "array"; readonly of: FieldSpec; readonly max: number }
15
+ | { readonly t: "object"; readonly fields: Fields }
16
+
17
+ /** Field name → spec; a name ending in `?` is optional. */
18
+ export type Fields = Readonly<Record<string, FieldSpec>>
19
+
20
+ const int: FieldSpec = { t: "int" }
21
+ const num: FieldSpec = { t: "num" }
22
+ const bool: FieldSpec = { t: "bool" }
23
+ const yes: FieldSpec = { t: "true" }
24
+ /** IDs and labels the console echoes (message IDs, inbox IDs, Decision IDs, option IDs). */
25
+ const id: FieldSpec = { t: "str", max: 300 }
26
+ const label: FieldSpec = { t: "str", max: 60 }
27
+ const text = (max: number): FieldSpec => ({ t: "text", max })
28
+ const oneOf = (...values: string[]): FieldSpec => ({ t: "enum", values })
29
+ const list = (of: FieldSpec, max: number): FieldSpec => ({ t: "array", of, max })
30
+ const object = (fields: Fields): FieldSpec => ({ t: "object", fields })
31
+
32
+ const L = RECORDING_LIMITS
33
+ const ref = object({ messageID: id, ordinal: int, "seq?": int, i: int })
34
+ const AUDIO_RESULTS = ["stored", "skipped-cap", "failed", "off"]
35
+
36
+ /** The console kinds (REC-02 emits exactly these fields). */
37
+ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
38
+ state: { state: label },
39
+ detector: { mode: oneOf("silero", "energy"), "avgMs?": num },
40
+ "speech.start": { bargeIn: bool },
41
+ "speech.end": { segment: int, durationMs: num, speechMs: num, forced: bool },
42
+ "segment.closed": { segment: int, bytes: int, durationMs: num, speechMs: num, forced: bool },
43
+ "stt.sent": { segment: int, bytes: int },
44
+ // `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) },
46
+ "utterance.held": { text: text(L.utteranceChars) },
47
+ "utterance.merged": { segments: list(int, 1000), pauseMs: num },
48
+ "utterance.sent": { text: text(L.utteranceChars), segments: list(int, 1000), reason: oneOf("finished", "hold", "max", "command", "typed"), holdMs: num },
49
+ "utterance.accepted": { inboxID: id },
50
+ "utterance.failed": { "status?": int, message: text(L.messageChars) },
51
+ "utterance.command": { command: oneOf("pause", "interrupt"), text: text(L.textChars) },
52
+ heard: { inboxID: id },
53
+ // The contract's `seq?` (the say's seq) collides with the envelope's `seq`: a flat event carries only the latter.
54
+ "say.received": { messageID: id, ordinal: int, chars: int, sentences: int, muted: bool },
55
+ "tts.sentence.queued": { ref, text: text(L.textChars) },
56
+ "tts.request": { ref },
57
+ "tts.first.byte": { ref },
58
+ "tts.sentence.played": { ref },
59
+ "tts.sentence.ended": { ref, playedMs: num, "durationMs?": num, completed: bool },
60
+ "barge.in": {
61
+ cause: oneOf("speech", "typed", "command", "hush", "pause", "stop"),
62
+ "playing?": object({ ref, text: text(L.textChars), playedMs: num, "durationMs?": num }),
63
+ spoken: object({ text: text(L.replyChars), estimated: bool }),
64
+ unplayed: list(object({ ref, text: text(L.textChars) }), 20),
65
+ mutedMessages: list(id, 20),
66
+ },
67
+ "cue.played": { cue: oneOf("earcon", "phrase"), "name?": label, "text?": text(L.textChars) },
68
+ mic: { live: bool, "reason?": label },
69
+ control: { action: oneOf("pause", "resume", "hush", "background", "cues"), "on?": bool },
70
+ "turn.timing": { "speechEnd?": num, "transcript?": num, "accepted?": num, "firstSay?": num, "firstByte?": num, firstAudio: num },
71
+ "decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
72
+ "clock.sync": { t0: num, t1: num, hubAt: num },
73
+ error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
74
+ }
75
+
76
+ /** The ✂ fields of the hub kinds (the hub writes the rest itself: only text needs scrubbing and cutting). */
77
+ const HUB_TEXT: Partial<Record<HubEventKind, Readonly<Record<string, number>>>> = {
78
+ "stt.result": { text: L.textChars },
79
+ "utterance.received": { text: L.utteranceChars },
80
+ "say.sent": { text: L.textChars },
81
+ "reply.full": { text: L.replyChars },
82
+ tool: { summary: L.textChars },
83
+ "wake.arrived": { text: L.textChars },
84
+ "decision.opened": { text: L.textChars },
85
+ error: { message: L.messageChars },
86
+ }
87
+
88
+ class Invalid extends Error {}
89
+
90
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
91
+
92
+ function checkValue(spec: FieldSpec, value: unknown, where: string): void {
93
+ const fail = (what: string): never => {
94
+ throw new Invalid(`${where} must be ${what}`)
95
+ }
96
+ switch (spec.t) {
97
+ case "int":
98
+ if (!Number.isSafeInteger(value)) fail("an integer")
99
+ return
100
+ case "num":
101
+ if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number")
102
+ return
103
+ case "bool":
104
+ if (typeof value !== "boolean") fail("true or false")
105
+ return
106
+ case "true":
107
+ if (value !== true) fail("true")
108
+ return
109
+ case "str":
110
+ if (typeof value !== "string" || value.length > spec.max) fail(`text of at most ${spec.max} characters`)
111
+ return
112
+ case "text":
113
+ if (typeof value !== "string") fail("text")
114
+ return
115
+ case "enum":
116
+ if (typeof value !== "string" || !spec.values.includes(value)) fail(`one of ${spec.values.join(", ")}`)
117
+ return
118
+ case "array":
119
+ if (!Array.isArray(value) || value.length > spec.max) fail(`a list of at most ${spec.max}`)
120
+ ;(value as unknown[]).forEach((item, i) => checkValue(spec.of, item, `${where}[${i}]`))
121
+ return
122
+ case "object":
123
+ if (!isRecord(value)) fail("an object")
124
+ checkFields(spec.fields, value as Record<string, unknown>, `${where}.`)
125
+ }
126
+ }
127
+
128
+ function checkFields(fields: Fields, value: Record<string, unknown>, prefix: string, skip: ReadonlySet<string> = new Set()): void {
129
+ const known = new Map(Object.entries(fields).map(([name, spec]) => [name.replace(/\?$/, ""), { spec, optional: name.endsWith("?") }]))
130
+ for (const key of Object.keys(value)) {
131
+ if (skip.has(key)) continue
132
+ if (!known.has(key)) throw new Invalid(`unknown field ${prefix}${/^[A-Za-z0-9_.-]{1,40}$/.test(key) ? key : "?"}`)
133
+ }
134
+ for (const [name, { spec, optional }] of known) {
135
+ if (value[name] === undefined) {
136
+ if (optional) continue
137
+ throw new Invalid(`${prefix}${name} is required`)
138
+ }
139
+ checkValue(spec, value[name], `${prefix}${name}`)
140
+ }
141
+ }
142
+
143
+ const ENVELOPE = new Set(["seq", "t", "kind"])
144
+
145
+ export type BatchCheck =
146
+ | { readonly ok: true; readonly events: readonly ConsoleEvent[] }
147
+ | { readonly ok: false; readonly problem: string; readonly seq?: number; readonly limit?: true }
148
+
149
+ /** Checks a console batch (`events` of POST …/events): count, envelopes, kinds and their fields. */
150
+ export function checkConsoleBatch(events: unknown): BatchCheck {
151
+ if (!Array.isArray(events) || events.length === 0) return { ok: false, problem: "Expected {events: [ConsoleEvent, …]} with at least one event" }
152
+ if (events.length > L.eventsPerBatch) return { ok: false, limit: true, problem: `A batch holds at most ${L.eventsPerBatch} events` }
153
+ let previous: number | undefined
154
+ for (const event of events) {
155
+ const seq = isRecord(event) && Number.isSafeInteger(event.seq) ? (event.seq as number) : undefined
156
+ try {
157
+ if (!isRecord(event)) throw new Invalid("an event must be an object")
158
+ if (seq === undefined || seq < 1) throw new Invalid("seq must be an integer from 1")
159
+ if (previous !== undefined && seq !== previous + 1) throw new Invalid("seq must increase by 1 within a batch")
160
+ if (typeof event.t !== "number" || !Number.isFinite(event.t) || event.t < 0) throw new Invalid("t must be a finite number ≥ 0")
161
+ const fields = typeof event.kind === "string" && Object.hasOwn(CONSOLE_FIELDS, event.kind) ? CONSOLE_FIELDS[event.kind as ConsoleEventKind] : undefined
162
+ if (!fields) throw new Invalid("unknown kind")
163
+ checkFields(fields, event, "", ENVELOPE)
164
+ } catch (cause) {
165
+ if (!(cause instanceof Invalid)) throw cause
166
+ return { ok: false, problem: cause.message, ...(seq !== undefined ? { seq } : {}) }
167
+ }
168
+ previous = seq
169
+ }
170
+ return { ok: true, events: events as ConsoleEvent[] }
171
+ }
172
+
173
+ /** Scrubs and cuts the text fields of a value by its spec; records the kinds removed and whether anything was cut. */
174
+ function scrubValue(spec: FieldSpec, value: unknown, marks: Marks): unknown {
175
+ if (spec.t === "text" && typeof value === "string") return scrubText(value, spec.max, marks)
176
+ if (spec.t === "array" && Array.isArray(value)) return value.map((item) => scrubValue(spec.of, item, marks))
177
+ if (spec.t === "object" && isRecord(value)) return scrubFields(spec.fields, value, marks)
178
+ return value
179
+ }
180
+
181
+ function scrubFields(fields: Fields, value: Record<string, unknown>, marks: Marks): Record<string, unknown> {
182
+ const out: Record<string, unknown> = {}
183
+ const specs = new Map(Object.entries(fields).map(([name, spec]) => [name.replace(/\?$/, ""), spec]))
184
+ for (const [key, item] of Object.entries(value)) {
185
+ const spec = specs.get(key)
186
+ out[key] = spec ? scrubValue(spec, item, marks) : item
187
+ }
188
+ return out
189
+ }
190
+
191
+ interface Marks {
192
+ kinds: string[]
193
+ truncated: boolean
194
+ }
195
+
196
+ /** Redacts a text, then cuts it at `max` characters (redacting first: a cut must never leave half a secret). */
197
+ export function scrubText(value: string, max: number, marks: Marks): string {
198
+ const { text: clean, kinds } = redactCredentials(value)
199
+ for (const kind of kinds) if (!marks.kinds.includes(kind)) marks.kinds.push(kind)
200
+ if (clean.length <= max) return clean
201
+ marks.truncated = true
202
+ return clean.slice(0, max)
203
+ }
204
+
205
+ const withMarks = (record: Record<string, unknown>, marks: Marks): RecordingEvent =>
206
+ ({ ...record, ...(marks.kinds.length ? { redacted: marks.kinds } : {}), ...(marks.truncated ? { truncated: true as const } : {}) }) as RecordingEvent
207
+
208
+ /** A checked console event as stored: the envelope, the receive time, scrubbed and cut fields. */
209
+ export function consoleRecord(event: ConsoleEvent, at: number): RecordingEvent {
210
+ const marks: Marks = { kinds: [], truncated: false }
211
+ const { seq, t, kind, ...rest } = event
212
+ const fields = scrubFields(CONSOLE_FIELDS[kind], rest, marks)
213
+ return withMarks({ v: 1, src: "console", seq, t, at, kind, ...fields }, marks)
214
+ }
215
+
216
+ // Not `seq`: hub events have no envelope seq, and say.sent carries the say's own.
217
+ const HUB_ENVELOPE = ["v", "src", "id", "at", "kind", "ts", "redacted", "truncated"]
218
+
219
+ /** A hub event as stored: `id` and `at`, its text fields scrubbed and cut. */
220
+ export function hubRecord(kind: HubEventKind, fields: Record<string, unknown>, meta: { readonly id: string; readonly at: number }): RecordingEvent {
221
+ const marks: Marks = { kinds: [], truncated: false }
222
+ const limits = HUB_TEXT[kind] ?? {}
223
+ const out: Record<string, unknown> = {}
224
+ for (const [key, value] of Object.entries(fields)) {
225
+ if (value === undefined) continue
226
+ const max = limits[key]
227
+ out[key] = max !== undefined && typeof value === "string" ? scrubText(value, max, marks) : value
228
+ }
229
+ // The envelope always wins over a field of the same name.
230
+ for (const key of HUB_ENVELOPE) delete out[key]
231
+ return withMarks({ v: 1, src: "hub", id: meta.id, at: meta.at, kind, ...out }, marks)
232
+ }
@@ -0,0 +1,35 @@
1
+ // REC-01 recording setting (contract: Setting and indicator): per user, off by default, version-checked by the
2
+ // caller's expectedRevision. StateStore collection `voice-recording-settings`, key `settings`.
3
+ import type { RecordingSettings } from "../../protocol"
4
+ import type { Clock, StateStore } from "../backend/types"
5
+
6
+ export const SETTINGS_COLLECTION = "voice-recording-settings"
7
+ const KEY = "settings"
8
+ export const DEFAULT_SETTINGS: RecordingSettings = { enabled: false, revision: 0 }
9
+
10
+ export type SettingsWrite = { readonly ok: true; readonly settings: RecordingSettings; readonly changed: boolean } | { readonly ok: false; readonly currentRevision: number }
11
+
12
+ export class RecordingSettingsStore {
13
+ constructor(
14
+ private readonly state: StateStore,
15
+ private readonly now: Clock,
16
+ ) {}
17
+
18
+ async get(userID: string): Promise<RecordingSettings> {
19
+ return (await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY))?.value ?? DEFAULT_SETTINGS
20
+ }
21
+
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> {
24
+ for (let attempt = 0; attempt < 8; attempt++) {
25
+ const stored = await this.state.get<RecordingSettings>(userID, SETTINGS_COLLECTION, KEY)
26
+ const current = stored?.value ?? DEFAULT_SETTINGS
27
+ if (current.revision !== expectedRevision) return { ok: false, currentRevision: current.revision }
28
+ const next: RecordingSettings = { enabled, revision: current.revision + 1, updatedAt: this.now() }
29
+ 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 }
31
+ }
32
+ const current = await this.get(userID)
33
+ return { ok: false, currentRevision: current.revision }
34
+ }
35
+ }
@@ -0,0 +1,316 @@
1
+ // REC-01 recording storage (contract: Recordings → Storage). Small records in the StateStore under the real user
2
+ // ID (`voice-recordings`, every update CAS), one index collection per recording (`voice-recording-index:<id>`:
3
+ // console pages c…, hub pages h…, audio segments a…), and the bytes in the BlobStore under the separate tenant
4
+ // `rec~<userID>` (artifact recovery walks only real user IDs, so it never sweeps them; usage() of the tenant is
5
+ // exactly the recording usage). Deletion is real: blobs (unless another recording of the user indexes the hash,
6
+ // with touchedBefore = the deletion start), then the index, then the record.
7
+ import { randomBytes } from "node:crypto"
8
+ import {
9
+ RECORDING_LIMITS,
10
+ RECORDING_MEDIA_TYPES,
11
+ type BlobRef,
12
+ type RecordingAnchor,
13
+ type RecordingAudioResult,
14
+ type RecordingClient,
15
+ type RecordingEndReason,
16
+ type RecordingSummary,
17
+ } from "../../protocol"
18
+ import type { BlobBody, BlobStore } from "../artifacts/blob-store"
19
+ import type { Clock, Entry, StateStore, Versioned } from "../backend/types"
20
+
21
+ export const RECORDINGS = "voice-recordings"
22
+ /** Unindexed rec~ blobs younger than this are left to their writer (a write in flight); older ones are swept. */
23
+ export const ORPHAN_GRACE_MS = 60 * 60_000
24
+ export const indexCollection = (recordingID: string) => `voice-recording-index:${recordingID}`
25
+ /**
26
+ * Recordings being or recently deleted (key: the ID, value {at}): the sweep empties their index collections again
27
+ * (a write racing the deletion, another pod's late page), since a StateStore cannot list collections.
28
+ */
29
+ export const TOMBSTONES = "voice-recording-tombstones"
30
+ /** How long a tombstone is kept after its index was found empty. */
31
+ export const TOMBSTONE_MS = 24 * 60 * 60_000
32
+ /** The BlobStore tenant of a user's recordings (user IDs never contain `~`). */
33
+ export const blobTenant = (userID: string) => `rec~${userID}`
34
+
35
+ /** Speech provider facts at start (no keys, no base URLs). */
36
+ export interface SpeechFacts {
37
+ readonly stt?: { readonly provider?: string; readonly model?: string }
38
+ readonly tts?: { readonly provider?: string; readonly model?: string; readonly voice?: string }
39
+ }
40
+
41
+ /** A recording as stored: the summary plus internal fields (kept small: ≤ 4 KiB). */
42
+ export interface RecordingRecord extends RecordingSummary, SpeechFacts {
43
+ readonly clientID: string
44
+ readonly client: RecordingClient
45
+ readonly anchor: RecordingAnchor
46
+ /** Highest contiguous console seq stored. */
47
+ readonly consoleThrough: number
48
+ readonly consoleGaps: number
49
+ readonly hubPages: number
50
+ readonly pages: number
51
+ readonly eventBytes: number
52
+ readonly lastEventAt: number
53
+ /** `<what>:<scope>` caps already reported (cap.reached once each). */
54
+ readonly capNoted: readonly string[]
55
+ readonly skipped: { readonly cap: number; readonly failed: number }
56
+ /** Appends are still accepted until then (the 10 s grace after a hub-initiated conversation_off end). */
57
+ readonly graceUntil?: number
58
+ readonly deletingAt?: number
59
+ }
60
+
61
+ export interface ConsolePageEntry {
62
+ readonly hash: string
63
+ readonly bytes: number
64
+ readonly first: number
65
+ readonly last: number
66
+ readonly at: number
67
+ }
68
+ export interface HubPageEntry {
69
+ readonly hash: string
70
+ readonly bytes: number
71
+ readonly count: number
72
+ readonly at: number
73
+ }
74
+ export interface AudioEntry {
75
+ readonly hash: string
76
+ readonly bytes: number
77
+ readonly durationMs?: number
78
+ readonly at: number
79
+ readonly via: "transcribe" | "put"
80
+ /** (USER CHANGE) The segment's transcript matched a secret pattern: its text was scrubbed, the audio kept. */
81
+ readonly redacted?: true
82
+ }
83
+
84
+ export const consoleKey = (firstSeq: number) => `c${String(firstSeq).padStart(10, "0")}`
85
+ export const hubKey = (podTag: string, n: number) => `h${podTag}-${String(n).padStart(8, "0")}`
86
+ export const audioKey = (n: number) => `a${String(n).padStart(6, "0")}`
87
+ /** Segment numbers that fit the 6-digit index key and file name. */
88
+ export const isSegmentNumber = (n: number) => Number.isSafeInteger(n) && n >= 0 && n <= 999_999
89
+
90
+ const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"
91
+ /** "rec_" + 20 base32 characters (100 random bits). */
92
+ export function newRecordingID(): string {
93
+ const bytes = randomBytes(20)
94
+ let out = ""
95
+ for (const b of bytes) out += BASE32[b & 31]
96
+ return `rec_${out}`
97
+ }
98
+ export const isRecordingID = (id: string) => /^rec_[a-z2-7]{20}$/.test(id)
99
+
100
+ /** The public part of a record. */
101
+ export function summaryOf(r: RecordingRecord): RecordingSummary {
102
+ return {
103
+ id: r.id,
104
+ kind: r.kind,
105
+ sessionID: r.sessionID,
106
+ ...(r.sessionTitle !== undefined ? { sessionTitle: r.sessionTitle } : {}),
107
+ deviceID: r.deviceID,
108
+ ...(r.deviceName !== undefined ? { deviceName: r.deviceName } : {}),
109
+ state: r.state,
110
+ startedAt: r.startedAt,
111
+ ...(r.endedAt !== undefined ? { endedAt: r.endedAt } : {}),
112
+ ...(r.endReason !== undefined ? { endReason: r.endReason } : {}),
113
+ expiresAt: r.expiresAt,
114
+ bytes: r.bytes,
115
+ segments: r.segments,
116
+ events: r.events,
117
+ audio: r.audio,
118
+ redactions: r.redactions,
119
+ }
120
+ }
121
+
122
+ export class RecordingStore {
123
+ constructor(
124
+ readonly state: StateStore,
125
+ readonly blobs: BlobStore,
126
+ private readonly now: Clock,
127
+ ) {}
128
+
129
+ async get(userID: string, id: string): Promise<Versioned<RecordingRecord> | undefined> {
130
+ if (!isRecordingID(id)) return undefined
131
+ return this.state.get<RecordingRecord>(userID, RECORDINGS, id)
132
+ }
133
+
134
+ /** The record when it is the user's and not being deleted (every route's view). */
135
+ async visible(userID: string, id: string): Promise<RecordingRecord | undefined> {
136
+ const got = await this.get(userID, id)
137
+ return got && got.value.state !== "deleting" ? got.value : undefined
138
+ }
139
+
140
+ async list(userID: string): Promise<RecordingRecord[]> {
141
+ return (await this.state.list<RecordingRecord>(userID, RECORDINGS)).map((e) => e.value)
142
+ }
143
+
144
+ async create(userID: string, record: RecordingRecord): Promise<boolean> {
145
+ return (await this.state.put(userID, RECORDINGS, record.id, record, { ifVersion: 0 })).ok
146
+ }
147
+
148
+ /**
149
+ * Read-modify-write with CAS, retried on a lost race. `change` answers the next record, or undefined to leave it
150
+ * (the current one is answered). Missing → undefined.
151
+ */
152
+ async update(userID: string, id: string, change: (current: RecordingRecord) => RecordingRecord | undefined): Promise<{ record: RecordingRecord; changed: boolean } | undefined> {
153
+ for (let attempt = 0; attempt < 32; attempt++) {
154
+ const got = await this.get(userID, id)
155
+ if (!got) return undefined
156
+ const next = change(got.value)
157
+ if (!next) return { record: got.value, changed: false }
158
+ if ((await this.state.put(userID, RECORDINGS, id, next, { ifVersion: got.version })).ok) return { record: next, changed: true }
159
+ }
160
+ throw new Error("recording record kept changing")
161
+ }
162
+
163
+ async index<T = unknown>(userID: string, id: string): Promise<Entry<T>[]> {
164
+ return this.state.list<T>(userID, indexCollection(id))
165
+ }
166
+
167
+ async indexEntry<T = unknown>(userID: string, id: string, key: string): Promise<T | undefined> {
168
+ return (await this.state.get<T>(userID, indexCollection(id), key))?.value
169
+ }
170
+
171
+ /** Writes an index key once (ifVersion 0); false when it already exists. */
172
+ async putIndex(userID: string, id: string, key: string, value: unknown): Promise<boolean> {
173
+ return (await this.state.put(userID, indexCollection(id), key, value, { ifVersion: 0 })).ok
174
+ }
175
+
176
+ async putBlob(userID: string, body: BlobBody, mediaType: (typeof RECORDING_MEDIA_TYPES)[number], maxBytes: number, quotaBytes: number): Promise<BlobRef> {
177
+ return this.blobs.put(blobTenant(userID), body, mediaType, { maxBytes, quotaBytes, mediaTypes: RECORDING_MEDIA_TYPES })
178
+ }
179
+
180
+ async usage(userID: string): Promise<number> {
181
+ return (await this.blobs.usage(blobTenant(userID))).bytes
182
+ }
183
+
184
+ /** Every hash another recording of the user indexes (deletion keeps those). */
185
+ private async otherHashes(userID: string, except: ReadonlySet<string>): Promise<Set<string>> {
186
+ const hashes = new Set<string>()
187
+ for (const record of await this.list(userID)) {
188
+ if (except.has(record.id)) continue
189
+ for (const entry of await this.index<{ hash?: string }>(userID, record.id)) if (typeof entry.value.hash === "string") hashes.add(entry.value.hash)
190
+ }
191
+ return hashes
192
+ }
193
+
194
+ /** Step 1 helper: remembers the deletion (the sweep re-checks the index collection later). */
195
+ async tombstone(userID: string, id: string, at: number): Promise<void> {
196
+ await this.state.put(userID, TOMBSTONES, id, { at })
197
+ }
198
+
199
+ /**
200
+ * After a write into a recording (an index key `key` naming `hash`): when the recording is gone or being deleted,
201
+ * the write is undone (a deletion that ran meanwhile did not see it). Answers whether it was undone.
202
+ */
203
+ async undoIfDeleted(userID: string, id: string, key: string, hash: string): Promise<boolean> {
204
+ const got = await this.get(userID, id)
205
+ if (got && got.value.state !== "deleting") return false
206
+ const entry = await this.indexEntry<{ hash?: string }>(userID, id, key)
207
+ if (entry?.hash === hash) await this.state.delete(userID, indexCollection(id), key)
208
+ await this.dropIfUnindexed(userID, hash, this.now())
209
+ return true
210
+ }
211
+
212
+ /**
213
+ * The orphan pass of the sweep: tombstoned recordings whose record is gone get their index emptied again; rec~ blobs
214
+ * no recording indexes and untouched for ORPHAN_GRACE_MS are deleted. Answers how many items it removed.
215
+ */
216
+ async sweepOrphans(userID: string): Promise<number> {
217
+ const now = this.now()
218
+ let removed = 0
219
+ for (const tomb of await this.state.list<{ at?: number }>(userID, TOMBSTONES)) {
220
+ if (await this.get(userID, tomb.key)) continue // still deleting: finishDelete runs (or the sweep resumes it)
221
+ const entries = await this.index(userID, tomb.key)
222
+ for (const entry of entries) {
223
+ await this.state.delete(userID, indexCollection(tomb.key), entry.key)
224
+ removed++
225
+ }
226
+ if (!entries.length && now - (tomb.value.at ?? 0) >= TOMBSTONE_MS) await this.state.delete(userID, TOMBSTONES, tomb.key)
227
+ }
228
+ const indexed = await this.otherHashes(userID, new Set())
229
+ let cursor: string | undefined
230
+ const tenant = blobTenant(userID)
231
+ for (;;) {
232
+ const page = await this.blobs.list(tenant, { limit: 200, ...(cursor ? { cursor } : {}) })
233
+ for (const blob of page.items) {
234
+ if (indexed.has(blob.hash) || now - blob.touchedAt < ORPHAN_GRACE_MS) continue
235
+ if (await this.blobs.delete(tenant, blob.hash, { touchedBefore: now - ORPHAN_GRACE_MS + 1 })) removed++
236
+ }
237
+ if (!page.cursor) break
238
+ cursor = page.cursor
239
+ }
240
+ return removed
241
+ }
242
+
243
+ /** Steps 2–4 of a deletion (the record is already `deleting`): blobs, index, record. */
244
+ async finishDelete(userID: string, id: string): Promise<void> {
245
+ const got = await this.get(userID, id)
246
+ if (!got) return
247
+ const deletingAt = got.value.deletingAt ?? this.now()
248
+ const entries = await this.index<{ hash?: string }>(userID, id)
249
+ const keep = await this.otherHashes(userID, new Set([id]))
250
+ for (const hash of new Set(entries.map((e) => e.value.hash).filter((h): h is string => typeof h === "string"))) {
251
+ if (keep.has(hash)) continue
252
+ await this.blobs.delete(blobTenant(userID), hash, { touchedBefore: deletingAt + 1 })
253
+ }
254
+ for (const entry of entries) await this.state.delete(userID, indexCollection(id), entry.key)
255
+ await this.state.delete(userID, RECORDINGS, id)
256
+ }
257
+
258
+ /** Discards a blob nothing indexes (a lost first-write race). */
259
+ async dropIfUnindexed(userID: string, hash: string, at: number): Promise<void> {
260
+ if ((await this.otherHashes(userID, new Set())).has(hash)) return
261
+ await this.blobs.delete(blobTenant(userID), hash, { touchedBefore: at + 1 })
262
+ }
263
+ }
264
+
265
+ export interface StartFacts {
266
+ readonly id: string
267
+ readonly kind: RecordingSummary["kind"]
268
+ readonly sessionID: string
269
+ readonly sessionTitle?: string
270
+ readonly deviceID: string
271
+ readonly deviceName?: string
272
+ readonly clientID: string
273
+ readonly client: RecordingClient
274
+ readonly anchor: RecordingAnchor
275
+ readonly audio: boolean
276
+ readonly at: number
277
+ }
278
+
279
+ export function newRecord(facts: StartFacts, speech: SpeechFacts): RecordingRecord {
280
+ return {
281
+ id: facts.id,
282
+ kind: facts.kind,
283
+ sessionID: facts.sessionID,
284
+ ...(facts.sessionTitle ? { sessionTitle: facts.sessionTitle } : {}),
285
+ deviceID: facts.deviceID,
286
+ ...(facts.deviceName ? { deviceName: facts.deviceName } : {}),
287
+ state: "active",
288
+ startedAt: facts.at,
289
+ expiresAt: facts.at + RECORDING_LIMITS.retentionMs,
290
+ bytes: 0,
291
+ segments: 0,
292
+ events: 0,
293
+ audio: facts.audio,
294
+ redactions: 0,
295
+ clientID: facts.clientID,
296
+ client: facts.client,
297
+ anchor: facts.anchor,
298
+ consoleThrough: 0,
299
+ consoleGaps: 0,
300
+ hubPages: 0,
301
+ pages: 0,
302
+ eventBytes: 0,
303
+ lastEventAt: facts.at,
304
+ capNoted: [],
305
+ skipped: { cap: 0, failed: 0 },
306
+ ...speech,
307
+ }
308
+ }
309
+
310
+ /** The record ended (unchanged when it already is). */
311
+ export function ended(record: RecordingRecord, reason: RecordingEndReason, at: number, graceUntil?: number): RecordingRecord | undefined {
312
+ if (record.state !== "active") return undefined
313
+ return { ...record, state: "ended", endedAt: at, endReason: reason, ...(graceUntil !== undefined ? { graceUntil } : {}) }
314
+ }
315
+
316
+ export type { RecordingAudioResult }