@huynle/hub 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -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, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, 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"
@@ -33,7 +33,7 @@ import { Escalations } from "./agent/escalation"
33
33
  import { GrantRequests } from "./agent/grant-requests"
34
34
  import { partyFromActor } from "./agent/envelope"
35
35
  import { Delivery } from "./agent/delivery"
36
- import { AgentError, type AgentErrorCode } from "./agent/errors"
36
+ import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
37
37
  import type { Launch } from "./agent/launch-store"
38
38
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
39
39
  import { hubDirectory } from "./agent/directory-routes"
@@ -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"
@@ -101,6 +106,7 @@ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
101
106
  import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
102
107
  import { grantState } from "./agent/grants"
103
108
  import { catalogHarnesses, TeamDiscovery } from "./teams/discovery"
109
+ import { inRevertWindow, RevertWindows } from "./revert-windows"
104
110
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
105
111
  import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
106
112
 
@@ -195,6 +201,8 @@ export interface Central {
195
201
  readonly agentEventWriters: EventWriters
196
202
  /** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
197
203
  readonly intentRetention: IntentRetention
204
+ /** (REC-01) Voice & Concierge recordings (developer): the owner-only store, taps, export and retention. */
205
+ readonly recordings: RecordingService
198
206
  /** The JSON API's route table (method, path, access), for audits and tests. */
199
207
  readonly routes: readonly RouteInfo[]
200
208
  /** Teams (HWA-33..37): definitions, seats (the delivery policy's) and staffing; HWA-37 routes reuse these. */
@@ -298,8 +306,6 @@ const LAUNCH_STOP_TIMEOUT_MS = 180_000
298
306
  */
299
307
  export const TURN_ACCEPT_MS = 1500
300
308
  const NO_OWNER = "No OpenCode process for this session is connected"
301
- /** The spoke's refusal of a revert while the session runs (src/spoke/fork-revert.ts REVERT_BUSY). */
302
- const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
303
309
  /** A message ID in a fork or revert request (as the spoke protocol bounds it). */
304
310
  const MESSAGE_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
305
311
  const AUTO_NAME_OUTDATED = "Update the Hub plugin to use Auto-name"
@@ -830,6 +836,8 @@ export function startCentral(options: CentralOptions): Central {
830
836
  const feed = new MessageFeed()
831
837
  // Cluster mode: messages stored on any pod reach this pod's listeners (ask waiters, agent watches).
832
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
833
841
  /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
834
842
  let conciergeTierHooks: ConciergeTiers | undefined
835
843
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
@@ -843,6 +851,8 @@ export function startCentral(options: CentralOptions): Central {
843
851
  changed: (userID, decision) => {
844
852
  broadcast({ type: "decision", decision }, { tenant: userID })
845
853
  conciergeTierHooks?.decisionChanged?.(userID, decision)
854
+ // (REC-01) decision.opened / decision.answered of a recorded session (best effort).
855
+ recordings?.decisionChanged(userID, decision)
846
856
  },
847
857
  // Escalations' Decisions reply to the permission as the user; grant requests' create the grant as the user;
848
858
  // (CON-06) Concierge proposals run their verb as the user (all set up below).
@@ -871,6 +881,15 @@ export function startCentral(options: CentralOptions): Central {
871
881
  await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
872
882
  }
873
883
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
884
+ // Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
885
+ const revertWindows = new RevertWindows(backends.state)
886
+ registry.onSessions((connID) => {
887
+ const tenant = registry.connTenant(connID)
888
+ if (tenant === undefined) return
889
+ for (const { sessionID, committed } of registry.revertCommits(connID)) {
890
+ void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
891
+ }
892
+ })
874
893
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
875
894
  // (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
876
895
  const intents = new SessionIntents({
@@ -1074,6 +1093,34 @@ export function startCentral(options: CentralOptions): Central {
1074
1093
  // Every stored agent message also goes to the user's console streams (live transcript rows).
1075
1094
  feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
1076
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()
1077
1124
  const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
1078
1125
  const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
1079
1126
  /** An agent as envelopes name it (a session by its directory name). */
@@ -1268,12 +1315,13 @@ export function startCentral(options: CentralOptions): Central {
1268
1315
  const { sessionID } = await conciergeIdentity.current(userID)
1269
1316
  return sessionID !== undefined ? conciergeContext.beforeWake(userID, sessionID) : false
1270
1317
  },
1271
- 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) => {
1272
1320
  const { sessionID } = await conciergeIdentity.current(userID)
1273
1321
  if (sessionID === undefined) throw new AgentError("not_found", "The Concierge is not running")
1274
1322
  const sent = await delivery.send(userID, conciergeActor(userID), sessionID, "msg", text, { wake: sendOptions.wake, ...(sendOptions.urgent ? { urgent: true } : {}) })
1275
1323
  return { messageID: sent.message.id, ...(sent.woke !== undefined ? { woke: sent.woke } : {}) }
1276
- },
1324
+ }, { current: async (userID) => (recordingService.mayRecord(userID) ? (await conciergeIdentity.current(userID)).sessionID : undefined), wake: (userID, sessionID, fields) => recordingService.wake(userID, sessionID, fields) }),
1277
1325
  entitlements: C1_ENTITLEMENTS,
1278
1326
  users: allUsers,
1279
1327
  // CON-05 the notifier's journal (cursor), ledger store, watchlist view, board revision and the concierge lease.
@@ -1296,7 +1344,7 @@ export function startCentral(options: CentralOptions): Central {
1296
1344
  state: backends.state,
1297
1345
  counters: backends.counters,
1298
1346
  decisions,
1299
- acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity) })),
1347
+ acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity), currentConcierge: async (userID, sessionID) => (await conciergeTimedIdentity.current(userID)).sessionID === sessionID })),
1300
1348
  audit,
