@huynle/hub 0.10.1 → 0.11.1

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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -8
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +851 -466
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/grants-mcp.ts +65 -0
  12. package/packages/cli/src/mcp-operations.ts +49 -0
  13. package/packages/cli/src/mcp.ts +26 -4
  14. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  15. package/packages/cli/src/team-intake-mcp.ts +2 -1
  16. package/packages/cli/src/teams-mcp.ts +9 -3
  17. package/packages/codex/package.json +1 -1
  18. package/packages/codex/src/sessions.ts +14 -3
  19. package/packages/node/package.json +1 -1
  20. package/packages/preset/package.json +1 -1
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/protocol/schema/api/v1/_root.json +119 -0
  23. package/packages/protocol/schema/api/v1/account.json +419 -0
  24. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  25. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  26. package/packages/protocol/schema/api/v1/events.json +679 -0
  27. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  28. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  29. package/packages/protocol/schema/api/v1/speech.json +492 -0
  30. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  31. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  32. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  33. package/packages/protocol/src/api-v1.ts +8 -0
  34. package/packages/protocol/src/index.ts +2 -0
  35. package/packages/protocol/src/teams.ts +16 -0
  36. package/packages/protocol/src/voice-recording.ts +39 -2
  37. package/packages/runtime-openshell/image/entry.sh +3 -1
  38. package/packages/runtime-openshell/image/start.sh +3 -1
  39. package/packages/runtime-openshell/package.json +1 -1
  40. package/packages/runtime-sdk/package.json +1 -1
  41. package/packages/sdk/package.json +1 -1
  42. package/packages/sdk/src/app-front.ts +2 -2
  43. package/packages/sdk/src/ask-fields.ts +76 -0
  44. package/packages/sdk/src/client.ts +24 -2
  45. package/packages/sdk/src/envelope.ts +3 -2
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/team-intake.ts +6 -0
  48. package/packages/sdk/src/teams.ts +4 -0
  49. package/packages/sdk/src/types.ts +36 -4
  50. package/packages/sdk/src/wake.ts +9 -4
  51. package/packages/spoke-sdk/package.json +1 -1
  52. package/src/central/agent/agent-limits.ts +185 -0
  53. package/src/central/agent/app-decisions.ts +6 -5
  54. package/src/central/agent/decisions.ts +136 -9
  55. package/src/central/agent/delivery.ts +164 -22
  56. package/src/central/agent/errors.ts +2 -1
  57. package/src/central/agent/events.ts +20 -2
  58. package/src/central/agent/grant-routes.ts +14 -1
  59. package/src/central/agent/grants.ts +23 -0
  60. package/src/central/agent/launch-engine.ts +9 -6
  61. package/src/central/agent/message-limits.ts +6 -0
  62. package/src/central/agent/message-routes.ts +33 -6
  63. package/src/central/agent/messages.ts +7 -0
  64. package/src/central/agent/placement.ts +4 -2
  65. package/src/central/agent/read-routes.ts +5 -0
  66. package/src/central/agent/routes.ts +13 -0
  67. package/src/central/agent/subscriptions.ts +15 -4
  68. package/src/central/api-v1.ts +115 -0
  69. package/src/central/artifacts/store.ts +4 -2
  70. package/src/central/concierge/board-results.ts +2 -1
  71. package/src/central/concierge/notifier.ts +4 -2
  72. package/src/central/config.ts +24 -3
  73. package/src/central/health/classify.ts +209 -0
  74. package/src/central/http.ts +380 -36
  75. package/src/central/recording/export.ts +57 -3
  76. package/src/central/recording/index.ts +236 -17
  77. package/src/central/recording/ingest.ts +11 -2
  78. package/src/central/recording/routes.ts +61 -7
  79. package/src/central/recording/schema.ts +68 -3
  80. package/src/central/recording/settings.ts +19 -5
  81. package/src/central/recording/store.ts +19 -0
  82. package/src/central/recording/taps.ts +45 -1
  83. package/src/central/registry.ts +34 -3
  84. package/src/central/speech/openai.ts +4 -4
  85. package/src/central/speech/openrouter.ts +4 -4
  86. package/src/central/speech/provider.ts +32 -1
  87. package/src/central/speech/service.ts +6 -5
  88. package/src/central/teams/budgets.ts +144 -0
  89. package/src/central/teams/hop-stalls.ts +80 -0
  90. package/src/central/teams/intake.ts +38 -6
  91. package/src/central/teams/involvement.ts +95 -0
  92. package/src/central/teams/item-decisions.ts +60 -0
  93. package/src/central/teams/item-eligibility.ts +35 -0
  94. package/src/central/teams/item-stalls.ts +87 -0
  95. package/src/central/teams/loops.ts +40 -0
  96. package/src/central/teams/policy.ts +10 -0
  97. package/src/central/teams/progress.ts +154 -0
  98. package/src/central/teams/routes.ts +2 -0
  99. package/src/central/teams/seats.ts +3 -3
  100. package/src/central/teams/staffing.ts +5 -0
  101. package/src/central/teams/store.ts +20 -1
  102. package/src/central/teams/validation.ts +16 -2
  103. package/src/central/workspaces/coordinator.ts +3 -1
  104. package/src/spoke/hub-tool.ts +26 -11
  105. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
  106. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
  107. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  108. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
  109. package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
  110. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
  111. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  112. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  113. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  114. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  115. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  116. package/web/dist/assets/live-B2IfKm37.js +5 -0
  117. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  118. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  119. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
  120. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
  121. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
  122. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  123. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  124. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  125. package/web/dist/index.html +5 -5
  126. package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
  127. package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
  128. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  129. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -5,7 +5,7 @@
