@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.
- package/CHANGELOG.md +193 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +116 -53
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- 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/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +92 -7
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/protocol/src/wire.ts +1 -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/sdk/src/client.ts +3 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +14 -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/context.ts +3 -1
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +134 -29
- 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 +91 -6
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +8 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +96 -6
- package/src/spoke/spoke.ts +119 -36
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +23 -2
- package/src/status.ts +33 -6
- package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-DBrO5hoL.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-BDq0AABg.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-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.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-DhVVcAep.js +0 -1
- package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- 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
|
+
}
|