1301
1349
  protects: conciergeProtects(conciergeIdentity),
1302
1350
  published: (userID, message) => feed.publish(userID, message),
@@ -1383,7 +1431,7 @@ export function startCentral(options: CentralOptions): Central {
1383
1431
  onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
1384
1432
  })
1385
1433
  void conciergeLeader.start()
1386
- 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 } }
1387
1435
  const resolveActor = createResolver({
1388
1436
  cookieName: COOKIE,
1389
1437
  findDevice: (token) => deviceOfToken(token),
@@ -1632,15 +1680,19 @@ export function startCentral(options: CentralOptions): Central {
1632
1680
  await store.removeDevice(device.id)
1633
1681
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
1634
1682
  }),
1635
- route("POST", "/api/transcribe", "user", ({ request, user }) => transcribe(request, speechFor(user.id))),
1636
- 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 }) => {
1637
1685
  const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
1638
1686
  if (!text) return error(400, "Nothing to speak")
1639
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)
1640
1690
  // Passed through as the provider sends it, so the phone can start playing before synthesis ends.
1641
1691
  const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
1642
- return new Response(audio, { headers: { "content-type": "audio/mpeg" } })
1692
+ return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
1643
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))),
1644
1696
  route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
1645
1697
  route("POST", "/api/push/subscribe", "user", async ({ request, device }) => {
1646
1698
  const input = await body(request)
@@ -1743,6 +1795,7 @@ export function startCentral(options: CentralOptions): Central {
1743
1795
  async ({ params }) => {
1744
1796
  await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
1745
1797
  registry.cancelTurns(params.session!)
1798
+ recordingTap(params.session!, "interrupt", {})
1746
1799
  return new Response(null, { status: 204 })
1747
1800
  },
1748
1801
  ),
@@ -1753,11 +1806,20 @@ export function startCentral(options: CentralOptions): Central {
1753
1806
  return Response.json(stopped.result, { headers: NO_STORE })
1754
1807
  }),
1755
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
+ }),
1756
1818
  // Fork and revert (feature "fork"/"revert"; console only, audited): see docs/session-view.md.
1757
1819
  route("POST", "/api/sessions/:session/fork", "session", ({ request, params, user }) => forkSession(request, params.session!, user.id)),
1758
1820
  route("GET", "/api/sessions/:session/revert/preview", "session", async ({ params, url }) => {
1759
1821
  const messageID = messageIDInput(url.searchParams.get("messageID"))
1760
- const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID })
1822
+ const preview = await spokes.request(featureOwner(params.session!, "revert"), { op: "revertPreview", sessionID: params.session!, messageID }).catch(refused())
1761
1823
  return Response.json(preview, { headers: NO_STORE })
1762
1824
  }),
1763
1825
  route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
