@huynle/hub 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +35 -7
  6. package/packages/cli/package.json +1 -1
  7. package/packages/codex/package.json +1 -1
  8. package/packages/node/package.json +1 -1
  9. package/packages/preset/package.json +1 -1
  10. package/packages/protocol/package.json +1 -1
  11. package/packages/protocol/src/index.ts +9 -1
  12. package/packages/protocol/src/voice-recording.ts +107 -0
  13. package/packages/runtime-openshell/package.json +1 -1
  14. package/packages/runtime-sdk/package.json +1 -1
  15. package/packages/sdk/package.json +1 -1
  16. package/packages/spoke-sdk/package.json +1 -1
  17. package/src/central/agent/routes.ts +7 -1
  18. package/src/central/artifacts/blob-store.ts +6 -1
  19. package/src/central/artifacts/blob-support.ts +3 -1
  20. package/src/central/concierge/board-render.ts +34 -11
  21. package/src/central/concierge/index.ts +1 -1
  22. package/src/central/concierge/notifier.ts +2 -1
  23. package/src/central/config.ts +4 -0
  24. package/src/central/csp.ts +55 -0
  25. package/src/central/http.ts +85 -9
  26. package/src/central/profiles/content.ts +16 -0
  27. package/src/central/recording/clock.ts +76 -0
  28. package/src/central/recording/concierge-taps.ts +104 -0
  29. package/src/central/recording/export.ts +215 -0
  30. package/src/central/recording/index.ts +658 -0
  31. package/src/central/recording/ingest.ts +209 -0
  32. package/src/central/recording/retention.ts +71 -0
  33. package/src/central/recording/routes.ts +281 -0
  34. package/src/central/recording/schema.ts +232 -0
  35. package/src/central/recording/settings.ts +35 -0
  36. package/src/central/recording/store.ts +316 -0
  37. package/src/central/recording/taps.ts +105 -0
  38. package/src/central/recording/zip.ts +138 -0
  39. package/src/central/registry.ts +68 -3
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/spoke/session-context.ts +28 -8
  42. package/src/spoke/spoke.ts +3 -0
  43. package/src/spoke/tracker.ts +6 -1
  44. package/src/status.ts +31 -5
  45. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
  46. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
  47. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  48. package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
  49. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  50. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
  51. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  52. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  53. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  54. package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
  55. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  56. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
  57. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
  60. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  61. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  62. package/web/dist/index.html +5 -16
  63. package/web/dist/theme-boot.js +12 -0
  64. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  65. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  66. package/web/dist/assets/index-BabrNDB3.css +0 -1
  67. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -19,7 +19,11 @@
19
19
  // Lines and the total are cut on code point boundaries, and a cut inside a frame still closes it ("…»").
20
20
  // The total is at most CONCIERGE_BOARD_LIMITS.renderedChars: overflow drops session lines from the lowest priority
21
21
  // (the end of watchlist order) up, then Decision lines from the oldest, and says how many lines were dropped.
22
+ // (V-01b verify) IDs are rendered only when they match their kind's pattern (ses_[A-Za-z0-9]+, dec_[A-Za-z0-9]+,
23
+ // blocker IDs): a session or Decision with any other ID loses its line, other bad IDs leave their list. Lone
24
+ // surrogates are removed from all of the text.
22
25
  import { CONCIERGE_BOARD_LIMITS, INTENT_MAX, type ConciergeBoard, type ConciergeBoardDecision, type ConciergeBoardSection, type ConciergeBoardSession } from "../../../packages/protocol/src/concierge"
26
+ import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
23
27
  import { sanitizeLabel } from "../agent/envelope"
24
28
 
25
29
  const MIN = 60_000
