@huynle/hub 0.10.0 → 0.11.0
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 +78 -0
- package/README.md +20 -4
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +625 -395
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +6 -0
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/teams-mcp.ts +5 -3
- 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 +13 -1
- package/packages/protocol/src/teams.ts +4 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -1
- 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 +13 -0
- package/packages/sdk/src/teams.ts +2 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/decisions.ts +7 -1
- package/src/central/agent/delivery.ts +39 -9
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +3 -3
- package/src/central/agent/message-limits.ts +3 -0
- package/src/central/agent/routes.ts +11 -0
- package/src/central/agent/subscriptions.ts +3 -1
- package/src/central/config.ts +9 -2
- package/src/central/http.ts +135 -24
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +234 -16
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +66 -2
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/hop-stalls.ts +72 -0
- package/src/central/teams/policy.ts +10 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +5 -2
- package/src/central/teams/validation.ts +8 -5
- package/src/spoke/spoke.ts +19 -0
- package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
- package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
- package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
- package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
- package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
- package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-Da8SKXMw.js +33 -0
- package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-BmqLASSo.js +5 -0
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
- package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
- package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/index-BS6lA6fN.js +0 -33
- package/web/dist/assets/live-CVUFIdUX.js +0 -5
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
// only while a recording is active for that session, only into the session owner's recordings, and never change
|
|
9
9
|
// what the voice path answers (failures are logged without content or IDs).
|
|
10
10
|
import { randomBytes } from "node:crypto"
|
|
11
|
+
import type { TranscribeStats } from "../speech/provider"
|
|
11
12
|
import {
|
|
13
|
+
DEV_LIMITS,
|
|
12
14
|
RECORDING_AUDIO_TYPE,
|
|
15
|
+
RECORDING_REPLY_TYPE,
|
|
13
16
|
RECORDING_HEADER,
|
|
14
17
|
RECORDING_LIMITS,
|
|
15
18
|
RECORDING_REF_HEADER,
|
|
@@ -29,10 +32,13 @@ import { redactCredentials } from "../profiles/content"
|
|
|
29
32
|
import type { ConciergeCallFields } from "./concierge-taps"
|
|
30
33
|
import { HubEventWriter, ingestConsole, PAGE_QUOTA_BYTES } from "./ingest"
|
|
31
34
|
import { RecordingRetention } from "./retention"
|
|
32
|
-
import { RecordingSettingsStore } from "./settings"
|
|
35
|
+
import { RecordingSettingsStore, type SettingsChange } from "./settings"
|
|
33
36
|
import {
|
|
34
37
|
type AudioEntry,
|
|
35
38
|
audioKey,
|
|
39
|
+
clipKey,
|
|
40
|
+
type ExtraEntry,
|
|
41
|
+
replyKey,
|
|
36
42
|
ended,
|
|
37
43
|
isSegmentNumber,
|
|
38
44
|
newRecord,
|
|
@@ -42,7 +48,7 @@ import {
|
|
|
42
48
|
type SpeechFacts,
|
|
43
49
|
summaryOf,
|
|
44
50
|
} from "./store"
|
|
45
|
-
import { parseTtsRef, RegistryTaps } from "./taps"
|
|
51
|
+
import { parseTtsRef, RegistryTaps, type TurnTimings } from "./taps"
|
|
46
52
|
|
|
47
53
|
const L = RECORDING_LIMITS
|
|
48
54
|
export const RECORDINGS_TOPIC = "recordings.changed"
|
|
@@ -82,20 +88,57 @@ export interface RecordingServiceOptions {
|
|
|
82
88
|
readonly log?: (message: string) => void
|
|
83
89
|
readonly sweepMs?: number
|
|
84
90
|
readonly hubFlushMs?: number
|
|
91
|
+
/** Developer mode's limits (tests shrink them). */
|
|
92
|
+
readonly devLimits?: Partial<DevLimits>
|
|
93
|
+
/** This pod's name for request lines (Developer mode). */
|
|
94
|
+
readonly pod?: string
|
|
95
|
+
/**
|
|
96
|
+
* (Developer mode) What ran a session's turn: the model and its provider, the agent, the context size (token
|
|
97
|
+
* counts only). Asked only when a developer recording of the session is active; never text.
|
|
98
|
+
*/
|
|
99
|
+
readonly turnFacts?: (userID: string, sessionID: string) => Promise<TurnFacts>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TurnFacts {
|
|
103
|
+
readonly provider?: string
|
|
104
|
+
readonly model?: string
|
|
105
|
+
readonly variant?: string
|
|
106
|
+
readonly agent?: string
|
|
107
|
+
readonly contextTokens?: number
|
|
108
|
+
readonly contextLimit?: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** How long a turn detail waits for the session's facts before it is recorded without them. */
|
|
112
|
+
export const TURN_FACTS_MS = 3000
|
|
113
|
+
|
|
114
|
+
export type DevLimits = { -readonly [K in keyof typeof DEV_LIMITS]: number }
|
|
115
|
+
|
|
116
|
+
/** One voice request of the user (Developer mode's request lines): what route, its status and time. Never a body. */
|
|
117
|
+
export interface RequestLine {
|
|
118
|
+
readonly route: "transcribe" | "speak" | "events"
|
|
119
|
+
readonly method: string
|
|
120
|
+
readonly status: number
|
|
121
|
+
readonly ms: number
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
export type ExtraOutcome = { readonly kind: "stored"; readonly entry: ExtraEntry; readonly n: number; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "off" } | { readonly kind: "failed"; readonly cause: unknown }
|
|
125
|
+
|
|
87
126
|
interface ActiveRef {
|
|
88
127
|
readonly id: string
|
|
89
128
|
readonly sessionID: string
|
|
90
129
|
readonly kind: RecordingKind
|
|
91
130
|
readonly deviceID: string
|
|
131
|
+
readonly developer?: true
|
|
92
132
|
}
|
|
93
133
|
|
|
94
134
|
export type AudioOutcome = { readonly kind: "stored"; readonly entry: AudioEntry; readonly fresh: boolean } | { readonly kind: "cap" } | { readonly kind: "gone" } | { readonly kind: "failed"; readonly cause: unknown }
|
|
95
135
|
|
|
96
136
|
export interface TranscribeTap {
|
|
97
|
-
/**
|
|
98
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). `stats`: the
|
|
139
|
+
* provider's HTTP status, response bytes and time to headers, recorded in stt.result when known.
|
|
140
|
+
*/
|
|
141
|
+
finish(text: string, providerMs: number, stats?: TranscribeStats): Promise<RecordingAudioResult>
|
|
99
142
|
}
|
|
100
143
|
|
|
101
144
|
export interface SpeakTap {
|
|
@@ -118,9 +161,11 @@ export class RecordingService {
|
|
|
118
161
|
private readonly background = new Set<Promise<unknown>>()
|
|
119
162
|
private readonly opened = new Set<string>()
|
|
120
163
|
private unsubscribe?: () => Promise<void>
|
|
164
|
+
readonly devLimits: DevLimits
|
|
121
165
|
|
|
122
166
|
constructor(private readonly options: RecordingServiceOptions) {
|
|
123
167
|
this.now = options.now ?? Date.now
|
|
168
|
+
this.devLimits = { ...DEV_LIMITS, ...options.devLimits }
|
|
124
169
|
// Unique per process: a restarted pod never reuses another's page keys.
|
|
125
170
|
this.podTag = `${(options.podID ?? "hub").replace(/[^A-Za-z0-9]/g, "").slice(0, 12) || "hub"}${randomBytes(3).toString("hex")}`
|
|
126
171
|
this.store = new RecordingStore(options.state, options.blobs, this.now)
|
|
@@ -139,7 +184,14 @@ export class RecordingService {
|
|
|
139
184
|
})().catch(() => undefined),
|
|
140
185
|
})
|
|
141
186
|
this.retention = new RecordingRetention({ users: options.users, sweepUser: (userID) => this.sweepUser(userID), ...(options.sweepMs !== undefined ? { sweepMs: options.sweepMs } : {}), log: (m) => this.log(m) })
|
|
142
|
-
this.registryTap = new RegistryTaps(
|
|
187
|
+
this.registryTap = new RegistryTaps(
|
|
188
|
+
{
|
|
189
|
+
hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields),
|
|
190
|
+
mayRecord: (userID) => this.mayRecord(userID),
|
|
191
|
+
turnDetail: (userID, sessionID, detail) => void this.track(this.turnDetail(userID, sessionID, detail)).catch((cause) => this.log(`[hub] recording turn detail failed: ${(cause as Error).message}`)),
|
|
192
|
+
},
|
|
193
|
+
this.now,
|
|
194
|
+
)
|
|
143
195
|
if (options.bus) {
|
|
144
196
|
options.bus
|
|
145
197
|
.subscribe(RECORDINGS_TOPIC, (message) => {
|
|
@@ -168,7 +220,7 @@ export class RecordingService {
|
|
|
168
220
|
private async activeOf(userID: string): Promise<ActiveRef[]> {
|
|
169
221
|
const cached = this.active.get(userID)
|
|
170
222
|
if (cached && this.now() - cached.at < ACTIVE_TTL_MS) return cached.refs
|
|
171
|
-
const refs = (await this.store.list(userID)).filter((r) => r.state === "active").map((r) => ({ id: r.id, sessionID: r.sessionID, kind: r.kind, deviceID: r.deviceID }))
|
|
223
|
+
const refs = (await this.store.list(userID)).filter((r) => r.state === "active").map((r) => ({ id: r.id, sessionID: r.sessionID, kind: r.kind, deviceID: r.deviceID, ...(r.developer ? { developer: true as const } : {}) }))
|
|
172
224
|
this.active.set(userID, { at: this.now(), refs })
|
|
173
225
|
if (this.active.size > 10_000) this.active.delete(this.active.keys().next().value as string)
|
|
174
226
|
return refs
|
|
@@ -214,6 +266,29 @@ export class RecordingService {
|
|
|
214
266
|
return run
|
|
215
267
|
}
|
|
216
268
|
|
|
269
|
+
/**
|
|
270
|
+
* (Developer mode) turn.detail into the session's active developer recordings: the turn's timings and what ran it
|
|
271
|
+
* (asked of the session only then, at most TURN_FACTS_MS). Only the whitelisted facts: never text.
|
|
272
|
+
*/
|
|
273
|
+
private async turnDetail(userID: string, sessionID: string, detail: TurnTimings): Promise<void> {
|
|
274
|
+
if (!this.mayRecord(userID)) return
|
|
275
|
+
const refs = (await this.activeOf(userID)).filter((r) => r.sessionID === sessionID && r.developer)
|
|
276
|
+
if (!refs.length) return
|
|
277
|
+
let facts: TurnFacts = {}
|
|
278
|
+
if (this.options.turnFacts) {
|
|
279
|
+
const timeout = new Promise<TurnFacts>((resolve) => setTimeout(() => resolve({}), TURN_FACTS_MS).unref?.())
|
|
280
|
+
facts = await Promise.race([this.options.turnFacts(userID, sessionID).catch(() => ({})), timeout])
|
|
281
|
+
}
|
|
282
|
+
const at = this.now()
|
|
283
|
+
const label = (v: unknown) => (typeof v === "string" && v ? v.slice(0, 120) : undefined)
|
|
284
|
+
const count = (v: unknown) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.round(v) : undefined)
|
|
285
|
+
const picked = Object.fromEntries(
|
|
286
|
+
Object.entries({ provider: label(facts.provider), model: label(facts.model), variant: label(facts.variant), agent: label(facts.agent), contextTokens: count(facts.contextTokens), contextLimit: count(facts.contextLimit) }).filter(([, v]) => v !== undefined),
|
|
287
|
+
)
|
|
288
|
+
const tools = detail.tools.map((t) => ({ callID: t.callID.slice(0, 120), ...(t.tool ? { tool: t.tool } : {}), startMs: t.startMs, ...(t.endMs !== undefined ? { endMs: t.endMs } : {}), state: t.state }))
|
|
289
|
+
for (const ref of refs) this.writer.add(userID, ref.id, "turn.detail", { outcome: detail.outcome, ms: detail.ms, ...(detail.firstTokenMs !== undefined ? { firstTokenMs: detail.firstTokenMs } : {}), ...picked, tools }, at)
|
|
290
|
+
}
|
|
291
|
+
|
|
217
292
|
/** Writes every buffered hub event now. */
|
|
218
293
|
async flush(): Promise<void> {
|
|
219
294
|
await Promise.all([...this.tails.values()])
|
|
@@ -239,13 +314,28 @@ export class RecordingService {
|
|
|
239
314
|
return { bytes, recordings: records.filter((r) => r.state !== "deleting").length, capBytes: L.userBytes }
|
|
240
315
|
}
|
|
241
316
|
|
|
242
|
-
|
|
243
|
-
|
|
317
|
+
/** The setting: recording and Developer mode (developer implies enabled; see RecordingSettingsStore.set). */
|
|
318
|
+
async setSettings(userID: string, deviceID: string, change: SettingsChange, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
|
|
319
|
+
const written = await this.settings.set(userID, change, expectedRevision)
|
|
244
320
|
if (!written.ok) return written
|
|
321
|
+
const { enabled } = written.settings
|
|
322
|
+
const developer = written.settings.developer === true
|
|
245
323
|
this.options.notify(userID, { type: "recording", settings: written.settings }, deviceID)
|
|
246
|
-
if (written.changed) {
|
|
247
|
-
|
|
248
|
-
|
|
324
|
+
if (written.changed) await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
|
|
325
|
+
if (written.developerChanged) await this.options.audit({ actor: userID, action: developer ? "recording.developer.enable" : "recording.developer.disable" })
|
|
326
|
+
if (written.changed && !enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
|
|
327
|
+
// Developer mode off: the running recordings go on without their extras from now on (on: from the next start).
|
|
328
|
+
if (written.developerChanged && !developer && enabled) {
|
|
329
|
+
for (const r of await this.store.list(userID)) {
|
|
330
|
+
if (r.state !== "active" || !r.developer) continue
|
|
331
|
+
await this.store.update(userID, r.id, (current) => {
|
|
332
|
+
if (!current.developer) return undefined
|
|
333
|
+
const { developer: _off, ...rest } = current
|
|
334
|
+
return rest as RecordingRecord
|
|
335
|
+
})
|
|
336
|
+
this.changed(userID, r.sessionID)
|
|
337
|
+
this.options.notify(userID, { type: "recording", recordingID: r.id, developer: false })
|
|
338
|
+
}
|
|
249
339
|
}
|
|
250
340
|
return { ok: true, settings: written.settings }
|
|
251
341
|
}
|
|
@@ -271,7 +361,8 @@ export class RecordingService {
|
|
|
271
361
|
}
|
|
272
362
|
|
|
273
363
|
async start(userID: string, device: { id: string; name?: string }, input: StartInput): Promise<StartResult> {
|
|
274
|
-
|
|
364
|
+
const setting = await this.settings.get(userID)
|
|
365
|
+
if (!setting.enabled) return { kind: "off" }
|
|
275
366
|
if (!this.options.owns(userID, input.sessionID)) return { kind: "not_found" }
|
|
276
367
|
const records = await this.store.list(userID)
|
|
277
368
|
const same = records.find((r) => r.clientID === input.clientID && r.deviceID === device.id && r.state !== "deleting")
|
|
@@ -298,6 +389,7 @@ export class RecordingService {
|
|
|
298
389
|
anchor: input.anchor ?? { perf: 0, epoch: at },
|
|
299
390
|
audio,
|
|
300
391
|
at,
|
|
392
|
+
developer: setting.developer === true,
|
|
301
393
|
},
|
|
302
394
|
speech,
|
|
303
395
|
)
|
|
@@ -445,7 +537,7 @@ export class RecordingService {
|
|
|
445
537
|
this.writer.add(userID, id, "stt.request", { segment: n, bytes: audio.length, format }, at)
|
|
446
538
|
const model = live.stt?.model
|
|
447
539
|
return {
|
|
448
|
-
finish: async (text, providerMs) => {
|
|
540
|
+
finish: async (text, providerMs, stats) => {
|
|
449
541
|
// (USER CHANGE vs the contract text) A transcript matching a secret pattern: the text is scrubbed (the
|
|
450
542
|
// hub record of stt.result is), the audio kept, and the segment marked redacted.
|
|
451
543
|
const redacted = redactCredentials(text).kinds.length > 0
|
|
@@ -460,7 +552,7 @@ export class RecordingService {
|
|
|
460
552
|
} catch (cause) {
|
|
461
553
|
result = this.audioResult(userID, id, n, { kind: "failed", cause })
|
|
462
554
|
}
|
|
463
|
-
this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result })
|
|
555
|
+
this.writer.add(userID, id, "stt.result", { segment: n, text, providerMs, ...(model ? { model } : {}), audio: result, ...providerStats(stats) })
|
|
464
556
|
return result
|
|
465
557
|
},
|
|
466
558
|
}
|
|
@@ -482,19 +574,34 @@ export class RecordingService {
|
|
|
482
574
|
return "failed"
|
|
483
575
|
}
|
|
484
576
|
|
|
485
|
-
/**
|
|
577
|
+
/**
|
|
578
|
+
* /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing. In a developer recording (with a ref) the
|
|
579
|
+
* reply audio is also kept, as the client received it (copied while streaming, no second upload).
|
|
580
|
+
*/
|
|
486
581
|
async speakTap(userID: string, deviceID: string, headers: Headers, chars: number): Promise<SpeakTap | undefined> {
|
|
487
582
|
const id = headers.get(RECORDING_HEADER)
|
|
488
583
|
if (id === null) return undefined
|
|
489
584
|
const record = await this.store.visible(userID, id).catch(() => undefined)
|
|
490
585
|
if (!record || record.state !== "active" || record.deviceID !== deviceID) return undefined
|
|
491
586
|
const ref = parseTtsRef(headers.get(RECORDING_REF_HEADER))
|
|
587
|
+
const keep = record.developer === true && ref !== undefined
|
|
492
588
|
const started = this.now()
|
|
493
|
-
const
|
|
589
|
+
const chunks: Uint8Array[] = []
|
|
590
|
+
let kept = 0
|
|
591
|
+
const collect = (chunk: Uint8Array) => {
|
|
592
|
+
if (!keep || kept > this.devLimits.replyBytes) return
|
|
593
|
+
kept += chunk.length
|
|
594
|
+
if (kept <= this.devLimits.replyBytes) chunks.push(chunk)
|
|
595
|
+
}
|
|
596
|
+
const done = (firstByteMs: number | undefined) => {
|
|
597
|
+
this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
|
|
598
|
+
if (keep) void this.track(this.keepReply(userID, id, ref!, kept > this.devLimits.replyBytes ? undefined : chunks))
|
|
599
|
+
}
|
|
494
600
|
return {
|
|
495
601
|
wrap: <T extends Uint8Array | ReadableStream<Uint8Array>>(audio: T): T => {
|
|
496
602
|
if (audio instanceof Uint8Array) {
|
|
497
603
|
const ms = this.now() - started
|
|
604
|
+
collect(audio)
|
|
498
605
|
done(ms)
|
|
499
606
|
return audio
|
|
500
607
|
}
|
|
@@ -503,6 +610,7 @@ export class RecordingService {
|
|
|
503
610
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
504
611
|
transform: (chunk, controller) => {
|
|
505
612
|
first ??= this.now() - started
|
|
613
|
+
collect(chunk)
|
|
506
614
|
controller.enqueue(chunk)
|
|
507
615
|
},
|
|
508
616
|
flush: () => done(first),
|
|
@@ -513,6 +621,96 @@ export class RecordingService {
|
|
|
513
621
|
}
|
|
514
622
|
}
|
|
515
623
|
|
|
624
|
+
/** A reply sentence's audio (Developer mode): stored under the next reply number; reply.audio says what happened. */
|
|
625
|
+
private async keepReply(userID: string, id: string, ref: NonNullable<ExtraEntry["ref"]>, chunks: Uint8Array[] | undefined): Promise<void> {
|
|
626
|
+
const bytes = chunks ? concat(chunks) : undefined
|
|
627
|
+
const record = await this.store.visible(userID, id)
|
|
628
|
+
if (!record) return
|
|
629
|
+
const outcome: ExtraOutcome = bytes?.length ? await this.storeExtra(userID, record, "reply", undefined, bytes, ref) : { kind: "cap" }
|
|
630
|
+
const audio = outcome.kind === "stored" ? "stored" : outcome.kind === "cap" ? "skipped-cap" : outcome.kind === "failed" ? "failed" : "off"
|
|
631
|
+
this.writer.add(userID, id, "reply.audio", { ref, ...(outcome.kind === "stored" ? { n: outcome.n } : {}), bytes: bytes?.length ?? 0, audio })
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* (Developer mode) Stores an audio clip (`n` from the console) or a reply's audio (the next number). Only for a
|
|
636
|
+
* developer recording; the extras' audio stops first: never past (cap − headroom) of the user's or the recording's
|
|
637
|
+
* cap, so segments keep the rest (cap.reached {what: "extras"} once).
|
|
638
|
+
*/
|
|
639
|
+
async storeExtra(userID: string, record: RecordingRecord, what: "clip" | "reply", n: number | undefined, audio: Uint8Array, ref?: ExtraEntry["ref"]): Promise<ExtraOutcome> {
|
|
640
|
+
const D = this.devLimits
|
|
641
|
+
if (!record.developer) return { kind: "off" }
|
|
642
|
+
if (what === "clip" && n !== undefined) {
|
|
643
|
+
const existing = await this.store.indexEntry<ExtraEntry>(userID, record.id, clipKey(n))
|
|
644
|
+
if (existing) return { kind: "stored", entry: existing, n, fresh: false }
|
|
645
|
+
}
|
|
646
|
+
if ((await this.store.usage(userID)) + audio.length > L.userBytes - D.userHeadroomBytes) {
|
|
647
|
+
await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
|
|
648
|
+
return { kind: "cap" }
|
|
649
|
+
}
|
|
650
|
+
// Reserve the bytes (and a reply's number) in the record, so parallel writes never pass the limits.
|
|
651
|
+
let verdict = "gone" as "ok" | "over" | "gone" | "off"
|
|
652
|
+
let number = n ?? 0
|
|
653
|
+
await this.store.update(userID, record.id, (current) => {
|
|
654
|
+
if (current.state === "deleting") return void (verdict = "gone")
|
|
655
|
+
if (!current.developer) return void (verdict = "off")
|
|
656
|
+
const count = what === "clip" ? (current.clips ?? 0) : (current.replies ?? 0)
|
|
657
|
+
const max = what === "clip" ? D.clipsPerRecording : D.repliesPerRecording
|
|
658
|
+
if (count >= max || current.bytes + audio.length > L.recordingBytes - D.recordingHeadroomBytes) return void (verdict = "over")
|
|
659
|
+
verdict = "ok"
|
|
660
|
+
if (what === "reply") number = count + 1
|
|
661
|
+
return { ...current, bytes: current.bytes + audio.length, ...(what === "clip" ? { clips: count + 1 } : { replies: count + 1 }) }
|
|
662
|
+
})
|
|
663
|
+
if (verdict === "gone" || verdict === "off") return { kind: verdict }
|
|
664
|
+
if (verdict === "over") {
|
|
665
|
+
await this.extrasCap(userID, record, "recording", L.recordingBytes - D.recordingHeadroomBytes)
|
|
666
|
+
return { kind: "cap" }
|
|
667
|
+
}
|
|
668
|
+
const release = () =>
|
|
669
|
+
this.store.update(userID, record.id, (current) =>
|
|
670
|
+
current.state === "deleting" ? undefined : { ...current, bytes: Math.max(0, current.bytes - audio.length), ...(what === "clip" ? { clips: Math.max(0, (current.clips ?? 1) - 1) } : {}) },
|
|
671
|
+
)
|
|
672
|
+
let blob
|
|
673
|
+
try {
|
|
674
|
+
blob = await this.store.putBlob(userID, audio, what === "clip" ? RECORDING_AUDIO_TYPE : RECORDING_REPLY_TYPE, what === "clip" ? D.clipBytes : D.replyBytes, L.userBytes - D.userHeadroomBytes)
|
|
675
|
+
} catch (cause) {
|
|
676
|
+
await release()
|
|
677
|
+
if (cause instanceof BlobQuotaError) {
|
|
678
|
+
await this.extrasCap(userID, record, "user", L.userBytes - D.userHeadroomBytes)
|
|
679
|
+
return { kind: "cap" }
|
|
680
|
+
}
|
|
681
|
+
return { kind: "failed", cause }
|
|
682
|
+
}
|
|
683
|
+
const at = this.now()
|
|
684
|
+
const key = what === "clip" ? clipKey(number) : replyKey(number)
|
|
685
|
+
const entry: ExtraEntry = { hash: blob.hash, bytes: blob.bytes, at, ...(ref ? { ref } : {}) }
|
|
686
|
+
if (!(await this.store.putIndex(userID, record.id, key, entry))) {
|
|
687
|
+
await release()
|
|
688
|
+
const first = await this.store.indexEntry<ExtraEntry>(userID, record.id, key)
|
|
689
|
+
if (first?.hash !== blob.hash) await this.store.dropIfUnindexed(userID, blob.hash, at)
|
|
690
|
+
return first ? { kind: "stored", entry: first, n: number, fresh: false } : { kind: "failed", cause: new Error("extra index lost") }
|
|
691
|
+
}
|
|
692
|
+
if (await this.store.undoIfDeleted(userID, record.id, key, blob.hash)) return { kind: "gone" }
|
|
693
|
+
return { kind: "stored", entry, n: number, fresh: true }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** cap.reached {what: "extras"} once per scope; audio (segments) goes on. */
|
|
697
|
+
private async extrasCap(userID: string, record: RecordingRecord, scope: "user" | "recording", limit: number): Promise<void> {
|
|
698
|
+
const key = `extras:${scope}`
|
|
699
|
+
const result = await this.store.update(userID, record.id, (current) => (current.capNoted.includes(key) ? undefined : { ...current, capNoted: [...current.capNoted, key] }))
|
|
700
|
+
if (result?.changed) this.writer.add(userID, record.id, "cap.reached", { what: "extras", scope, limit })
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* (Developer mode) A request line for one of the user's voice requests naming a recording (transcribe, speak:
|
|
705
|
+
* x-hub-recording; events: the path): only into an active developer recording of that device. Never a body.
|
|
706
|
+
*/
|
|
707
|
+
async requestLine(userID: string, deviceID: string, recordingID: string | null | undefined, line: RequestLine): Promise<void> {
|
|
708
|
+
if (!recordingID || !this.mayRecord(userID)) return
|
|
709
|
+
const record = await this.store.visible(userID, recordingID).catch(() => undefined)
|
|
710
|
+
if (!record?.developer || record.deviceID !== deviceID || (record.state !== "active" && !(record.graceUntil !== undefined && this.now() <= record.graceUntil))) return
|
|
711
|
+
this.writer.add(userID, record.id, "request", { route: line.route, method: line.method, status: line.status, ms: Math.round(line.ms), pod: this.options.pod ?? "hub" })
|
|
712
|
+
}
|
|
713
|
+
|
|
516
714
|
/** POST …/conversation: the event, and (off) the session's recordings end with a grace for the last batches. */
|
|
517
715
|
async conversationChanged(userID: string, sessionID: string, on: boolean): Promise<void> {
|
|
518
716
|
await this.hubNow(userID, sessionID, "conversation", { on })
|
|
@@ -656,3 +854,23 @@ export type StartResult =
|
|
|
656
854
|
| { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
|
|
657
855
|
|
|
658
856
|
export { summaryOf }
|
|
857
|
+
|
|
858
|
+
const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
|
|
859
|
+
const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
|
|
860
|
+
let at = 0
|
|
861
|
+
for (const c of chunks) {
|
|
862
|
+
out.set(c, at)
|
|
863
|
+
at += c.length
|
|
864
|
+
}
|
|
865
|
+
return out
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** stt.result's provider fields (capture.v2): the upstream HTTP status, response bytes and time to headers, when known. */
|
|
869
|
+
function providerStats(stats: TranscribeStats | undefined): Record<string, number> {
|
|
870
|
+
const out: Record<string, number> = {}
|
|
871
|
+
const finite = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x)
|
|
872
|
+
if (finite(stats?.status)) out.httpStatus = stats.status
|
|
873
|
+
if (finite(stats?.bytes)) out.responseBytes = stats.bytes
|
|
874
|
+
if (finite(stats?.headersMs)) out.headersMs = Math.round(stats.headersMs)
|
|
875
|
+
return out
|
|
876
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ifVersion 0 (an existing key is a page stored before a crash: treated as stored), then the record CAS-updated.
|
|
4
4
|
// Hub events: buffered per pod and recording, written as pages of up to hubFlushEvents every hubFlushMs (and at
|
|
5
5
|
// end and drain) under h<podTag>-<n>; a pod that dies loses at most its last hubFlushMs of hub events.
|
|
6
|
-
import { type ConsoleEvent, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
|
|
6
|
+
import { type ConsoleEvent, DEV_CONSOLE_KINDS, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
|
|
7
7
|
import { BlobQuotaError } from "../artifacts/blob-store"
|
|
8
8
|
import type { Clock, Timers } from "../backend/types"
|
|
9
9
|
import { consoleRecord, hubRecord } from "./schema"
|
|
@@ -15,6 +15,7 @@ export const PAGE_QUOTA_BYTES = L.userBytes + L.activePerUser * L.eventBytesPerR
|
|
|
15
15
|
/** Room past PAGE_QUOTA_BYTES for the end markers of a recording whose user filled the page quota. */
|
|
16
16
|
export const CONTROL_HEADROOM_BYTES = 1024 * 1024
|
|
17
17
|
const CONTROL_KINDS = new Set<string>(["cap.reached", "recording.ended", "error"])
|
|
18
|
+
const DEV_KINDS: ReadonlySet<string> = new Set(DEV_CONSOLE_KINDS)
|
|
18
19
|
/** A page is the batch plus the envelope the hub adds (≈ 50 bytes per event). */
|
|
19
20
|
const PAGE_MAX_BYTES = L.batchBytes + L.eventsPerBatch * 128
|
|
20
21
|
|
|
@@ -33,7 +34,15 @@ export async function ingestConsole(store: RecordingStore, userID: string, recor
|
|
|
33
34
|
const fresh = events.filter((e) => e.seq > record.consoleThrough)
|
|
34
35
|
if (!fresh.length) return { kind: "stored", record }
|
|
35
36
|
const at = now()
|
|
36
|
-
|
|
37
|
+
// Developer mode's kinds only in a developer recording (a console that has not seen it turned off yet): dropped
|
|
38
|
+
// here, their seqs still count as stored (no gap).
|
|
39
|
+
const kept = record.developer ? fresh : fresh.filter((e) => !DEV_KINDS.has(e.kind))
|
|
40
|
+
if (!kept.length) {
|
|
41
|
+
const last = fresh.at(-1)!.seq
|
|
42
|
+
const updated = await store.update(userID, record.id, (current) => (current.state === "deleting" || current.consoleThrough >= last ? undefined : { ...current, consoleThrough: last, consoleGaps: current.consoleGaps + Math.max(0, fresh[0]!.seq - (current.consoleThrough + 1)), lastEventAt: at }))
|
|
43
|
+
return updated ? { kind: "stored", record: updated.record } : { kind: "gone" }
|
|
44
|
+
}
|
|
45
|
+
const records = kept.map((e) => consoleRecord(e, at))
|
|
37
46
|
const page = ndjson(records)
|
|
38
47
|
if (record.events + records.length > L.eventsPerRecording || record.eventBytes + page.length > L.eventBytesPerRecording) return { kind: "limit" }
|
|
39
48
|
const first = fresh[0]!.seq
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// is a console route with access "user": the user's cookie or device token only (the hub refuses session, app and
|
|
3
3
|
// act tokens before these run), so no agent, not even the Concierge, can read or export a recording, and there is
|
|
4
4
|
// no admin route. Every lookup is by (user.id, id); another user's, an unknown and a deleted ID answer 404 alike.
|
|
5
|
-
import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, type RecordingClient } from "../../protocol"
|
|
5
|
+
import { RECORDING_AUDIO_TYPE, RECORDING_FEATURES, RECORDING_LIMITS, RECORDING_REPLY_TYPE, type RecordingClient } from "../../protocol"
|
|
6
6
|
import { readBounded } from "../agent/body"
|
|
7
7
|
import { checkConsoleBatch } from "./schema"
|
|
8
|
-
import { audioKey, blobTenant, isSegmentNumber, summaryOf, type AudioEntry } from "./store"
|
|
8
|
+
import { audioKey, blobTenant, clipKey, type ExtraEntry, isSegmentNumber, replyKey, summaryOf, type AudioEntry } from "./store"
|
|
9
9
|
import { exportEntries } from "./export"
|
|
10
10
|
import type { RecordingService, StartInput } from "./index"
|
|
11
11
|
import { zipStream } from "./zip"
|
|
@@ -169,6 +169,25 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
169
169
|
return n
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/** A clip's or reply's number: 1 to the recording's limit. */
|
|
173
|
+
const extraNumber = (raw: string | undefined, max: number, what: string): number => {
|
|
174
|
+
const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0
|
|
175
|
+
if (n < 1 || n > max) throw new BadInput(`${what} must be a number 1–${max}`)
|
|
176
|
+
return n
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** GET of a stored extra (a clip or a reply's audio): owner-only, private, as a download. */
|
|
180
|
+
const extraResponse = async (userID: string, id: string, key: string, type: string, file: string): Promise<Response> => {
|
|
181
|
+
const record = await service.visible(userID, id)
|
|
182
|
+
if (!record) return notFound()
|
|
183
|
+
const entry = await service.store.indexEntry<ExtraEntry>(userID, record.id, key)
|
|
184
|
+
const blob = entry ? await service.store.blobs.get(blobTenant(userID), entry.hash) : undefined
|
|
185
|
+
if (!blob) return notFound()
|
|
186
|
+
return new Response(blob.body, {
|
|
187
|
+
headers: { ...PRIVATE, "content-type": type, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${file}"` },
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
172
191
|
return [
|
|
173
192
|
route("GET", "/api/settings/recording", async ({ user }) =>
|
|
174
193
|
Response.json({ settings: await service.settings.get(user.id), usage: await service.usage(user.id), limits: { retentionDays: L.retentionMs / 86_400_000, userBytes: L.userBytes, recordingBytes: L.recordingBytes } }, { headers: NO_STORE }),
|
|
@@ -176,11 +195,14 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
176
195
|
route("PUT", "/api/settings/recording", async ({ request, user, device }) => {
|
|
177
196
|
const input = await jsonOf(request)
|
|
178
197
|
if (input instanceof Response) return input
|
|
179
|
-
only(input, ["enabled", "expectedRevision"])
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
only(input, ["enabled", "developer", "expectedRevision"])
|
|
199
|
+
const flag = (v: unknown) => v === undefined || typeof v === "boolean"
|
|
200
|
+
if (!flag(input.enabled) || !flag(input.developer) || (input.enabled === undefined && input.developer === undefined) || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) {
|
|
201
|
+
throw new BadInput("Expected {enabled?: boolean, developer?: boolean, expectedRevision: number}")
|
|
182
202
|
}
|
|
183
|
-
|
|
203
|
+
if (input.enabled === false && input.developer === true) throw new BadInput("Developer mode needs recording on")
|
|
204
|
+
const change = { ...(input.enabled !== undefined ? { enabled: input.enabled as boolean } : {}), ...(input.developer !== undefined ? { developer: input.developer as boolean } : {}) }
|
|
205
|
+
const result = await service.setSettings(user.id, device.id, change, input.expectedRevision as number)
|
|
184
206
|
if (!result.ok) return fail(409, "The setting changed meanwhile; reload it", "conflict", { currentRevision: result.currentRevision })
|
|
185
207
|
return Response.json({ settings: result.settings }, { headers: NO_STORE })
|
|
186
208
|
}),
|
|
@@ -199,7 +221,10 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
199
221
|
return fail(429, `At most ${L.recordingsStored} recordings are kept; delete some in Settings → Voice`, "limit", { limit: L.recordingsStored })
|
|
200
222
|
}
|
|
201
223
|
const record = started.record
|
|
202
|
-
return Response.json(
|
|
224
|
+
return Response.json(
|
|
225
|
+
{ recording: summaryOf(record), audio: record.audio, hubAt: options.now(), features: [...RECORDING_FEATURES], ...(record.developer ? { developer: true } : {}) },
|
|
226
|
+
{ status: started.kind === "created" ? 201 : 200, headers: NO_STORE },
|
|
227
|
+
)
|
|
203
228
|
}),
|
|
204
229
|
route("GET", "/api/recordings", async ({ url, user }) => {
|
|
205
230
|
const limitRaw = url.searchParams.get("limit")
|
|
@@ -235,6 +260,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
235
260
|
return after ? Response.json({ recording: summaryOf(after) }, { headers: NO_STORE }) : notFound()
|
|
236
261
|
}),
|
|
237
262
|
route("POST", "/api/recordings/:id/events", async ({ request, params, user, device }) => {
|
|
263
|
+
const startedAt = options.now()
|
|
238
264
|
const appendable = await service.appendable(user.id, device.id, params.id!)
|
|
239
265
|
if (appendable.kind === "not_found") return notFound()
|
|
240
266
|
if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
|
|
@@ -244,6 +270,7 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
244
270
|
const checked = checkConsoleBatch(input.events)
|
|
245
271
|
if (!checked.ok) return checked.limit ? fail(413, checked.problem, "limit") : fail(400, checked.problem, "invalid", checked.seq !== undefined ? { seq: checked.seq } : {})
|
|
246
272
|
const appended = await service.appendConsole(user.id, appendable.record, checked.events)
|
|
273
|
+
void service.requestLine(user.id, device.id, appendable.record.id, { route: "events", method: "POST", status: appended.kind === "ok" ? 200 : appended.kind === "limit" ? 413 : 404, ms: options.now() - startedAt }).catch(() => undefined)
|
|
247
274
|
if (appended.kind === "not_found") return notFound()
|
|
248
275
|
if (appended.kind === "limit") return fail(413, "This recording reached its event limit and has ended", "limit")
|
|
249
276
|
return Response.json({ through: appended.through, hubAt: options.now(), audio: appended.audio }, { headers: NO_STORE })
|
|
@@ -275,6 +302,33 @@ export function recordingRoutes(service: RecordingService, options: { readonly v
|
|
|
275
302
|
headers: { ...PRIVATE, "content-type": RECORDING_AUDIO_TYPE, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${n}.wav"` },
|
|
276
303
|
})
|
|
277
304
|
}),
|
|
305
|
+
// Developer mode: audio clips around near-misses and marks (uploaded by the recording's device) and reply audio.
|
|
306
|
+
route("PUT", "/api/recordings/:id/clips/:n", async ({ request, params, user, device }) => {
|
|
307
|
+
const appendable = await service.appendable(user.id, device.id, params.id!)
|
|
308
|
+
if (appendable.kind === "not_found") return notFound()
|
|
309
|
+
const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
|
|
310
|
+
if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
|
|
311
|
+
if (!appendable.record.developer) return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
|
|
312
|
+
const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
|
|
313
|
+
if (type !== RECORDING_AUDIO_TYPE) return fail(415, `Clips are ${RECORDING_AUDIO_TYPE}`, "unsupported")
|
|
314
|
+
const bytes = await bytesOf(request, service.devLimits.clipBytes)
|
|
315
|
+
if (bytes instanceof Response) return bytes
|
|
316
|
+
if (!bytes.length) throw new BadInput("Empty clip")
|
|
317
|
+
const stored = await service.storeExtra(user.id, appendable.record, "clip", n, bytes)
|
|
318
|
+
if (stored.kind === "cap") return fail(413, "Developer mode's share of the storage is full: the clip is not saved", "limit")
|
|
319
|
+
if (stored.kind === "gone") return notFound()
|
|
320
|
+
if (stored.kind === "off") return fail(409, "This recording does not keep Developer mode's extras", "developer_off")
|
|
321
|
+
if (stored.kind === "failed") return fail(503, "The clip could not be stored; retry", "unavailable")
|
|
322
|
+
return Response.json({ clip: { n, bytes: stored.entry.bytes, hash: stored.entry.hash } }, { status: stored.fresh ? 201 : 200, headers: NO_STORE })
|
|
323
|
+
}),
|
|
324
|
+
route("GET", "/api/recordings/:id/clips/:n", async ({ params, user }) => {
|
|
325
|
+
const n = extraNumber(params.n, service.devLimits.clipsPerRecording, "clip")
|
|
326
|
+
return extraResponse(user.id, params.id!, clipKey(n), RECORDING_AUDIO_TYPE, `clip-${n}.wav`)
|
|
327
|
+
}),
|
|
328
|
+
route("GET", "/api/recordings/:id/replies/:n", async ({ params, user }) => {
|
|
329
|
+
const n = extraNumber(params.n, service.devLimits.repliesPerRecording, "reply")
|
|
330
|
+
return extraResponse(user.id, params.id!, replyKey(n), RECORDING_REPLY_TYPE, `reply-${n}.mp3`)
|
|
331
|
+
}),
|
|
278
332
|
route("GET", "/api/recordings/:id/export", async ({ params, user }) => ((await service.visible(user.id, params.id!)) ? exportOf(user.id, params.id!) : notFound())),
|
|
279
333
|
route("DELETE", "/api/recordings/:id", async ({ params, user }) => ((await service.deleteOne(user.id, params.id!)) ? new Response(null, { status: 204 }) : notFound())),
|
|
280
334
|
]
|
|
@@ -32,6 +32,8 @@ const object = (fields: Fields): FieldSpec => ({ t: "object", fields })
|
|
|
32
32
|
const L = RECORDING_LIMITS
|
|
33
33
|
const ref = object({ messageID: id, ordinal: int, "seq?": int, i: int })
|
|
34
34
|
const AUDIO_RESULTS = ["stored", "skipped-cap", "failed", "off"]
|
|
35
|
+
/** A device label (enumerateDevices, a track's label): ✂ like any text. */
|
|
36
|
+
const DEVICE_LABEL = 120
|
|
35
37
|
|
|
36
38
|
/** The console kinds (REC-02 emits exactly these fields). */
|
|
37
39
|
export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
@@ -42,13 +44,25 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
|
42
44
|
"segment.closed": { segment: int, bytes: int, durationMs: num, speechMs: num, forced: bool },
|
|
43
45
|
"stt.sent": { segment: int, bytes: int },
|
|
44
46
|
// `text` is optional: a failed request has none (failed names why).
|
|
45
|
-
"
|
|
47
|
+
// Feature "capture.v2" adds junkReason (junkTranscript's reason), held (the text held at that moment) and speechMs.
|
|
48
|
+
"stt.received": {
|
|
49
|
+
segment: int,
|
|
50
|
+
"text?": text(L.textChars),
|
|
51
|
+
"junk?": yes,
|
|
52
|
+
"empty?": yes,
|
|
53
|
+
"failed?": text(L.messageChars),
|
|
54
|
+
"audio?": oneOf(...AUDIO_RESULTS),
|
|
55
|
+
"junkReason?": label,
|
|
56
|
+
"held?": text(L.utteranceChars),
|
|
57
|
+
"speechMs?": num,
|
|
58
|
+
},
|
|
46
59
|
"utterance.held": { text: text(L.utteranceChars) },
|
|
47
60
|
"utterance.merged": { segments: list(int, 1000), pauseMs: num },
|
|
48
61
|
"utterance.sent": { text: text(L.utteranceChars), segments: list(int, 1000), reason: oneOf("finished", "hold", "max", "command", "typed", "smart", "smart-fallback"), holdMs: num },
|
|
49
62
|
"utterance.accepted": { inboxID: id },
|
|
50
63
|
"utterance.failed": { "status?": int, message: text(L.messageChars) },
|
|
51
|
-
"
|
|
64
|
+
// "mark": feature "capture.v2".
|
|
65
|
+
"utterance.command": { command: oneOf("pause", "interrupt", "mark"), text: text(L.textChars) },
|
|
52
66
|
heard: { inboxID: id },
|
|
53
67
|
// The contract's `seq?` (the say's seq) collides with the envelope's `seq`: a flat event carries only the latter.
|
|
54
68
|
"say.received": { messageID: id, ordinal: int, chars: int, sentences: int, muted: bool },
|
|
@@ -70,6 +84,56 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
|
|
|
70
84
|
"turn.timing": { "speechEnd?": num, "transcript?": num, "accepted?": num, "firstSay?": num, "firstByte?": num, firstAudio: num },
|
|
71
85
|
// Feature "turn.predict" (Smart Turn): a score (none when the model failed or was slow) and what it decided.
|
|
72
86
|
"turn.predict": { "p?": num, ms: num, silenceMs: num, decision: oneOf("send", "hold", "fallback", "timer-fallback"), "error?": text(L.messageChars) },
|
|
87
|
+
// Feature "capture.v2": what the detector almost took for speech, its 10 s summary, a slow STT answer, a bookmark.
|
|
88
|
+
"vad.near": {
|
|
89
|
+
ms: num,
|
|
90
|
+
"voicedMs?": num,
|
|
91
|
+
"peakProb?": num,
|
|
92
|
+
"peakDb?": num,
|
|
93
|
+
threshold: num,
|
|
94
|
+
echoGuard: bool,
|
|
95
|
+
mode: oneOf("silero", "energy"),
|
|
96
|
+
reason: oneOf("short-onset", "below-threshold", "voiced-ratio"),
|
|
97
|
+
},
|
|
98
|
+
"vad.summary": {
|
|
99
|
+
frames: int,
|
|
100
|
+
"maxProb?": num,
|
|
101
|
+
"p90Prob?": num,
|
|
102
|
+
"maxDb?": num,
|
|
103
|
+
"noiseFloorDb?": num,
|
|
104
|
+
framesAboveStart: int,
|
|
105
|
+
framesAboveSpeakingStart: int,
|
|
106
|
+
msEchoGuard: num,
|
|
107
|
+
"nearDropped?": int,
|
|
108
|
+
mode: oneOf("silero", "energy"),
|
|
109
|
+
sensitivity: oneOf("low", "normal", "high"),
|
|
110
|
+
},
|
|
111
|
+
"stt.slow": { segment: int, ms: num },
|
|
112
|
+
mark: { via: oneOf("voice", "button"), "note?": text(L.textChars) },
|
|
113
|
+
// Feature "dev.v1" (Developer mode; stored only in a developer recording, see ingest).
|
|
114
|
+
"vad.trace": {
|
|
115
|
+
reason: oneOf("speech.start", "speech.end", "vad.near"),
|
|
116
|
+
mode: oneOf("silero", "energy"),
|
|
117
|
+
frameMs: num,
|
|
118
|
+
// p100: probabilities × 100; db: dBFS, both rounded to integers. `guard`: one "0"/"1" per frame (echo guard up).
|
|
119
|
+
scale: oneOf("p100", "db"),
|
|
120
|
+
values: list(int, 128),
|
|
121
|
+
threshold: int,
|
|
122
|
+
guard: { t: "str", max: 128 },
|
|
123
|
+
},
|
|
124
|
+
"audio.clip": { clip: int, for: object({ kind: oneOf("vad.near", "mark"), seq: int }), preMs: num, postMs: num, bytes: int },
|
|
125
|
+
"device.info": {
|
|
126
|
+
reason: oneOf("start", "change"),
|
|
127
|
+
"outputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
|
|
128
|
+
"inputs?": list(object({ kind: label, "label?": text(DEVICE_LABEL) }), 20),
|
|
129
|
+
"track?": object({ "echoCancellation?": bool, "noiseSuppression?": bool, "autoGainControl?": bool, "sampleRate?": num, "channelCount?": num, "label?": text(DEVICE_LABEL) }),
|
|
130
|
+
"network?": object({ "effectiveType?": label, "rtt?": num, "downlink?": num, "saveData?": bool }),
|
|
131
|
+
"visibility?": label,
|
|
132
|
+
"wakeLock?": bool,
|
|
133
|
+
"battery?": object({ "level?": num, "charging?": bool }),
|
|
134
|
+
},
|
|
135
|
+
"resource.timing": { route: oneOf("transcribe", "speak", "events", "utterance", "recording"), n: int, medianMs: num, maxMs: num, "medianTtfbMs?": num, "bytes?": int },
|
|
136
|
+
"console.log": { level: oneOf("error", "warn", "unhandled"), message: text(L.messageChars), "count?": int, "source?": label },
|
|
73
137
|
"decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
|
|
74
138
|
"clock.sync": { t0: num, t1: num, hubAt: num },
|
|
75
139
|
error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
|