@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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +9 -1
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +68 -3
- package/src/central/spoke-ws.ts +2 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +3 -0
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +31 -5
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- 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
|
+
}
|