5
5
  // entries are reached (the root manifest of "all" reads them once more beforehand).
6
6
  import { RECORDING_AUDIO_TYPE, RECORDING_FORMAT, RECORDING_FORMAT_VERSION, type RecordingEvent } from "../../protocol"
7
7
  import { clockAlignment, type ClockSample, type ClockSummary } from "./clock"
8
- import { type AudioEntry, blobTenant, type RecordingRecord, type RecordingStore } from "./store"
8
+ import { type AudioEntry, blobTenant, type ExtraEntry, isPageKey, type RecordingRecord, type RecordingStore } from "./store"
9
9
  import type { ZipEntry } from "./zip"
10
10
 
11
11
  export interface ExportOptions {
@@ -31,6 +31,8 @@ export interface SegmentManifest {
31
31
  const decoder = new TextDecoder()
32
32
  const encoder = new TextEncoder()
33
33
  const audioFile = (n: number) => `audio/${String(n).padStart(6, "0")}.wav`
34
+ const clipFile = (n: number) => `clips/${String(n).padStart(6, "0")}.wav`
35
+ const replyFile = (n: number) => `replies/${String(n).padStart(6, "0")}.mp3`
34
36
 
35
37
  /** A hub event ID "<podTag>.<n>" as a sort key. */
36
38
  const idKey = (id: unknown): [string, number] => {
@@ -52,6 +54,9 @@ interface Loaded {
52
54
  readonly events: RecordingEvent[]
53
55
  readonly clock: ClockSummary
54
56
  readonly audio: Map<number, AudioEntry>
57
+ /** Developer mode: clips (k…) and reply audio (r…) by number. */
58
+ readonly clips: Map<number, ExtraEntry>
59
+ readonly replies: Map<number, ExtraEntry>
55
60
  }
56
61
 
57
62
  /** Every stored event of a recording (pages that are missing or unreadable are skipped), aligned and sorted. */
@@ -59,11 +64,16 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
59
64
  const index = await store.index<Record<string, unknown>>(userID, record.id)
60
65
  const raw: RecordingEvent[] = []
61
66
  const audio = new Map<number, AudioEntry>()
67
+ const clips = new Map<number, ExtraEntry>()
68
+ const replies = new Map<number, ExtraEntry>()
62
69
  for (const entry of index) {
63
70
  if (entry.key.startsWith("a")) {
64
71
  audio.set(Number(entry.key.slice(1)), entry.value as unknown as AudioEntry)
65
72
  continue
66
73
  }
74
+ if (entry.key.startsWith("k")) clips.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
75
+ if (entry.key.startsWith("r")) replies.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
76
+ if (!isPageKey(entry.key)) continue
67
77
  const hash = entry.value.hash
68
78
  if (typeof hash !== "string") continue
69
79
  const blob = await store.blobs.get(blobTenant(userID), hash).catch(() => undefined)
@@ -82,7 +92,36 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
82
92
  const clock = clockAlignment(samples, record.anchor)
83
93
  const events = raw.map((e) => ({ ...e, ts: e.src === "console" ? clock.ts(e.t ?? 0) : (e.at ?? 0) }) as RecordingEvent)
84
94
  events.sort(compare)
85
- return { events, clock: clock.summary, audio }
95
+ return { events, clock: clock.summary, audio, clips, replies }
96
+ }
97
+
98
+ const sha = (hash: string) => hash.replace(/^sha256:/, "")
99
+
100
+ /** Developer mode: clips[] joined with their audio.clip events (what each belongs to), replies[] with their ref. */
101
+ function extras(loaded: Loaded): { clips: Record<string, unknown>[]; replies: Record<string, unknown>[] } {
102
+ const clipEvents = new Map<number, RecordingEvent>()
103
+ for (const e of loaded.events) if (e.src === "console" && e.kind === "audio.clip" && typeof e.clip === "number") clipEvents.set(e.clip, e)
104
+ const sentences = new Map<string, string>()
105
+ for (const e of loaded.events) {
106
+ const ref = e.ref as ExtraEntry["ref"]
107
+ if (e.src === "console" && e.kind === "tts.sentence.queued" && ref && typeof e.text === "string") sentences.set(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`, e.text)
108
+ }
109
+ const clips = [...loaded.clips].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
110
+ const e = clipEvents.get(n)
111
+ return {
112
+ n,
113
+ file: clipFile(n),
114
+ bytes: entry.bytes,
115
+ sha256: sha(entry.hash),
116
+ ...(e ? { for: e.for, preMs: e.preMs, postMs: e.postMs, ts: e.ts } : {}),
117
+ }
118
+ })
119
+ const replies = [...loaded.replies].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
120
+ const ref = entry.ref
121
+ const text = ref ? sentences.get(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`) : undefined
122
+ return { n, file: replyFile(n), bytes: entry.bytes, sha256: sha(entry.hash), ...(ref ? { ref } : {}), ...(text !== undefined ? { text } : {}), at: entry.at }
123
+ })
124
+ return { clips, replies }
86
125
  }
87
126
 
88
127
  const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
@@ -176,7 +215,15 @@ function manifestEntry(record: RecordingRecord, loaded: Loaded): Record<string,
176
215
  clock: loaded.clock,
177
216
  audio: { format: RECORDING_AUDIO_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16 },
178
217
  segments: list,
179
- counts: { events: loaded.events.length, consoleGaps: record.consoleGaps, redactions: record.redactions, segmentsRedacted: redacted, segmentsSkipped: skipped },
218
+ ...(record.developer || loaded.clips.size || loaded.replies.size ? { developer: record.developer === true, ...extras(loaded) } : {}),
219
+ counts: {
220
+ events: loaded.events.length,
221
+ consoleGaps: record.consoleGaps,
222
+ redactions: record.redactions,
223
+ segmentsRedacted: redacted,
224
+ segmentsSkipped: skipped,
225
+ ...(loaded.clips.size || loaded.replies.size || record.developer ? { clips: loaded.clips.size, replies: loaded.replies.size } : {}),
226
+ },
180
227
  }
181
228
  }
