@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,215 @@
1
+ // REC-01 export (contract: List, read, export, delete → Export; docs/voice-recording.md). One recording: at the
2
+ // zip's root `manifest.json`, `events.jsonl` (both sources merged, sorted by ts, then src (hub first), then
3
+ // seq/id), `audio/<n 6 digits>.wav` (exactly as sent to STT). All: a root manifest.json of every recording plus one
4
+ // folder `<id>/` per recording with the same three entries. Streamed: each recording's pages are read when its
5
+ // entries are reached (the root manifest of "all" reads them once more beforehand).
6
+ import { RECORDING_AUDIO_TYPE, RECORDING_FORMAT, RECORDING_FORMAT_VERSION, type RecordingEvent } from "../../protocol"
7
+ import { clockAlignment, type ClockSample, type ClockSummary } from "./clock"
8
+ import { type AudioEntry, blobTenant, type RecordingRecord, type RecordingStore } from "./store"
9
+ import type { ZipEntry } from "./zip"
10
+
11
+ export interface ExportOptions {
12
+ readonly version: string
13
+ readonly now: () => number
14
+ }
15
+
16
+ export interface SegmentManifest {
17
+ readonly n: number
18
+ readonly file?: string
19
+ readonly bytes?: number
20
+ readonly sha256?: string
21
+ readonly durationMs?: number
22
+ readonly speechMs?: number
23
+ readonly forced?: boolean
24
+ readonly startTs?: number
25
+ readonly endTs?: number
26
+ readonly text?: string
27
+ readonly audio: string
28
+ readonly redacted?: true
29
+ }
30
+
31
+ const decoder = new TextDecoder()
32
+ const encoder = new TextEncoder()
33
+ const audioFile = (n: number) => `audio/${String(n).padStart(6, "0")}.wav`
34
+
35
+ /** A hub event ID "<podTag>.<n>" as a sort key. */
36
+ const idKey = (id: unknown): [string, number] => {
37
+ const text = typeof id === "string" ? id : ""
38
+ const dot = text.lastIndexOf(".")
39
+ return [text.slice(0, dot), Number(text.slice(dot + 1)) || 0]
40
+ }
41
+
42
+ function compare(a: RecordingEvent, b: RecordingEvent): number {
43
+ if (a.ts! !== b.ts!) return a.ts! - b.ts!
44
+ if (a.src !== b.src) return a.src === "hub" ? -1 : 1
45
+ if (a.src === "console") return (a.seq ?? 0) - (b.seq ?? 0)
46
+ const [ta, na] = idKey(a.id)
47
+ const [tb, nb] = idKey(b.id)
48
+ return ta < tb ? -1 : ta > tb ? 1 : na - nb
49
+ }
50
+
51
+ interface Loaded {
52
+ readonly events: RecordingEvent[]
53
+ readonly clock: ClockSummary
54
+ readonly audio: Map<number, AudioEntry>
55
+ }
56
+
57
+ /** Every stored event of a recording (pages that are missing or unreadable are skipped), aligned and sorted. */
58
+ async function load(store: RecordingStore, userID: string, record: RecordingRecord): Promise<Loaded> {
59
+ const index = await store.index<Record<string, unknown>>(userID, record.id)
60
+ const raw: RecordingEvent[] = []
61
+ const audio = new Map<number, AudioEntry>()
62
+ for (const entry of index) {
63
+ if (entry.key.startsWith("a")) {
64
+ audio.set(Number(entry.key.slice(1)), entry.value as unknown as AudioEntry)
65
+ continue
66
+ }
67
+ const hash = entry.value.hash
68
+ if (typeof hash !== "string") continue
69
+ const blob = await store.blobs.get(blobTenant(userID), hash).catch(() => undefined)
70
+ if (!blob) continue
71
+ const text = decoder.decode(new Uint8Array(await new Response(blob.body).arrayBuffer()))
72
+ for (const line of text.split("\n")) {
73
+ if (!line) continue
74
+ try {
75
+ raw.push(JSON.parse(line) as RecordingEvent)
76
+ } catch {
77
+ // a damaged line is skipped
78
+ }
79
+ }
80
+ }
81
+ const samples: ClockSample[] = raw.filter((e) => e.src === "console" && e.kind === "clock.sync").map((e) => ({ t0: e.t0 as number, t1: e.t1 as number, hubAt: e.hubAt as number }))
82
+ const clock = clockAlignment(samples, record.anchor)
83
+ const events = raw.map((e) => ({ ...e, ts: e.src === "console" ? clock.ts(e.t ?? 0) : (e.at ?? 0) }) as RecordingEvent)
84
+ events.sort(compare)
85
+ return { events, clock: clock.summary, audio }
86
+ }
87
+
88
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
89
+
90
+ /** segments[]: the index joined with segment.closed, speech.end (aligned window), stt.result / stt.received. */
91
+ function segments(loaded: Loaded): SegmentManifest[] {
92
+ const out = new Map<number, Record<string, unknown>>()
93
+ const at = (n: unknown) => {
94
+ if (typeof n !== "number" || !Number.isSafeInteger(n)) return undefined
95
+ let s = out.get(n)
96
+ if (!s) out.set(n, (s = { n }))
97
+ return s
98
+ }
99
+ for (const e of loaded.events) {
100
+ if (e.kind === "segment.closed" && e.src === "console") {
101
+ const s = at(e.segment)
102
+ if (!s) continue
103
+ s.durationMs ??= num(e.durationMs)
104
+ s.speechMs ??= num(e.speechMs)
105
+ if (typeof e.forced === "boolean") s.forced ??= e.forced
106
+ s.closedBytes ??= num(e.bytes)
107
+ } else if (e.kind === "speech.end" && e.src === "console") {
108
+ const s = at(e.segment)
109
+ if (!s) continue
110
+ s.endTs = e.ts
111
+ s.windowMs = num(e.durationMs)
112
+ s.durationMs ??= num(e.durationMs)
113
+ s.speechMs ??= num(e.speechMs)
114
+ if (typeof e.forced === "boolean") s.forced ??= e.forced
115
+ } else if (e.kind === "stt.result" && e.src === "hub") {
116
+ const s = at(e.segment)
117
+ if (!s) continue
118
+ if (typeof e.text === "string") s.text = e.text
119
+ if (typeof e.audio === "string") s.result = e.audio
120
+ if (e.redacted?.length) s.redacted = true
121
+ } else if (e.kind === "stt.received" && e.src === "console") {
122
+ const s = at(e.segment)
123
+ if (!s) continue
124
+ if (typeof e.text === "string") s.text ??= e.text
125
+ if (typeof e.audio === "string") s.received ??= e.audio
126
+ }
127
+ }
128
+ for (const n of loaded.audio.keys()) at(n)
129
+ return [...out.values()]
130
+ .sort((a, b) => (a.n as number) - (b.n as number))
131
+ .map((s) => {
132
+ const n = s.n as number
133
+ const stored = loaded.audio.get(n)
134
+ const result = (s.result ?? s.received) as string | undefined
135
+ const audio = stored ? "stored" : result === "stored" || result === undefined ? (result === undefined ? "off" : "failed") : result
136
+ const window = (s.windowMs ?? s.durationMs) as number | undefined
137
+ const endTs = s.endTs as number | undefined
138
+ return {
139
+ n,
140
+ ...(stored ? { file: audioFile(n), bytes: stored.bytes, sha256: stored.hash.replace(/^sha256:/, "") } : {}),
141
+ ...(s.durationMs !== undefined ? { durationMs: s.durationMs as number } : stored?.durationMs !== undefined ? { durationMs: stored.durationMs } : {}),
142
+ ...(s.speechMs !== undefined ? { speechMs: s.speechMs as number } : {}),
143
+ ...(s.forced !== undefined ? { forced: s.forced as boolean } : {}),
144
+ ...(endTs !== undefined && window !== undefined ? { startTs: Math.round((endTs - window) * 10) / 10 } : {}),
145
+ ...(endTs !== undefined ? { endTs } : {}),
146
+ ...(typeof s.text === "string" ? { text: s.text } : {}),
147
+ audio,
148
+ ...(s.redacted || stored?.redacted ? { redacted: true as const } : {}),
149
+ }
150
+ })
151
+ }
152
+
153
+ function manifestEntry(record: RecordingRecord, loaded: Loaded): Record<string, unknown> {
154
+ const list = segments(loaded)
155
+ const skipped = { cap: 0, failed: 0 }
156
+ let redacted = 0
157
+ for (const s of list) {
158
+ if (s.audio === "skipped-cap") skipped.cap++
159
+ else if (s.audio === "failed") skipped.failed++
160
+ if (s.redacted) redacted++
161
+ }
162
+ return {
163
+ id: record.id,
164
+ kind: record.kind,
165
+ sessionID: record.sessionID,
166
+ ...(record.sessionTitle !== undefined ? { sessionTitle: record.sessionTitle } : {}),
167
+ device: { id: record.deviceID, ...(record.deviceName !== undefined ? { name: record.deviceName } : {}) },
168
+ state: record.state,
169
+ startedAt: record.startedAt,
170
+ ...(record.endedAt !== undefined ? { endedAt: record.endedAt } : {}),
171
+ ...(record.endReason !== undefined ? { endReason: record.endReason } : {}),
172
+ expiresAt: record.expiresAt,
173
+ client: record.client,
174
+ ...(record.stt ? { stt: record.stt } : {}),
175
+ ...(record.tts ? { tts: record.tts } : {}),
176
+ clock: loaded.clock,
177
+ audio: { format: RECORDING_AUDIO_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16 },
178
+ segments: list,
179
+ counts: { events: loaded.events.length, consoleGaps: record.consoleGaps, redactions: record.redactions, segmentsRedacted: redacted, segmentsSkipped: skipped },
180
+ }
181
+ }
182
+
183
+ const manifest = (entries: Record<string, unknown>[], records: readonly RecordingRecord[], options: ExportOptions) =>
184
+ encoder.encode(
185
+ `${JSON.stringify(
186
+ { format: RECORDING_FORMAT, version: RECORDING_FORMAT_VERSION, exportedAt: options.now(), hub: { version: options.version }, partial: records.some((r) => r.state === "active"), recordings: entries },
187
+ null,
188
+ 2,
189
+ )}\n`,
190
+ )
191
+
192
+ /** The three entries of one recording, under `prefix` ("" or "<id>/"). */
193
+ async function* recordingEntries(store: RecordingStore, userID: string, record: RecordingRecord, prefix: string, options: ExportOptions): AsyncGenerator<ZipEntry> {
194
+ const loaded = await load(store, userID, record)
195
+ const entry = manifestEntry(record, loaded)
196
+ yield { name: `${prefix}manifest.json`, body: async () => manifest([entry], [record], options) }
197
+ yield { name: `${prefix}events.jsonl`, body: async () => encoder.encode(loaded.events.map((e) => JSON.stringify(e)).join("\n") + (loaded.events.length ? "\n" : "")) }
198
+ for (const [n, audio] of [...loaded.audio].sort((a, b) => a[0] - b[0])) {
199
+ const blob = await store.blobs.get(blobTenant(userID), audio.hash).catch(() => undefined)
200
+ if (!blob) continue
201
+ yield { name: `${prefix}${audioFile(n)}`, body: async () => blob.body }
202
+ }
203
+ }
204
+
205
+ /** The zip entries of one recording (at the root) or of several (root manifest + one folder each). */
206
+ export async function* exportEntries(store: RecordingStore, userID: string, records: readonly RecordingRecord[], single: boolean, options: ExportOptions): AsyncGenerator<ZipEntry> {
207
+ if (single) {
208
+ yield* recordingEntries(store, userID, records[0]!, "", options)
209
+ return
210
+ }
211
+ const entries: Record<string, unknown>[] = []
212
+ for (const record of records) entries.push(manifestEntry(record, await load(store, userID, record)))
213
+ yield { name: "manifest.json", body: async () => manifest(entries, records, options) }
214
+ for (const record of records) yield* recordingEntries(store, userID, record, `${record.id}/`, options)
215
+ }