@@ -44,11 +48,24 @@ export interface RenderOptions {
44
48
  */
45
49
  const HIDDEN = /[\u00ad\u115f\u1160\u180e\u3164\uffa0\u{e0000}-\u{e007f}]/gu
46
50
 
47
- /** Board text: hidden characters dropped, then sanitised (sanitizeLabel), one line, at most `max` code points. */
51
+ /** (V-01b verify) Lone surrogates (paired ones are one code point under the u flag): a strict provider rejects them. */
52
+ const LONE_SURROGATE = /[\ud800-\udfff]/gu
53
+
54
+ /** Board text: hidden characters and lone surrogates dropped, then sanitised (sanitizeLabel), one line, at most `max` code points. */
48
55
  export function boardText(text: string, max: number): string {
49
- return sanitizeLabel(text.replace(HIDDEN, ""), max)
56
+ return sanitizeLabel(text.replace(HIDDEN, "").replace(LONE_SURROGATE, ""), max)
50
57
  }
51
58
 
59
+ /**
60
+ * (V-01b verify) Hub and harness IDs by kind (like the notifier's): an ID that does not match its kind's pattern is
61
+ * never rendered (its line, list entry or blocker is dropped), so no ID can carry a line break or text of its own.
62
+ */
63
+ const DECISION_ID = /^dec_[A-Za-z0-9]{1,64}$/
64
+ /** Harness blocker IDs (OpenCode per_…, Codex per_<connection>_n…): the board builder's own pattern (board.ts). */
65
+ const BLOCKER_ID = /^[A-Za-z0-9_-]{1,64}$/
66
+ const isDecisionID = (id: unknown): id is string => typeof id === "string" && DECISION_ID.test(id)
67
+ const isBlockerID = (id: unknown): id is string => typeof id === "string" && BLOCKER_ID.test(id)
68
+
52
69
  /** Untrusted text: board text, guillemets neutralised, cut to `max` characters (code points), framed in «…». */
53
70
  export function frame(text: string, max: number): string {
54
71
  const chars = Array.from(boardText(text, Number.MAX_SAFE_INTEGER).replace(/«/g, "‹").replace(/»/g, "›"))
@@ -118,10 +135,11 @@ function sessionLine(s: ConciergeBoardSession, at: number): string {
118
135
  if (!s.blockers.reported) parts.push("blockers not reported")
119
136
  else if (s.blockers.count > 0) {
120
137
  // (CON-13) Each pending permission with the hub's inGrant (never the model's call), each form by its ID.
121
- const pending = (s.blockers.pending ?? []).map((b) => (b.kind === "permission" ? `permission ${b.id} ${sanitizeLabel(b.action, 40)}: inGrant=${b.inGrant ? "yes" : "no"} auto=${b.auto ? "yes" : "no"}` : `form ${b.id}`))
138
+ const pending = (s.blockers.pending ?? []).filter((b) => isBlockerID(b.id)).map((b) => (b.kind === "permission" ? `permission ${b.id} ${sanitizeLabel(b.action, 40)}: inGrant=${b.inGrant ? "yes" : "no"} auto=${b.auto ? "yes" : "no"}` : `form ${b.id}`))
122
139
  parts.push(`blockers ${s.blockers.count}${s.blockers.kinds.length ? `: ${s.blockers.kinds.map((k) => sanitizeLabel(k, 40)).join(", ")}` : ""}${pending.length ? ` (${pending.join("; ")})` : ""}`)
123
140
  }
124
- if (s.decisions.length) parts.push(`decisions ${s.decisions.join(", ")}`)
141
+ const decisions = s.decisions.filter(isDecisionID)
142
+ if (decisions.length) parts.push(`decisions ${decisions.join(", ")}`)
125
143
  if (s.lastResult) parts.push(`last ${s.lastResult.outcome} ${span(at - s.lastResult.at)} ago`)
126
144
  if (s.intent) parts.push(`intent (${s.intent.source}) ${frame(s.intent.text, INTENT_MAX)}`)
127
145
  // (CON-11) "no intent": the title shows only because no intent is set (one the user cleared reads the same).
@@ -131,7 +149,8 @@ function sessionLine(s: ConciergeBoardSession, at: number): string {
131
149
  }
132
150
 
133
151
  function decisionLine(d: ConciergeBoardDecision, at: number): string {
134
- const parts = [`${d.id} from ${frame(d.from, LABEL_CHARS)}${d.about.length ? ` about ${d.about.join(", ")}` : ""}`, `urgency ${d.urgency}`, `tier ${d.tier}`, `asked ${span(at - d.at)} ago`]
152
+ const about = d.about.filter(isHubSessionID)
153
+ const parts = [`${d.id} from ${frame(d.from, LABEL_CHARS)}${about.length ? ` about ${about.join(", ")}` : ""}`, `urgency ${d.urgency}`, `tier ${d.tier}`, `asked ${span(at - d.at)} ago`]
135
154
  if (d.deadline !== undefined) parts.push(d.deadline >= at ? `deadline in ${span(d.deadline - at)}` : `deadline passed ${span(at - d.deadline)} ago`)
136
155
  if (d.armed) parts.push("armed")
137
156
  return cutLine(`- ${parts.join(" · ")}`)
@@ -152,20 +171,24 @@ export function renderBoard(board: ConciergeBoard, options: RenderOptions = {}):
152
171
  const { counts } = board
153
172
  if (board.sessions.status !== "ok") sessionsHead = `Sessions: ${unavailable(board.sessions)}`
154
173
  else {
155
- const shown = board.sessions.items.length
174
+ const items = board.sessions.items.filter((s) => isHubSessionID(s.sessionID))
175
+ const shown = items.length
156
176
  const total = Math.max(counts.watched, shown)
157
177
  sessionsHead = `Sessions (${total} watched${total > shown ? `, ${shown} shown` : ""}):${shown === 0 ? " none" : ""}`
158
- sessionLines = board.sessions.items.map((s) => sessionLine(s, board.at))
178
+ sessionLines = items.map((s) => sessionLine(s, board.at))
159
179
  if (total > shown) sessionTail.push(`(${total - shown} more watched sessions not shown)`)
160
180
  }
161
181
 
162
182
  let decisionsHead: string
163
183
  let decisionLines: string[] = []
164
184
  if (board.decisions.status !== "ok") decisionsHead = `Open decisions: ${unavailable(board.decisions)}`
165
- else if (board.decisions.items.length === 0) decisionsHead = "Open decisions: none"
166
185
  else {
167
- decisionsHead = `Open decisions (${Math.max(counts.openDecisions, board.decisions.items.length)}):`
168
- decisionLines = board.decisions.items.map((d) => decisionLine(d, board.at))
186
+ const items = board.decisions.items.filter((d) => isDecisionID(d.id))
187
+ if (items.length === 0) decisionsHead = "Open decisions: none"
188
+ else {
189
+ decisionsHead = `Open decisions (${Math.max(counts.openDecisions, items.length)}):`
190
+ decisionLines = items.map((d) => decisionLine(d, board.at))
191
+ }
169
192
  }
170
193
 
171
194
  const machines =
@@ -186,5 +209,5 @@ export function renderBoard(board: ConciergeBoard, options: RenderOptions = {}):
186
209
  dropped++
187
210
  text = compose(dropped)
188
211
  }
189
- return cutUnits(text, max)
212
+ return cutUnits(text, max).replace(LONE_SURROGATE, "")
190
213
  }
@@ -23,7 +23,7 @@ export interface ConciergePorts {
23
23
  * Sends a message to the user's current Concierge session as app:concierge (Delivery.send; the inbound rule admits
24
24
  * it). Throws not_found when there is no current session.
25
25
  */
26
- readonly send: (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
26
+ readonly send: (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean; readonly batch?: { readonly id: string; readonly kind: string; readonly budget?: string; readonly lines: number } }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
27
27
  readonly entitlements: ConciergeEntitlements
28
28
  /** Every user of the hub (leader loops). */
29
29
  readonly users: () => Promise<Iterable<string>>
@@ -937,7 +937,8 @@ export class ConciergeNotifierImpl implements ConciergeNotifier {
937
937
  try {
938
938
  if (!this.leading()) return
939
939
  // Queued, never steered: no `urgent`.
940
- result = await this.within(this.ports.send(userID, text, { wake: batch.wake }), this.limits.sendTimeoutMs)
940
+ // (REC-01) The batch's facts ride along for a recording of the Concierge (wake.arrived): IDs, enums, counts.
941
+ result = await this.within(this.ports.send(userID, text, { wake: batch.wake, batch: { id: `b${batch.seq}`, kind: batch.kind, budget: batch.budget, lines: batch.lines.length } }), this.limits.sendTimeoutMs)
941
942
  } catch (cause) {
942
943
  failure = cause instanceof AgentError && REFUSED.has(cause.code) ? "refused" : "uncertain"
943
944
  this.log(`[hub] concierge notifier: ${batch.id} attempt ${batch.attempts} ${failure}: ${(cause as Error).message}`)
@@ -9,6 +9,7 @@ import { isLoopbackHost } from "../loopback"
9
9
  import { formatPattern, parsePattern } from "./agent/policy"
10
10
  import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
11
11
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
12
+ import { cspMode, type CspMode } from "./csp"
12
13
 
13
14
  export const GOOGLE_ISSUER = "https://accounts.google.com"
14
15
 
@@ -57,6 +58,8 @@ export interface CentralConfig {
57
58
  readonly turnTimeoutMs: number
58
59
  /** VAPID subject (mailto: or https: URL) sent to push services. */
59
60
  readonly vapidSubject: string
61
+ /** The console's Content-Security-Policy: enforced, or (HUB_CSP=report-only) only reported (./csp.ts). */
62
+ readonly csp?: CspMode
60
63
  /** Speech settings from hub.json (below the phone's saved settings, above the env). */
61
64
  readonly speech?: StoredSpeech
62
65
  readonly auth: AuthConfig
@@ -486,6 +489,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
486
489
  turnTimeoutMs: int(env.HUB_TURN_TIMEOUT_MS, 600_000),
487
490
  // Push services want a contact: an https URL or mailto. The public URL works for hosted hubs.
488
491
  vapidSubject: env.HUB_VAPID_SUBJECT || (publicURL.startsWith("https://") ? publicURL : "mailto:hub@localhost"),
492
+ csp: cspMode(env.HUB_CSP),
489
493
  ...(file.speech ? { speech: file.speech } : {}),
490
494
  auth,
491
495
  agentsNever: [...(file.agents?.never ?? [])],
@@ -0,0 +1,55 @@
1
+ // The console's Content-Security-Policy (CSP-01), sent with the console HTML (src/central/http.ts staticFile; the
2
+ // mock hub test/web-mock-hub.ts sends the same). Nothing inline, no eval, every source the hub's own origin:
3
+ // - scripts and styles come only from the built bundle (web/dist: hashed assets, /theme-boot.js); the console has
4
+ // no inline <script>, <style> or style="…" (Solid's style objects set CSSOM properties, which CSP allows), and the
5
+ // <style> that 3d-force-graph would inject is stripped at build time (web/vite.config.ts, stripStyleInject);
6
+ // - images may be data: (attachments in a transcript) or blob: (pasted images, artifact previews);
7
+ // - media may be blob: (Voice's whole-clip and MediaSource playback, the screen-off silence loop);
8
+ // - connect-src 'self' covers fetch, the SSE stream and same-origin WebSockets (CSP3: 'self' matches ws/wss);
9
+ // - frames only from this origin: the isolated artifact host (/artifact-host/v1/…, docs/artifact-host.md), which
10
+ // sends its own sandboxing policy; nothing may frame the console (frame-ancestors 'none');
11
+ // - the Silero VAD worker is a same-origin module script; its .wasm compiles in the worker, under the worker's own
12
+ // (absent) policy, so script-src needs no 'wasm-unsafe-eval';
13
+ // - forms submit only here (the console's forms are handled in script; OIDC sign-in is a navigation, not a form).
14
+ // HUB_CSP=report-only sends the same policy as Content-Security-Policy-Report-Only (violations are only logged in
15
+ // the browser's console): an escape hatch for a deployment that hits a violation, not a way to turn it off.
16
+ export type CspMode = "enforce" | "report-only"
17
+
18
+ const DIRECTIVES: ReadonlyArray<readonly [string, string]> = [
19
+ ["default-src", "'self'"],
20
+ ["script-src", "'self'"],
21
+ ["style-src", "'self'"],
22
+ ["img-src", "'self' data: blob:"],
23
+ ["media-src", "'self' blob:"],
24
+ ["font-src", "'self'"],
25
+ ["connect-src", "'self'"],
26
+ ["worker-src", "'self'"],
27
+ ["manifest-src", "'self'"],
28
+ ["frame-src", "'self'"],
29
+ ["frame-ancestors", "'none'"],
30
+ ["base-uri", "'none'"],
31
+ ["form-action", "'self'"],
32
+ ["object-src", "'none'"],
33
+ ]
34
+
35
+ export const CONSOLE_CSP = DIRECTIVES.map(([name, sources]) => `${name} ${sources}`).join("; ")
36
+
37
+ /** HUB_CSP: unset or "enforce" → enforce; "report-only" → report only. Anything else is a configuration error. */
38
+ export function cspMode(value: string | undefined): CspMode {
39
+ const mode = value?.trim().toLowerCase() || "enforce"
40
+ if (mode === "enforce" || mode === "report-only") return mode
41
+ throw new Error(`HUB_CSP must be "enforce" or "report-only", got ${JSON.stringify(value)}`)
42
+ }
43
+
44
+ /** The header that carries the console policy in this mode. */
45
+ export const cspHeader = (mode: CspMode): string => (mode === "report-only" ? "content-security-policy-report-only" : "content-security-policy")
46
+
47
+ /** A policy as directive → sources (tests, the e2e checks). */
48
+ export function parseCsp(policy: string): Record<string, string[]> {
49
+ const out: Record<string, string[]> = {}
50
+ for (const part of policy.split(";")) {
51
+ const [name, ...sources] = part.trim().split(/\s+/)
52
+ if (name) out[name.toLowerCase()] = sources
53
+ }
54
+ return out
55
+ }
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
8
8
  import path from "node:path"
9
9
  import { promisify } from "node:util"
10
10
  import { brotliCompress, constants as zlib, gzip } from "node:zlib"
11
- import { formAnswerProblem, NOT_A_USER_MESSAGE, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
11
+ import { formAnswerProblem, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage } from "../protocol"
12
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
13
13
  import { speakable } from "../speakable"
14
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -71,9 +71,14 @@ import { CheckpointStore, journalCursors } from "./workspaces/checkpoints"
71
71
  import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinator"
72
72
  import { artifactJournalEvent } from "./artifacts/events"
73
73
  import { ArtifactMaintenance } from "./artifacts/maintenance"
74
+ import { MemoryBlobStore } from "./backend/memory-blobs"
75
+ import { RecordingService } from "./recording"
76
+ import { recordingRoutes } from "./recording/routes"
77
+ import { wrapConciergeSend } from "./recording/concierge-taps"
74
78
  import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
75
79
  import { ArtifactStore } from "./artifacts/store"
76
80
  import { artifactHost, isArtifactHostPath } from "./artifacts/host"
81
+ import { CONSOLE_CSP, cspHeader } from "./csp"
77
82
  import { ArtifactHostCaps } from "./artifacts/host-caps"
78
83
  import { artifactWorkspacesFromState } from "./artifacts/workspaces"
79
84
  import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
@@ -196,6 +201,8 @@ export interface Central {
196
201
  readonly agentEventWriters: EventWriters
197
202
  /** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
198
203
  readonly intentRetention: IntentRetention
204
+ /** (REC-01) Voice & Concierge recordings (developer): the owner-only store, taps, export and retention. */
205
+ readonly recordings: RecordingService
199
206
  /** The JSON API's route table (method, path, access), for audits and tests. */
200
207
  readonly routes: readonly RouteInfo[]
201
208
  /** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
@@ -829,6 +836,8 @@ export function startCentral(options: CentralOptions): Central {
829
836
  const feed = new MessageFeed()
830
837
  // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
831
838
  if (config.cluster) feed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] message feed did not attach: ${(cause as Error).message}`))
839
+ /** (REC-01) The recording service (set below, once its ports exist); the Decision hook reads it late. */
840
+ let recordings: RecordingService | undefined
832
841
  /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
833
842
  let conciergeTierHooks: ConciergeTiers | undefined
834
843
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
@@ -842,6 +851,8 @@ export function startCentral(options: CentralOptions): Central {
842
851
  changed: (userID, decision) => {
843
852
  broadcast({ type: "decision", decision }, { tenant: userID })
844
853
  conciergeTierHooks?.decisionChanged?.(userID, decision)
854
+ // (REC-01) decision.opened / decision.answered of a recorded session (best effort).
855
+ recordings?.decisionChanged(userID, decision)
845
856
  },
846
857
  // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
847
858
  // (CON-06) Concierge proposals run their verb as the user (all set up below).
@@ -1082,6 +1093,34 @@ export function startCentral(options: CentralOptions): Central {
1082
1093
  // Every stored agent message also goes to the user's console streams (live transcript rows).
1083
1094
  feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
1084
1095
  const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID) })
1096
+ // (REC-01) Voice & Concierge recordings (docs/voice-recording.md): records in the StateStore, bytes in the BlobStore
1097
+ // under the tenant rec~<userID> (never the artifact tenant: artifact recovery would delete them); the retention sweep
1098
+ // runs on the voice-recordings leader. SSE `recording` messages go to the user's own devices only.
1099
+ const recordingService = new RecordingService({
1100
+ state: backends.state,
1101
+ blobs: backends.blobs ?? new MemoryBlobStore({ now }),
1102
+ bus: backends.bus,
1103
+ now,
1104
+ ...(config.cluster ? { podID: config.cluster.podID } : {}),
1105
+ version: options.version ?? "0.0.0",
1106
+ owns: (userID, sessionID) => registry.owns(userID, sessionID),
1107
+ sessionTitle: (sessionID) => registry.session(sessionID)?.title,
1108
+ conversation: (sessionID) => registry.conversation(sessionID),
1109
+ isConcierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID),
1110
+ speech: (userID) => {
1111
+ const view = speechSettings.view(speechScope(userID)) as unknown as { stt?: Record<string, unknown>; tts?: Record<string, unknown> }
1112
+ const pick = (o: Record<string, unknown> | undefined, keys: readonly string[]) => (o ? Object.fromEntries(keys.filter((k) => typeof o[k] === "string").map((k) => [k, (o[k] as string).slice(0, 120)])) : undefined)
1113
+ return { ...(view.stt ? { stt: pick(view.stt, ["provider", "model"]) } : {}), ...(view.tts ? { tts: pick(view.tts, ["provider", "model", "voice"]) } : {}) }
1114
+ },
1115
+ notify: (userID, message, exceptDevice) => toUser(userID, message, exceptDevice),
1116
+ audit: (entry) => store.audit(entry),
1117
+ users: allUsers,
1118
+ log: (message) => console.error(message),
1119
+ })
1120
+ recordings = recordingService
1121
+ registry.recordingTap = recordingService.registryTap
1122
+ registry.onSessionDeleted((sessionID, tenant) => void recordingService.sessionDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] recording session end failed: ${(cause as Error).message}`)))
1123
+ void leader("voice-recordings", { onElected: () => recordingService.retention.start(), onDeposed: () => recordingService.retention.stop() }).start()
1085
1124
  const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
1086
1125
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1087
1126
  /** An agent as envelopes name it (a session by its directory name). */
@@ -1276,12 +1315,13 @@ export function startCentral(options: CentralOptions): Central {
1276
1315
  const { sessionID } = await conciergeIdentity.current(userID)
1277
1316
  return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
1278
1317
  },
1279
- send: async (userID, text, sendOptions) => {
1318
+ // (REC-01) A successful send is recorded as wake.arrived in a recording of the Concierge session.
1319
+ send: wrapConciergeSend(async (userID, text, sendOptions) => {
1280
1320
  const { sessionID } = await conciergeIdentity.current(userID)
1281
1321
  if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
1282
1322
  const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
1283
1323
  return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1284
- },
1324
+ }, { current: async (userID) => (recordingService.mayRecord(userID) ? (await conciergeIdentity.current(userID)).sessionID : undefined), wake: (userID, sessionID, fields) => recordingService.wake(userID, sessionID, fields) }),
1285
1325
  entitlements: C1_ENTITLEMENTS,
1286
1326
  users: allUsers,
1287
1327
  // CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
@@ -1391,7 +1431,7 @@ export function startCentral(options: CentralOptions): Central {
1391
1431
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1392
1432
  })
1393
1433
  void conciergeLeader.start()
1394
- const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1434
+ const agentDeps = { recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
1395
1435
  const resolveActor = createResolver({
1396
1436
  cookieName: COOKIE,
1397
1437
  findDevice: (token) => deviceOfToken(token),
@@ -1640,15 +1680,19 @@ export function startCentral(options: CentralOptions): Central {
1640
1680
  await store.removeDevice(device.id)
1641
1681
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
1642
1682
  }),
1643
- route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
1644
- route("POST", "/api/speak", "user", async ({ request, user }) => {
1683
+ route("POST", "/api/transcribe", "user", ({ request, user, device }) => transcribe(request, speechFor(user.id), user.id, device.id)),
1684
+ route("POST", "/api/speak", "user", async ({ request, user, device }) => {
1645
1685
  const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
1646
1686
  if (!text) return error(400, "Nothing to speak")
1647
1687
  const speech = speechFor(user.id)
1688
+ // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
1689
+ const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
1648
1690
  // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1649
1691
  const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
1650
- return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
1692
+ return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
1651
1693
  }),
1694
+ // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
1695
+ ...recordingRoutes(recordingService, { version: options.version ?? "0.0.0", now, audit: (entry) => store.audit(entry) }).map((r) => route(r.method, r.path, "user", (context) => r.handle(context))),
1652
1696
  route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
1653
1697
  route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
1654
1698
  const input = await body(request)
@@ -1751,6 +1795,7 @@ export function startCentral(options: CentralOptions): Central {
1751
1795
  async ({ params }) => {
1752
1796
  await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1753
1797
  registry.cancelTurns(params.session!)
1798
+ recordingTap(params.session!, "interrupt", {})
1754
1799
  return new Response(null, { status: 204 })
1755
1800
  },
1756
1801
  ),
@@ -1761,6 +1806,15 @@ export function startCentral(options: CentralOptions): Central {
1761
1806
  return Response.json(stopped.result, { headers: NO_STORE })
1762
1807
  }),
1763
1808
  route("POST", "/api/sessions/:session/rename", "session", ({ request, params }) => rename(request, params.session!)),
1809
+ // The hub forgets the error first; every process reporting the session that can (feature "dismissError")
1810
+ // forgets it too, best effort: an older or offline one may seed it again, which the board ignores (StatusBoard).
1811
+ route("POST", "/api/sessions/:session/error/dismiss", "session", async ({ params }) => {
1812
+ const sessionID = params.session!
1813
+ registry.dismissError(sessionID)
1814
+ const targets = registry.sessionTargets(sessionID).filter((c) => registry.supports(c, "dismissError"))
1815
+ await Promise.allSettled(targets.map((connID) => spokes.request(connID, { op: "dismissError", sessionID })))
1816
+ return new Response(null, { status: 204 })
1817
+ }),
1764
1818
  // Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
1765
1819
  route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
1766
1820
  route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
@@ -2224,6 +2278,12 @@ export function startCentral(options: CentralOptions): Central {
2224
2278
  return new Response(null, { status: 204 })
2225
2279
  }
2226
2280
 
2281
+ /** (REC-01) A hub event for the session's active recordings (its owner's only); never fails the route. */
2282
+ function recordingTap(sessionID: string, kind: "interrupt" | "utterance.received" | "conversation", fields: Record<string, unknown>): void {
2283
+ const tenant = registry.sessionTenant(sessionID)
2284
+ if (tenant !== undefined) recordingService.hub(tenant, sessionID, kind, fields)
2285
+ }
2286
+
2227
2287
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
2228
2288
  async function utterance(request: Request, sessionID: string): Promise<Response> {
2229
2289
  const raw = (await body(request)).text
@@ -2233,6 +2293,7 @@ export function startCentral(options: CentralOptions): Central {
2233
2293
  const connID = ownerOf(sessionID, "steer")
2234
2294
  await humanTurn(sessionID)
2235
2295
  const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
2296
+ recordingTap(sessionID, "utterance.received", { inboxID, text })
2236
2297
  registry.trackUtterance(sessionID, inboxID)
2237
2298
  return Response.json({ inboxID }, { status: 202 })
2238
2299
  }
@@ -2290,9 +2351,13 @@ export function startCentral(options: CentralOptions): Central {
2290
2351
  if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
2291
2352
  // Turning off always clears the hub's state, so a later reconnect does not switch it back on.
2292
2353
  if (!on) registry.setConversation(sessionID, false)
2354
+ // (REC-01) Off ends the session's recordings (with a grace for the console's last batches); best effort.
2355
+ const tenant = registry.sessionTenant(sessionID)
2356
+ if (!on && tenant !== undefined) await recordingService.conversationChanged(tenant, sessionID, false).catch((cause) => console.error(`[hub] recording end failed: ${(cause as Error).message}`))
2293
2357
  const connID = ownerOf(sessionID)
2294
2358
  const { agent } = await spokes.request(connID, { op: "conversation", sessionID, on })
2295
2359
  if (on) registry.setConversation(sessionID, true, connID)
2360
+ if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true })
2296
2361
  return Response.json({ on, agent })
2297
2362
  }
2298
2363
 
@@ -2382,7 +2447,7 @@ export function startCentral(options: CentralOptions): Central {
2382
2447
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2383
2448
  }
2384
2449
 
2385
- async function transcribe(request: Request, speech: Speech): Promise<Response> {
2450
+ async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
2386
2451
  const format = audioFormat(request.headers.get("content-type"))
2387
2452
  if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
2388
2453
  if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
@@ -2390,7 +2455,13 @@ export function startCentral(options: CentralOptions): Central {
2390
2455
  }
2391
2456
  const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
2392
2457
  if (audio.length === 0) return error(400, "Empty recording")
2393
- return Response.json({ text: await speech.transcribe(audio, format) })
2458
+ // (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
2459
+ const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
2460
+ const started = performance.now()
2461
+ const text = await speech.transcribe(audio, format)
2462
+ if (!tap) return Response.json({ text })
2463
+ const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
2464
+ return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
2394
2465
  }
2395
2466
 
2396
2467
  /**
@@ -2546,6 +2617,8 @@ export function startCentral(options: CentralOptions): Central {
2546
2617
  for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
2547
2618
  drainSignal.abort()
2548
2619
  await connections
2620
+ // (REC-01) The buffered hub events of recordings are written before the pod goes.
2621
+ await recordingService.flush().catch((cause) => console.error(`[hub] recording hub events not written: ${(cause as Error).message}`))
2549
2622
  }
2550
2623
 
2551
2624
  /** Compressed copies of hashed assets, made once per file and encoding (they never change). */
@@ -2578,6 +2651,7 @@ export function startCentral(options: CentralOptions): Central {
2578
2651
  headers.set("service-worker-allowed", "/")
2579
2652
  } else if (type.startsWith("text/html")) {
2580
2653
  headers.set("cache-control", "no-cache")
2654
+ headers.set(cspHeader(config.csp ?? "enforce"), CONSOLE_CSP)
2581
2655
  } else if (HASHED_ASSET.test(name)) {
2582
2656
  headers.set("cache-control", IMMUTABLE)
2583
2657
  if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
@@ -2615,6 +2689,7 @@ export function startCentral(options: CentralOptions): Central {
2615
2689
  concierge: conciergeService,
2616
2690
  agentEventWriters,
2617
2691
  intentRetention,
2692
+ recordings: recordingService,
2618
2693
  teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2619
2694
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
2620
2695
  drain(drainOptions: DrainOptions = {}) {
@@ -2630,6 +2705,7 @@ export function startCentral(options: CentralOptions): Central {
2630
2705
  conciergeParts.tiers.stop?.()
2631
2706
  webhooks.stop()
2632
2707
  artifactMaintenance?.stop()
2708
+ await recordingService.stop()
2633
2709
  profileApplier?.stop()
2634
2710
  agentEventWriters.stop()
2635
2711
  tokenPush.stop()
@@ -100,6 +100,22 @@ export function credentialFinding(text: string): string | undefined {
100
100
  return undefined
101
101
  }
102
102
 
103
+ /**
104
+ * (REC-01) Replaces every literal credential in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
105
+ * globally) and lists the kinds removed, each once, in table order. Voice recordings scrub every text field with it.
106
+ */
107
+ export function redactCredentials(text: string): { text: string; kinds: string[] } {
108
+ const kinds: string[] = []
109
+ let out = text
110
+ for (const [kind, pattern] of CREDENTIALS) {
111
+ const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`)
112
+ const next = out.replace(global, `[redacted: ${kind}]`)
113
+ if (next !== out && !kinds.includes(kind)) kinds.push(kind)
114
+ out = next
115
+ }
116
+ return { text: out, kinds }
117
+ }
118
+
103
119
  /** Fields that would run code or install things on a machine: refused as unsupported. */
104
120
  const HOOK_FIELDS = new Set(["hooks", "hook", "setup", "install", "postinstall", "preinstall", "dependencies", "devdependencies", "requirements", "scripts"])
105
121
  const CONTENT_FIELDS = new Set(["instructions", "model", "skills", "commands"])
@@ -0,0 +1,76 @@
1
+ // REC-01 clock alignment of console events to hub time (contract: Clocks and alignment).
2
+ // Each `clock.sync {t0, t1, hubAt}` sample gives offset = hubAt − (t0 + t1) / 2 and rtt = t1 − t0. A console event
3
+ // at page time t gets ts = t + offset of the lowest-rtt sample within ±5 min (else the lowest-rtt sample overall):
4
+ // piecewise, so page clock pauses (sleep) and drift are absorbed. Without samples, anchor.epoch − anchor.perf.
5
+ import type { RecordingAnchor } from "../../protocol"
6
+
7
+ export interface ClockSample {
8
+ readonly t0: number
9
+ readonly t1: number
10
+ readonly hubAt: number
11
+ }
12
+
13
+ export interface ClockSummary {
14
+ /** The best sample's offset (hub epoch ms − page ms), or the anchor's. */
15
+ readonly offsetMs: number
16
+ /** Half the worst rtt among the samples used (null: no sample, the anchor's wall clock). */
17
+ readonly errorMs: number | null
18
+ readonly samples: number
19
+ }
20
+
21
+ export interface ClockAlignment {
22
+ /** Aligned hub-epoch ms of a console event's page time. */
23
+ ts(t: number): number
24
+ /** For the manifest, after the events were aligned. */
25
+ readonly summary: ClockSummary
26
+ }
27
+
28
+ export const CLOCK_WINDOW_MS = 5 * 60_000
29
+
30
+ interface Prepared {
31
+ readonly mid: number
32
+ readonly rtt: number
33
+ readonly offset: number
34
+ }
35
+
36
+ const round = (ms: number) => Math.round(ms * 10) / 10
37
+
38
+ export function clockAlignment(samples: readonly ClockSample[], anchor: RecordingAnchor | undefined): ClockAlignment {
39
+ const prepared: Prepared[] = samples
40
+ .filter((s) => Number.isFinite(s.t0) && Number.isFinite(s.t1) && Number.isFinite(s.hubAt) && s.t1 >= s.t0)
41
+ .map((s) => ({ mid: (s.t0 + s.t1) / 2, rtt: s.t1 - s.t0, offset: s.hubAt - (s.t0 + s.t1) / 2 }))
42
+ .sort((a, b) => a.mid - b.mid)
43
+ const fallback = anchor ? anchor.epoch - anchor.perf : 0
44
+ if (!prepared.length) return { ts: (t) => round(t + fallback), summary: { offsetMs: fallback, errorMs: null, samples: 0 } }
45
+ const best = prepared.reduce((a, b) => (b.rtt < a.rtt ? b : a))
46
+ const used = new Set<Prepared>()
47
+ /** The first sample whose mid is ≥ value. */
48
+ const lowerBound = (value: number) => {
49
+ let lo = 0
50
+ let hi = prepared.length
51
+ while (lo < hi) {
52
+ const m = (lo + hi) >> 1
53
+ if (prepared[m]!.mid < value) lo = m + 1
54
+ else hi = m
55
+ }
56
+ return lo
57
+ }
58
+ const choose = (t: number): Prepared => {
59
+ let chosen: Prepared | undefined
60
+ for (let i = lowerBound(t - CLOCK_WINDOW_MS); i < prepared.length && prepared[i]!.mid <= t + CLOCK_WINDOW_MS; i++) {
61
+ if (!chosen || prepared[i]!.rtt < chosen.rtt) chosen = prepared[i]
62
+ }
63
+ return chosen ?? best
64
+ }
65
+ return {
66
+ ts: (t) => {
67
+ const sample = choose(t)
68
+ used.add(sample)
69
+ return round(t + sample.offset)
70
+ },
71
+ get summary() {
72
+ const worst = used.size ? Math.max(...[...used].map((s) => s.rtt)) : best.rtt
73
+ return { offsetMs: round(best.offset), errorMs: round(worst / 2), samples: prepared.length }
74
+ },
75
+ }
76
+ }