@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,104 @@
1
+ // REC-01 Concierge taps (contract: Event schema → Hub): `concierge.tool` for every agent-route call of the user's
2
+ // recorded Concierge session (method, route pattern, target, status, ms: never a request body, never a response),
3
+ // and `wake.arrived` when the notifier's send to the Concierge succeeded (the rendered batch: hub-written IDs,
4
+ // enums and counts only). Both are best effort and never change what the route or the send answers.
5
+ import { AGENT_ERROR_STATUS, AgentError } from "../agent/errors"
6
+ import type { AgentContext, AgentRoute } from "../agent/routes"
7
+
8
+ export interface ConciergeCallFields {
9
+ readonly verb?: string
10
+ readonly method: string
11
+ readonly route: string
12
+ readonly target?: string
13
+ readonly status: number
14
+ readonly ms: number
15
+ }
16
+
17
+ export interface ConciergeCallRecorder {
18
+ /** A session actor's agent call (the recorder keeps it only for a recorded Concierge session). */
19
+ call(userID: string, sessionID: string, fields: ConciergeCallFields): void
20
+ }
21
+
22
+ /** The hub verb a route maps to: its last literal path segment (board, decisions, proposals, approve, …). */
23
+ export function conciergeKind(path: string): string | undefined {
24
+ const literal = path.split("/").filter((s) => s && !s.startsWith(":"))
25
+ const last = literal.at(-1)
26
+ return last && last !== "agent" && last !== "api" ? last : undefined
27
+ }
28
+
29
+ const TARGET_PARAMS = ["addr", "id", "form", "staffing"] as const
30
+
31
+ /** Wraps every route: session-actor calls are reported to the recorder after they answer (or throw). */
32
+ export function recordConciergeCalls(routes: readonly AgentRoute[], recorder: ConciergeCallRecorder, now: () => number = Date.now): AgentRoute[] {
33
+ return routes.map((route) => ({
34
+ ...route,
35
+ handle: async (context: AgentContext) => {
36
+ if (context.actor.kind !== "session") return route.handle(context)
37
+ const started = now()
38
+ let status = 500
39
+ try {
40
+ const response = await route.handle(context)
41
+ status = response.status
42
+ return response
43
+ } catch (cause) {
44
+ status = cause instanceof AgentError ? (AGENT_ERROR_STATUS[cause.code] ?? 500) : 500
45
+ throw cause
46
+ } finally {
47
+ try {
48
+ const target = TARGET_PARAMS.map((p) => context.params[p]).find((v) => typeof v === "string" && v.length > 0)
49
+ const verb = conciergeKind(route.path)
50
+ recorder.call(context.user.id, (context.actor as { sessionID: string }).sessionID, {
51
+ ...(verb ? { verb } : {}),
52
+ method: route.method,
53
+ route: route.path,
54
+ ...(target ? { target: target.slice(0, 300) } : {}),
55
+ status,
56
+ ms: now() - started,
57
+ })
58
+ } catch {
59
+ // best effort
60
+ }
61
+ }
62
+ },
63
+ }))
64
+ }
65
+
66
+ export interface ConciergeBatchFacts {
67
+ readonly id: string
68
+ readonly kind: string
69
+ readonly budget?: string
70
+ readonly lines: number
71
+ }
72
+
73
+ export type ConciergeSend = (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean; readonly batch?: ConciergeBatchFacts }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
74
+
75
+ export interface WakeRecorder {
76
+ /** The user's current Concierge session (the send's recipient). */
77
+ current(userID: string): Promise<string | undefined>
78
+ wake(userID: string, sessionID: string, fields: Record<string, unknown>): void
79
+ }
80
+
81
+ /** The batch facts from the rendered text when the sender did not pass them ("Concierge update b7 …"). */
82
+ function factsOf(text: string): ConciergeBatchFacts {
83
+ const head = text.split("\n", 1)[0] ?? ""
84
+ const id = /\bb(\d+)\b/.exec(head)?.[0] ?? "b?"
85
+ const kind = head.startsWith("Concierge digest") ? "digest" : head.includes("quiet hours ended") ? "release" : head.includes("changes were missed") ? "snapshot" : "update"
86
+ return { id, kind, lines: Math.max(0, text.split("\n").length - 2) }
87
+ }
88
+
89
+ /** Wraps the Concierge ports' send: a successful send is recorded as wake.arrived (a refused one is not). */
90
+ export function wrapConciergeSend(send: ConciergeSend, recorder: WakeRecorder): ConciergeSend {
91
+ return async (userID, text, options) => {
92
+ const result = await send(userID, text, options)
93
+ try {
94
+ const sessionID = await recorder.current(userID)
95
+ if (sessionID !== undefined) {
96
+ const facts = options.batch ?? factsOf(text)
97
+ recorder.wake(userID, sessionID, { batchID: facts.id, batchKind: facts.kind, ...(facts.budget !== undefined ? { budget: facts.budget } : {}), wake: options.wake, lines: facts.lines, text })
98
+ }
99
+ } catch {
100
+ // best effort: the send succeeded either way
101
+ }
102
+ return result
103
+ }
104
+ }
@@ -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
+ }