182
229
 
@@ -200,6 +247,13 @@ async function* recordingEntries(store: RecordingStore, userID: string, record:
200
247
  if (!blob) continue
201
248
  yield { name: `${prefix}${audioFile(n)}`, body: async () => blob.body }
202
249
  }
250
+ for (const [files, name] of [[loaded.clips, clipFile], [loaded.replies, replyFile]] as const) {
251
+ for (const [n, extra] of [...files].sort((a, b) => a[0] - b[0])) {
252
+ const blob = await store.blobs.get(blobTenant(userID), extra.hash).catch(() => undefined)
253
+ if (!blob) continue
254
+ yield { name: `${prefix}${name(n)}`, body: async () => blob.body }
255
+ }
256
+ }
203
257
  }
204
258
 
205
259
  /** The zip entries of one recording (at the root) or of several (root manifest + one folder each). */
@@ -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
- /** The answer's x-hub-recording-audio after STT answered (the write of a small body is done by then). */
98
- finish(text: string, providerMs: number): Promise<RecordingAudioResult>
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({ hub: (userID, sessionID, kind, fields) => this.hub(userID, sessionID, kind, fields), mayRecord: (userID) => this.mayRecord(userID) })
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
- async setEnabled(userID: string, deviceID: string, enabled: boolean, expectedRevision: number): Promise<{ ok: true; settings: RecordingSettings } | { ok: false; currentRevision: number }> {
243
- const written = await this.settings.set(userID, enabled, expectedRevision)
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
- await this.options.audit({ actor: userID, action: enabled ? "recording.enable" : "recording.disable" })
248
- if (!enabled) for (const r of await this.store.list(userID)) if (r.state === "active") await this.endByHub(userID, r.id, "disabled")
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
- if (!(await this.settings.get(userID)).enabled) return { kind: "off" }
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
- /** /api/speak with x-hub-recording (and x-hub-tts-ref): tts.synth timing; the reply audio is never stored. */
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 done = (firstByteMs: number | undefined) => this.writer.add(userID, id, "tts.synth", { ...(ref ? { ref } : {}), chars, ...(firstByteMs !== undefined ? { firstByteMs } : {}), ms: this.now() - started })
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 })
@@ -537,7 +735,8 @@ export class RecordingService {
537
735
  if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
538
736
  this.hub(userID, sessionID, "decision.opened", { decisionID: decision.id, from: partyKey(from), tier: decision.tier, urgency: decision.urgency, options: decision.options.map((o) => o.id), text: decision.question })
539
737
  } else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