@@ -1816,11 +1878,18 @@ export function startCentral(options: CentralOptions): Central {
1816
1878
  const raw = url.searchParams.get("limit")
1817
1879
  const limit = raw === null ? undefined : Number(raw)
1818
1880
  if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return error(400, "limit must be 1–200")
1819
- const messages = await delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit })
1881
+ const [listed, windows] = await Promise.all([
1882
+ delivery.messages.list(user.id, { kind: "session", sessionID: params.session! }, limit === undefined ? {} : { limit }),
1883
+ revertWindows.list(user.id, params.session!),
1884
+ ])
1885
+ // Rows of a history a committed revert removed stay out.
1886
+ const messages = windows.length ? listed.filter((m) => !inRevertWindow(m.at, windows)) : listed
1820
1887
  return Response.json({ messages }, { headers: NO_STORE })
1821
1888
  }),
1889
+ // (M1) Embedded images become placeholders here too, so a spoke that still sends them (older versions, other
1890
+ // harnesses) never ships image bytes to the console in a tool detail.
1822
1891
  route("GET", "/api/sessions/:session/timeline/messages/:message/parts/:part", "session", async ({ params }) =>
1823
- Response.json(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! }), { headers: NO_STORE })),
1892
+ Response.json(redactEmbeddedMedia(await spokes.request(ownerOf(params.session!), { op: "timelinePart", sessionID: params.session!, messageID: params.message!, partID: params.part! })), { headers: NO_STORE })),
1824
1893
  route("GET", "/api/sessions/:session/timeline/messages/:message/files/:index", "session", ({ params }) => timelineFile(params.session!, params.message!, params.index!)),