540
- this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), via: decision.answer.via ?? "tap" })
738
+ const picks = decision.answer.picks && Object.entries(decision.answer.picks).map(([group, option]) => ({ group, option }))
739
+ this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), ...(picks ? { picks } : {}), via: decision.answer.via ?? "tap" })
541
740
  }
542
741
  }
543
742
 
@@ -656,3 +855,23 @@ export type StartResult =
656
855
  | { readonly kind: "existing" | "created"; readonly record: RecordingRecord }
657
856
 
658
857
  export { summaryOf }
858
+
859
+ const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
860
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0))
861
+ let at = 0
862
+ for (const c of chunks) {
863
+ out.set(c, at)
864
+ at += c.length
865
+ }
866
+ return out
867
+ }
868
+
869
+ /** stt.result's provider fields (capture.v2): the upstream HTTP status, response bytes and time to headers, when known. */
870
+ function providerStats(stats: TranscribeStats | undefined): Record<string, number> {
871
+ const out: Record<string, number> = {}
872
+ const finite = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x)
873
+ if (finite(stats?.status)) out.httpStatus = stats.status
874
+ if (finite(stats?.bytes)) out.responseBytes = stats.bytes
875
+ if (finite(stats?.headersMs)) out.headersMs = Math.round(stats.headersMs)
876
+ return out
877
+ }
@@ -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
- const records = fresh.map((e) => consoleRecord(e, at))
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