1825
1894
  route(
1826
1895
  "GET",
@@ -2163,16 +2232,26 @@ export function startCentral(options: CentralOptions): Central {
2163
2232
  return raw
2164
2233
  }
2165
2234
 
2166
- /** A spoke's refusal of a revert while the session runs, as 409 busy; anything else unchanged. */
2167
- function busyRefusal(cause: unknown): unknown {
2168
- return cause instanceof SpokeError && cause.message === REVERT_BUSY ? new HttpError(409, REVERT_BUSY, "busy") : cause
2235
+ /**
2236
+ * A spoke's refusal of a fork or revert op: code "busy" → 409 busy, "not_found" → 404 (failure() maps both). Older
2237
+ * spokes send no code: their busy texts (the spoke's REVERT_BUSY, OpenCode's "Session is busy: …") and
2238
+ * NOT_A_USER_MESSAGE are recognised instead; anything else unchanged (502).
2239
+ */
2240
+ function forkRevertRefusal(cause: unknown, busy = REVERT_BUSY): unknown {
2241
+ if (!(cause instanceof SpokeError) || cause.code !== undefined) return cause
2242
+ if (cause.message === REVERT_BUSY || /Session is busy/i.test(cause.message)) return new HttpError(409, busy, "busy")
2243
+ if (cause.message === NOT_A_USER_MESSAGE) return new HttpError(404, NOT_A_USER_MESSAGE, "not_found")
2244
+ return cause
2245
+ }
2246
+ const refused = (busy?: string) => (cause: unknown) => {
2247
+ throw forkRevertRefusal(cause, busy)
2169
2248
  }
2170
2249
 
2171
2250
  /** POST …/fork {messageID}: a new session with the conversation before that user message (201). */
2172
2251
  async function forkSession(request: Request, sessionID: string, userID: string): Promise<Response> {
2173
2252
  const messageID = messageIDInput((await body(request)).messageID)
2174
2253
  const connID = featureOwner(sessionID, "fork")
2175
- const result = await spokes.request(connID, { op: "fork", sessionID, messageID })
2254
+ const result = await spokes.request(connID, { op: "fork", sessionID, messageID }).catch(refused())
2176
2255
  await store.audit({ actor: userID, action: "session.fork", target: sessionID, detail: `${messageID} -> ${result.sessionID}` })
2177
2256
  return Response.json(result, { status: 201, headers: NO_STORE })
2178
2257
  }
@@ -2185,9 +2264,7 @@ export function startCentral(options: CentralOptions): Central {
2185
2264
  const files = input.files !== false
2186
2265
  const connID = featureOwner(sessionID, "revert")
2187
2266
  if (registry.running(sessionID)) throw new HttpError(409, REVERT_BUSY, "busy")
2188
- const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch((cause) => {
2189
- throw busyRefusal(cause)
2190
- })
2267
+ const result = await spokes.request(connID, { op: "revert", sessionID, messageID, files }).catch(refused())
2191
2268
  await store.audit({ actor: userID, action: "session.revert", target: sessionID, detail: files ? messageID : `${messageID} (messages only)` })
2192
2269
  return Response.json(result, { headers: NO_STORE })
2193
2270
  }
@@ -2195,13 +2272,18 @@ export function startCentral(options: CentralOptions): Central {
2195
2272
  /** POST …/revert/restore (undo) or …/revert/commit (make permanent): 204. */
2196
2273
  async function revertEnd(sessionID: string, userID: string, op: "revertRestore" | "revertCommit"): Promise<Response> {
2197
2274
  const connID = featureOwner(sessionID, "revert")
2198
- await spokes.request(connID, { op, sessionID }).catch((cause) => {
2199
- throw busyRefusal(cause)
2200
- })
2275
+ if (registry.running(sessionID)) throw new HttpError(409, REVERT_END_BUSY, "busy")
2276
+ await spokes.request(connID, { op, sessionID }).catch(refused(REVERT_END_BUSY))
2201
2277
  await store.audit({ actor: userID, action: op === "revertRestore" ? "session.revert.restore" : "session.revert.commit", target: sessionID })
2202
2278
  return new Response(null, { status: 204 })
2203
2279
  }
2204
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
+
2205
2287
  /** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
2206
2288
  async function utterance(request: Request, sessionID: string): Promise<Response> {
2207
2289
  const raw = (await body(request)).text
@@ -2211,6 +2293,7 @@ export function startCentral(options: CentralOptions): Central {
2211
2293
  const connID = ownerOf(sessionID, "steer")
2212
2294
  await humanTurn(sessionID)
2213
2295
  const { inboxID } = await spokes.request(connID, { op: "steer", sessionID, text })
2296
+ recordingTap(sessionID, "utterance.received", { inboxID, text })
2214
2297
  registry.trackUtterance(sessionID, inboxID)
2215
2298
  return Response.json({ inboxID }, { status: 202 })
2216
2299
  }
@@ -2268,9 +2351,13 @@ export function startCentral(options: CentralOptions): Central {
2268
2351
  if (typeof on !== "boolean") return error(400, "Expected {on: boolean}")
2269
2352
  // Turning off always clears the hub's state, so a later reconnect does not switch it back on.
2270
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}`))
2271
2357
  const connID = ownerOf(sessionID)
2272
2358
  const { agent } = await spokes.request(connID, { op: "conversation", sessionID, on })
2273
2359
  if (on) registry.setConversation(sessionID, true, connID)
2360
+ if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true })
2274
2361
  return Response.json({ on, agent })
2275
2362
  }
2276
2363
 
@@ -2360,7 +2447,7 @@ export function startCentral(options: CentralOptions): Central {
2360
2447
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
2361
2448
  }
2362
2449
 
2363
- async function transcribe(request: Request, speech: Speech): Promise<Response> {
2450
+ async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
2364
2451
  const format = audioFormat(request.headers.get("content-type"))
2365
2452
  if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
2366
2453
  if (Number(request.headers.get("content-length") ?? 0) > config.maxAudioBytes) {
@@ -2368,7 +2455,13 @@ export function startCentral(options: CentralOptions): Central {
2368
2455
  }
2369
2456
  const audio = await boundedBytes(request, config.maxAudioBytes, "Recording too large")
2370
2457
  if (audio.length === 0) return error(400, "Empty recording")
2371
- 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 } })
2372
2465
  }
2373
2466
 
2374
2467
  /**
@@ -2524,6 +2617,8 @@ export function startCentral(options: CentralOptions): Central {
2524
2617
  for (const listener of [...listeners]) listener.end(SSE_RETRY_MS)
2525
2618
  drainSignal.abort()
2526
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}`))
2527
2622
  }
2528
2623
 
2529
2624
  /** Compressed copies of hashed assets, made once per file and encoding (they never change). */
@@ -2556,6 +2651,7 @@ export function startCentral(options: CentralOptions): Central {
2556
2651
  headers.set("service-worker-allowed", "/")
2557
2652
  } else if (type.startsWith("text/html")) {
2558
2653
  headers.set("cache-control", "no-cache")
2654
+ headers.set(cspHeader(config.csp ?? "enforce"), CONSOLE_CSP)
2559
2655
  } else if (HASHED_ASSET.test(name)) {
2560
2656
  headers.set("cache-control", IMMUTABLE)
2561
2657
  if (COMPRESSIBLE.has(path.extname(name).toLowerCase()) && bytes.length >= MIN_COMPRESS_BYTES) {
@@ -2593,6 +2689,7 @@ export function startCentral(options: CentralOptions): Central {
2593
2689
  concierge: conciergeService,
2594
2690
  agentEventWriters,
2595
2691
  intentRetention,
2692
+ recordings: recordingService,
2596
2693
  teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
2597
2694
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
2598
2695
  drain(drainOptions: DrainOptions = {}) {
@@ -2608,6 +2705,7 @@ export function startCentral(options: CentralOptions): Central {
2608
2705
  conciergeParts.tiers.stop?.()
2609
2706
  webhooks.stop()
2610
2707
  artifactMaintenance?.stop()
2708
+ await recordingService.stop()
2611
2709
  profileApplier?.stop()
2612
2710
  agentEventWriters.stop()
2613
2711
  tokenPush.stop()
@@ -2642,11 +2740,15 @@ function decisionAnswer(input: Record<string, unknown>): { option?: string; text
2642
2740
  return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
2643
2741
  }
2644
2742
 
2645
- /** Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by code. */
2743
+ /**
2744
+ * Console envelope for a Decision failure: gone → 409 (the web app's toast), the rest by the agent API's code
2745
+ * table (a revert-staged `conflict` 409, a `busy` 503 with retryAfter), never a bare 500 for a known code.
2746
+ */
2646
2747
  function decisionFailure(cause: unknown): Response {
2647
2748
  if (!(cause instanceof AgentError)) throw cause
2648
- const status: Partial<Record<string, number>> = { gone: 409, not_found: 404, forbidden: 403, invalid: 400, rate_limited: 429, paused: 423, offline: 503, unsupported: 422 }
2649
- return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}) }, { status: status[cause.code] ?? 500 })
2749
+ const status = cause.code === "gone" ? 409 : AGENT_ERROR_STATUS[cause.code] ?? 500
2750
+ const extra = cause.options.extra && typeof cause.options.extra.retryAfter === "number" ? { retryAfter: cause.options.extra.retryAfter } : {}
2751
+ return Response.json({ error: cause.message, code: cause.code, ...(cause.options.hint ? { hint: cause.options.hint } : {}), ...extra }, { status })
2650
2752
  }
2651
2753
 
2652
2754
  /** Deep-link scheme of the native apps: `hubapp://link?hub=<public URL>&code=<link code>`. */
@@ -2790,6 +2892,9 @@ function failure(cause: unknown): Response {
2790
2892
  if (cause instanceof ValidationError) return error(400, cause.message)
2791
2893
  if (cause instanceof SyntaxError) return error(400, "Invalid JSON")
2792
2894
  if (cause instanceof SpeechError) return error(502, cause.message)
2895
+ // A spoke's coded refusal (Res.code): busy → 409, not found → 404; anything else from a spoke is a bad gateway.
2896
+ if (cause instanceof SpokeError && cause.code === "busy") return error(409, cause.message, "busy")
2897
+ if (cause instanceof SpokeError && cause.code === "not_found") return error(404, cause.message, "not_found")
2793
2898
  if (cause instanceof SpokeError) return error(502, cause.message)
2794
2899
  if (cause instanceof RequestTooLargeError) return error(413, cause.message)
2795
2900
  if (cause instanceof OidcError) return authPage(cause.status, "Sign-in failed", cause.message)
@@ -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
+ }
@@ -0,0 +1,104 @@
1
+ // REC-01 Concierge taps (contract: Event schema → Hub): `concierge.tool` for every agent-route call of the user's
2
+ // recorded Concierge session (method, route pattern, target, status, ms: never a request body, never a response),
3
+ // and `wake.arrived` when the notifier's send to the Concierge succeeded (the rendered batch: hub-written IDs,
4
+ // enums and counts only). Both are best effort and never change what the route or the send answers.
5
+ import { AGENT_ERROR_STATUS, AgentError } from "../agent/errors"
6
+ import type { AgentContext, AgentRoute } from "../agent/routes"
7
+
8
+ export interface ConciergeCallFields {
9
+ readonly verb?: string
10
+ readonly method: string
11
+ readonly route: string
12
+ readonly target?: string
13
+ readonly status: number
14
+ readonly ms: number
15
+ }
16
+
17
+ export interface ConciergeCallRecorder {
18
+ /** A session actor's agent call (the recorder keeps it only for a recorded Concierge session). */
19
+ call(userID: string, sessionID: string, fields: ConciergeCallFields): void
20
+ }
21
+
22
+ /** The hub verb a route maps to: its last literal path segment (board, decisions, proposals, approve, …). */
23
+ export function conciergeKind(path: string): string | undefined {
24
+ const literal = path.split("/").filter((s) => s && !s.startsWith(":"))
25
+ const last = literal.at(-1)
26
+ return last && last !== "agent" && last !== "api" ? last : undefined
27
+ }
28
+
29
+ const TARGET_PARAMS = ["addr", "id", "form", "staffing"] as const
30
+
31
+ /** Wraps every route: session-actor calls are reported to the recorder after they answer (or throw). */
32
+ export function recordConciergeCalls(routes: readonly AgentRoute[], recorder: ConciergeCallRecorder, now: () => number = Date.now): AgentRoute[] {
33
+ return routes.map((route) => ({
34
+ ...route,
35
+ handle: async (context: AgentContext) => {
36
+ if (context.actor.kind !== "session") return route.handle(context)
37
+ const started = now()
38
+ let status = 500
39
+ try {
40
+ const response = await route.handle(context)
41
+ status = response.status
42
+ return response
43
+ } catch (cause) {
44
+ status = cause instanceof AgentError ? (AGENT_ERROR_STATUS[cause.code] ?? 500) : 500
45
+ throw cause
46
+ } finally {
47
+ try {
48
+ const target = TARGET_PARAMS.map((p) => context.params[p]).find((v) => typeof v === "string" && v.length > 0)
49
+ const verb = conciergeKind(route.path)
50
+ recorder.call(context.user.id, (context.actor as { sessionID: string }).sessionID, {
51
+ ...(verb ? { verb } : {}),
52
+ method: route.method,
53
+ route: route.path,
54
+ ...(target ? { target: target.slice(0, 300) } : {}),
55
+ status,
56
+ ms: now() - started,
57
+ })
58
+ } catch {
59
+ // best effort
60
+ }
61
+ }
62
+ },
63
+ }))
64
+ }
65
+
66
+ export interface ConciergeBatchFacts {
67
+ readonly id: string
68
+ readonly kind: string
69
+ readonly budget?: string
70
+ readonly lines: number
71
+ }
72
+
73
+ export type ConciergeSend = (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean; readonly batch?: ConciergeBatchFacts }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
74
+
75
+ export interface WakeRecorder {
76
+ /** The user's current Concierge session (the send's recipient). */
77
+ current(userID: string): Promise<string | undefined>
78
+ wake(userID: string, sessionID: string, fields: Record<string, unknown>): void
79
+ }
80
+
81
+ /** The batch facts from the rendered text when the sender did not pass them ("Concierge update b7 …"). */
82
+ function factsOf(text: string): ConciergeBatchFacts {
83
+ const head = text.split("\n", 1)[0] ?? ""
84
+ const id = /\bb(\d+)\b/.exec(head)?.[0] ?? "b?"
85
+ const kind = head.startsWith("Concierge digest") ? "digest" : head.includes("quiet hours ended") ? "release" : head.includes("changes were missed") ? "snapshot" : "update"
86
+ return { id, kind, lines: Math.max(0, text.split("\n").length - 2) }
87
+ }
88
+
89
+ /** Wraps the Concierge ports' send: a successful send is recorded as wake.arrived (a refused one is not). */
90
+ export function wrapConciergeSend(send: ConciergeSend, recorder: WakeRecorder): ConciergeSend {
91
+ return async (userID, text, options) => {
92
+ const result = await send(userID, text, options)
93
+ try {
94
+ const sessionID = await recorder.current(userID)
95
+ if (sessionID !== undefined) {
96
+ const facts = options.batch ?? factsOf(text)
97
+ recorder.wake(userID, sessionID, { batchID: facts.id, batchKind: facts.kind, ...(facts.budget !== undefined ? { budget: facts.budget } : {}), wake: options.wake, lines: facts.lines, text })
98
+ }
99
+ } catch {
100
+ // best effort: the send succeeded either way
101
+ }
102
+ return result
103
+ }
104
+ }