@huynle/hub 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +20 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +625 -395
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/src/index.ts +13 -1
  17. package/packages/protocol/src/teams.ts +4 -0
  18. package/packages/protocol/src/voice-recording.ts +39 -2
  19. package/packages/runtime-openshell/image/entry.sh +3 -1
  20. package/packages/runtime-openshell/image/start.sh +3 -1
  21. package/packages/runtime-openshell/package.json +1 -1
  22. package/packages/runtime-sdk/package.json +1 -1
  23. package/packages/sdk/package.json +1 -1
  24. package/packages/sdk/src/client.ts +13 -0
  25. package/packages/sdk/src/teams.ts +2 -0
  26. package/packages/spoke-sdk/package.json +1 -1
  27. package/src/central/agent/decisions.ts +7 -1
  28. package/src/central/agent/delivery.ts +39 -9
  29. package/src/central/agent/grant-routes.ts +14 -1
  30. package/src/central/agent/grants.ts +23 -0
  31. package/src/central/agent/launch-engine.ts +3 -3
  32. package/src/central/agent/message-limits.ts +3 -0
  33. package/src/central/agent/routes.ts +11 -0
  34. package/src/central/agent/subscriptions.ts +3 -1
  35. package/src/central/config.ts +9 -2
  36. package/src/central/http.ts +135 -24
  37. package/src/central/recording/export.ts +57 -3
  38. package/src/central/recording/index.ts +234 -16
  39. package/src/central/recording/ingest.ts +11 -2
  40. package/src/central/recording/routes.ts +61 -7
  41. package/src/central/recording/schema.ts +66 -2
  42. package/src/central/recording/settings.ts +19 -5
  43. package/src/central/recording/store.ts +19 -0
  44. package/src/central/recording/taps.ts +45 -1
  45. package/src/central/speech/openai.ts +4 -4
  46. package/src/central/speech/openrouter.ts +4 -4
  47. package/src/central/speech/provider.ts +32 -1
  48. package/src/central/speech/service.ts +6 -5
  49. package/src/central/teams/hop-stalls.ts +72 -0
  50. package/src/central/teams/policy.ts +10 -0
  51. package/src/central/teams/routes.ts +2 -0
  52. package/src/central/teams/seats.ts +3 -3
  53. package/src/central/teams/staffing.ts +5 -0
  54. package/src/central/teams/store.ts +5 -2
  55. package/src/central/teams/validation.ts +8 -5
  56. package/src/spoke/spoke.ts +19 -0
  57. package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
  58. package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  59. package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  60. package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
  61. package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
  62. package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  63. package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
  64. package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
  65. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  66. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  67. package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
  68. package/web/dist/assets/live-BmqLASSo.js +5 -0
  69. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  70. package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
  71. package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
  72. package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
  73. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  74. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  75. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  76. package/web/dist/index.html +5 -5
  77. package/web/dist/assets/index-BS6lA6fN.js +0 -33
  78. package/web/dist/assets/live-CVUFIdUX.js +0 -5
@@ -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 { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, 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, type ResultFor, type SessionModelPrefs } from "../protocol"
11
+ import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, RECORDING_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, type ResultFor, type SessionModelPrefs, type SessionSummary } 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"
@@ -60,7 +60,7 @@ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./
60
60
  import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
61
61
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
62
62
  import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
63
- import { audioFormat, SpeechError } from "./speech/provider"
63
+ import { audioFormat, SpeechError, type TranscribeStats } from "./speech/provider"
64
64
  import { SpeechService } from "./speech/service"
65
65
  import { NodeRegistry } from "./nodes"
66
66
  import { ConnDirectory } from "./cluster/directory"
@@ -85,7 +85,7 @@ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinato
85
85
  import { artifactJournalEvent } from "./artifacts/events"
86
86
  import { ArtifactMaintenance } from "./artifacts/maintenance"
87
87
  import { MemoryBlobStore } from "./backend/memory-blobs"
88
- import { RecordingService } from "./recording"
88
+ import { RecordingService, type DevLimits } from "./recording"
89
89
  import { recordingRoutes } from "./recording/routes"
90
90
  import { wrapConciergeSend } from "./recording/concierge-taps"
91
91
  import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
@@ -100,6 +100,7 @@ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
100
100
  import { TeamSeatStore } from "./teams/seats"
101
101
  import { TeamStaffingService } from "./teams/staffing"
102
102
  import { intakeActor, TeamIntakeService } from "./teams/intake"
103
+ import { TeamHopStalls } from "./teams/hop-stalls"
103
104
  import { TeamStore } from "./teams/store"
104
105
  import { ActivityTracker } from "./concierge/activity"
105
106
  import { HumanActivity, withHumanActivity } from "./human-activity"
@@ -137,7 +138,8 @@ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEY
137
138
  const HUMAN_ACTIVITY_STOP_MS = 3_000
138
139
 
139
140
  export interface Speech {
140
- transcribe(audio: Uint8Array, format: string): Promise<string>
141
+ /** `stats` (recording mode): the provider fills in its upstream request's status, bytes and time to headers. */
142
+ transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
141
143
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
142
144
  /** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
143
145
  speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
@@ -149,6 +151,8 @@ export interface CentralOptions {
149
151
  readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
150
152
  /** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
151
153
  readonly registryTimers?: Timers
154
+ /** Developer mode's recording limits (tests shrink them; default DEV_LIMITS). */
155
+ readonly recordingDevLimits?: Partial<DevLimits>
152
156
  /** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
153
157
  readonly webDir?: string
154
158
  readonly web?: WebAssets
@@ -453,7 +457,7 @@ class HttpError extends Error {
453
457
  /** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
454
458
  /** (AF3) The same, as an /api/agent error. */
455
459
  const unsupportedAgentError = (feature: string, harness: string) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
456
- const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
460
+ const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert" | "setModel"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
457
461
 
458
462
  const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
459
463
  Response.json({ error: message, ...(code ? { code } : {}) }, { status, ...(headers ? { headers } : {}) })
@@ -888,7 +892,7 @@ export function startCentral(options: CentralOptions): Central {
888
892
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
889
893
  // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
890
894
  let teamSeats: TeamSeatStore | undefined
891
- const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
895
+ const teamStore = new TeamStore({ state: backends.state, now, globalHopLimit: config.hopLimit, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
892
896
  const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
893
897
  teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
894
898
  const teamPolicy = new TeamPolicy({
@@ -901,8 +905,35 @@ export function startCentral(options: CentralOptions): Central {
901
905
  await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
902
906
  },
903
907
  })
908
+ let decisions: Decisions
909
+ const teamHopStalls = new TeamHopStalls({
910
+ state: backends.state,
911
+ now,
912
+ seats: async (userID, teamID) => [...new Set((await teamSeats!.list(userID, teamID, { current: true })).map((seat) => seat.sessionID))],
913
+ reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
914
+ close: async (userID, decisionID) => { await decisions.close(userID, decisionID, "A person continued this team's work; renewal is no longer needed") },
915
+ notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
916
+ open: async (userID, teamID, sender, recipient, limit) => {
917
+ const team = await teamStore.require(userID, teamID)
918
+ const intake = await teamIntake?.list(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, teamID, { limit: 50 })
919
+ const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
920
+ const detail = active ? `, intake ${active.id} (${active.title})` : ""
921
+ const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
922
+ question: `Team #${team.name}${detail} reached its ${limit}-hop limit (${sender} → ${recipient}). Renew this team's seats?`,
923
+ options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
924
+ urgency: "high", tier: "tap", about: [recipient],
925
+ }, { action: { kind: "teamHopRenew", teamID } })
926
+ return decision.id
927
+ },
928
+ })
904
929
  const delivery = new Delivery({
930
+ hopLimit: config.hopLimit,
905
931
  teams: teamPolicy,
932
+ teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
933
+ teamHopNotice: async (userID, teamID) => {
934
+ const team = await teamStore.get(userID, teamID)
935
+ return (await teamSeats!.list(userID, teamID, { current: true })).find((seat) => seat.role === team?.entryRole)?.sessionID
936
+ },
906
937
  // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
907
938
  inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
908
939
  state: backends.state,
@@ -937,7 +968,7 @@ export function startCentral(options: CentralOptions): Central {
937
968
  /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
938
969
  let conciergeTierHooks: ConciergeTiers | undefined
939
970
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
940
- const decisions = new Decisions({
971
+ decisions = new Decisions({
941
972
  state: backends.state,
942
973
  delivery,
943
974
  now,
@@ -954,6 +985,13 @@ export function startCentral(options: CentralOptions): Central {
954
985
  // (CON-06) Concierge proposals run their verb as the user (all set up below).
955
986
  act: (userID, decision, choice, by) => {
956
987
  const kind = decision.action?.kind
988
+ if (kind === "teamHopRenew") {
989
+ if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
990
+ const teamID = decision.action!.teamID
991
+ return choice.option === "renew"
992
+ ? teamHopStalls.renew(userID, teamID, decision.id).then(async () => { await audit.record(userID, { actor: { kind: "user" }, verb: "team.hop.renew", target: {}, decision: "allow", outcome: "ok" }); return "done" as const })
993
+ : Promise.resolve("done" as const)
994
+ }
957
995
  if (kind === "act") {
958
996
  if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
959
997
  return conciergeTierHooks.act(userID, decision, choice, by)
@@ -975,6 +1013,7 @@ export function startCentral(options: CentralOptions): Central {
975
1013
  const tenant = registry.sessionTenant(sessionID)
976
1014
  if (tenant === undefined) return
977
1015
  await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
1016
+ await teamHopStalls.clearForHuman(tenant, sessionID).catch((cause) => console.error(`[hub] team hop reset failed: ${(cause as Error).message}`))
978
1017
  }
979
1018
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
980
1019
  // Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
@@ -1313,6 +1352,23 @@ export function startCentral(options: CentralOptions): Central {
1313
1352
  audit: (entry) => store.audit(entry),
1314
1353
  users: allUsers,
1315
1354
  log: (message) => console.error(message),
1355
+ pod: config.cluster?.podID ?? "hub",
1356
+ // (Developer mode) What ran a turn: the session's model and context size from its spoke (summary), counts only.
1357
+ turnFacts: async (_userID, sessionID) => {
1358
+ const summary = (await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary
1359
+ const model = summary?.model
1360
+ const context = summary?.context
1361
+ const agent = registry.session(sessionID)?.agent
1362
+ return {
1363
+ ...(model?.providerID ? { provider: model.providerID } : {}),
1364
+ ...(model?.modelID ? { model: model.modelID } : context?.model ? { model: context.model } : {}),
1365
+ ...(model?.variant ? { variant: model.variant } : {}),
1366
+ ...(agent ? { agent } : {}),
1367
+ ...(typeof context?.used === "number" ? { contextTokens: context.used } : {}),
1368
+ ...(typeof context?.limit === "number" ? { contextLimit: context.limit } : {}),
1369
+ }
1370
+ },
1371
+ ...(options.recordingDevLimits ? { devLimits: options.recordingDevLimits } : {}),
1316
1372
  })
1317
1373
  recordings = recordingService
1318
1374
  registry.recordingTap = recordingService.registryTap
@@ -1378,7 +1434,7 @@ export function startCentral(options: CentralOptions): Central {
1378
1434
  const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1379
1435
  // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1380
1436
  const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
1381
- const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1437
+ const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, hopStalls: teamHopStalls, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1382
1438
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1383
1439
  launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1384
1440
  // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
@@ -1435,6 +1491,7 @@ export function startCentral(options: CentralOptions): Central {
1435
1491
  const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
1436
1492
  subscriptions = new Subscriptions({
1437
1493
  state: backends.state,
1494
+ hopLimit: config.hopLimit,
1438
1495
  events: agentEvents,
1439
1496
  send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
1440
1497
  hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
@@ -1744,7 +1801,7 @@ export function startCentral(options: CentralOptions): Central {
1744
1801
  conversationOf: (sessionID) => registry.conversationOf(sessionID),
1745
1802
  stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
1746
1803
  }
1747
- const agentDeps = { contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, 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, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, 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(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
1804
+ const agentDeps = { nodes, contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, 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, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, 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(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
1748
1805
  const resolveActor = createResolver({
1749
1806
  cookieName: COOKIE,
1750
1807
  findDevice: (token) => deviceOfToken(token),
@@ -2026,16 +2083,18 @@ export function startCentral(options: CentralOptions): Central {
2026
2083
  await store.removeDevice(device.id)
2027
2084
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
2028
2085
  }),
2029
- route("POST", "/api/transcribe", "user", ({ request, user, device }) => transcribe(request, speechFor(user.id), user.id, device.id)),
2030
- route("POST", "/api/speak", "user", async ({ request, user, device }) => {
2031
- const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
2032
- if (!text) return error(400, "Nothing to speak")
2033
- const speech = speechFor(user.id)
2034
- // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
2035
- const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
2036
- const audio = await speakAudio(speech, text, request.signal)
2037
- return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
2038
- }),
2086
+ route("POST", "/api/transcribe", "user", ({ request, user, device }) => requestLined(user.id, device.id, request, "transcribe", () => transcribe(request, speechFor(user.id), user.id, device.id))),
2087
+ route("POST", "/api/speak", "user", ({ request, user, device }) =>
2088
+ requestLined(user.id, device.id, request, "speak", async () => {
2089
+ const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
2090
+ if (!text) return error(400, "Nothing to speak")
2091
+ const speech = speechFor(user.id)
2092
+ // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; in Developer mode the audio is kept too.
2093
+ const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
2094
+ const audio = await speakAudio(speech, text, request.signal)
2095
+ return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
2096
+ }),
2097
+ ),
2039
2098
  // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
2040
2099
  ...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))),
2041
2100
  route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
@@ -2196,11 +2255,14 @@ export function startCentral(options: CentralOptions): Central {
2196
2255
  const change = modelPrefsInput(await jsonBody(request))
2197
2256
  // Only an obsolete Worker model (an older console): nothing changes.
2198
2257
  if (!("voice" in change)) return getModelPrefs(params.session!, user.id)
2199
- await checkModelSelections(ownerOf(params.session!), params.session!, change)
2258
+ await checkModelSelections(ownerOf(params.session!), params.session!, [change.voice])
2200
2259
  const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
2201
2260
  const prefs = await store.setModelPrefs(user.id, params.session!, change)
2202
2261
  return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
2203
2262
  }),
2263
+ // The session's own model (feature "setModel"): every later turn runs on it, whoever prompts. Same access as a
2264
+ // prompt (the session's owner); the Voice model above is separate.
2265
+ route("PUT", "/api/sessions/:session/model", "session", async ({ request, params, user }) => setSessionModel(request, params.session!, user.id)),
2204
2266
  route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
2205
2267
  const limitRaw = url.searchParams.get("limit")
2206
2268
  const limit = limitRaw === null ? undefined : Number(limitRaw)
@@ -2668,6 +2730,20 @@ export function startCentral(options: CentralOptions): Central {
2668
2730
  })
2669
2731
  }
2670
2732
 
2733
+ /**
2734
+ * PUT /api/sessions/:id/model {model}: 400 for a malformed body or a model (variant) the session's catalog lacks,
2735
+ * 409 unsupported when its spoke did not announce setModel (an older plugin: the console's chip stays display only).
2736
+ */
2737
+ async function setSessionModel(request: Request, sessionID: string, userID: string): Promise<Response> {
2738
+ const model = sessionModelInput(await jsonBody(request))
2739
+ const connID = ownerOf(sessionID)
2740
+ if (!registry.supports(connID, "setModel")) throw unsupportedError({ feature: "setModel", harness: registry.harnessOf(connID) ?? "unknown" })
2741
+ await checkModelSelections(connID, sessionID, [model])
2742
+ const result = await spokes.request(connID, { op: "setModel", sessionID, model })
2743
+ await store.audit({ actor: userID, action: "session.model", target: sessionID, detail: `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ""}` })
2744
+ return Response.json({ model: result.model }, { headers: NO_STORE })
2745
+ }
2746
+
2671
2747
  /** A session's Voice model preference, re-sent to its spoke (which answers with its defaults). */
2672
2748
  async function getModelPrefs(sessionID: string, userID: string): Promise<Response> {
2673
2749
  const prefs = store.modelPrefs(userID, sessionID)
@@ -2706,8 +2782,8 @@ export function startCentral(options: CentralOptions): Central {
2706
2782
  * Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
2707
2783
  * the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
2708
2784
  */
2709
- async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null }): Promise<void> {
2710
- const selections = [change.voice].filter((m): m is ModelSelection => !!m)
2785
+ async function checkModelSelections(connID: string, sessionID: string, picked: ReadonlyArray<ModelSelection | null | undefined>): Promise<void> {
2786
+ const selections = picked.filter((m): m is ModelSelection => !!m)
2711
2787
  if (selections.length === 0) return
2712
2788
  let models: readonly AvailableModel[]
2713
2789
  try {
@@ -3135,6 +3211,28 @@ export function startCentral(options: CentralOptions): Central {
3135
3211
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3136
3212
  }
3137
3213
 
3214
+ /**
3215
+ * (Developer mode) A voice request naming a recording (x-hub-recording): its route, status and time are recorded
3216
+ * as a request line in that recording when it is a developer one (recordingService.requestLine). Never a body.
3217
+ */
3218
+ async function requestLined(userID: string, deviceID: string, request: Request, route: "transcribe" | "speak", handle: () => Promise<Response>): Promise<Response> {
3219
+ const recordingID = request.headers.get(RECORDING_HEADER)
3220
+ if (recordingID === null) return handle()
3221
+ const started = performance.now()
3222
+ let status = 500
3223
+ try {
3224
+ const res = await handle()
3225
+ status = res.status
3226
+ return res
3227
+ } catch (cause) {
3228
+ const s = (cause as { status?: unknown }).status
3229
+ status = cause instanceof SpeechError ? 502 : typeof s === "number" ? s : 500
3230
+ throw cause
3231
+ } finally {
3232
+ void recordingService.requestLine(userID, deviceID, recordingID, { route, method: "POST", status, ms: performance.now() - started }).catch(() => undefined)
3233
+ }
3234
+ }
3235
+
3138
3236
  async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
3139
3237
  const format = audioFormat(request.headers.get("content-type"))
3140
3238
  if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
@@ -3146,9 +3244,10 @@ export function startCentral(options: CentralOptions): Central {
3146
3244
  // (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
3147
3245
  const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
3148
3246
  const started = performance.now()
3149
- const text = await speech.transcribe(audio, format)
3247
+ const stats: TranscribeStats = {}
3248
+ const text = await (tap ? speech.transcribe(audio, format, stats) : speech.transcribe(audio, format))
3150
3249
  if (!tap) return Response.json({ text })
3151
- const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
3250
+ const result = await tap.finish(text, Math.round(performance.now() - started), stats).catch(() => "failed" as const)
3152
3251
  return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
3153
3252
  }
3154
3253
 
@@ -3572,6 +3671,18 @@ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null } {
3572
3671
  return "voice" in out ? { voice: out.voice! } : {}
3573
3672
  }
3574
3673
 
3674
+ /** The body of PUT /api/sessions/:id/model: {model: {providerID, modelID, variant?}}; other keys inside `model` are dropped. */
3675
+ function sessionModelInput(value: unknown): ModelSelection {
3676
+ const invalid = () => new HttpError(400, "Expected {model: {providerID, modelID, variant?}}")
3677
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
3678
+ const record = value as Record<string, unknown>
3679
+ if (Object.keys(record).some((key) => key !== "model")) throw invalid()
3680
+ const m = record.model as Record<string, unknown> | null | undefined
3681
+ if (typeof m !== "object" || m === null || Array.isArray(m)) throw invalid()
3682
+ if (typeof m.providerID !== "string" || !MODEL_ID.test(m.providerID) || typeof m.modelID !== "string" || !MODEL_ID.test(m.modelID) || (m.variant !== undefined && (typeof m.variant !== "string" || !MODEL_ID.test(m.variant)))) throw invalid()
3683
+ return { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" && m.variant !== "default" ? { variant: m.variant } : {}) }
3684
+ }
3685
+
3575
3686
  function param(raw: string): string {
3576
3687
  try {
3577
3688
  return decodeURIComponent(raw)
@@ -5,7 +5,7 @@
5
5
  // entries are reached (the root manifest of "all" reads them once more beforehand).
6
6
  import { RECORDING_AUDIO_TYPE, RECORDING_FORMAT, RECORDING_FORMAT_VERSION, type RecordingEvent } from "../../protocol"
7
7
  import { clockAlignment, type ClockSample, type ClockSummary } from "./clock"
8
- import { type AudioEntry, blobTenant, type RecordingRecord, type RecordingStore } from "./store"
8
+ import { type AudioEntry, blobTenant, type ExtraEntry, isPageKey, type RecordingRecord, type RecordingStore } from "./store"
9
9
  import type { ZipEntry } from "./zip"
10
10
 
11
11
  export interface ExportOptions {
@@ -31,6 +31,8 @@ export interface SegmentManifest {
31
31
  const decoder = new TextDecoder()
32
32
  const encoder = new TextEncoder()
33
33
  const audioFile = (n: number) => `audio/${String(n).padStart(6, "0")}.wav`
34
+ const clipFile = (n: number) => `clips/${String(n).padStart(6, "0")}.wav`
35
+ const replyFile = (n: number) => `replies/${String(n).padStart(6, "0")}.mp3`
34
36
 
35
37
  /** A hub event ID "<podTag>.<n>" as a sort key. */
36
38
  const idKey = (id: unknown): [string, number] => {
@@ -52,6 +54,9 @@ interface Loaded {
52
54
  readonly events: RecordingEvent[]
53
55
  readonly clock: ClockSummary
54
56
  readonly audio: Map<number, AudioEntry>
57
+ /** Developer mode: clips (k…) and reply audio (r…) by number. */
58
+ readonly clips: Map<number, ExtraEntry>
59
+ readonly replies: Map<number, ExtraEntry>
55
60
  }
56
61
 
57
62
  /** Every stored event of a recording (pages that are missing or unreadable are skipped), aligned and sorted. */
@@ -59,11 +64,16 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
59
64
  const index = await store.index<Record<string, unknown>>(userID, record.id)
60
65
  const raw: RecordingEvent[] = []
61
66
  const audio = new Map<number, AudioEntry>()
67
+ const clips = new Map<number, ExtraEntry>()
68
+ const replies = new Map<number, ExtraEntry>()
62
69
  for (const entry of index) {
63
70
  if (entry.key.startsWith("a")) {
64
71
  audio.set(Number(entry.key.slice(1)), entry.value as unknown as AudioEntry)
65
72
  continue
66
73
  }
74
+ if (entry.key.startsWith("k")) clips.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
75
+ if (entry.key.startsWith("r")) replies.set(Number(entry.key.slice(1)), entry.value as unknown as ExtraEntry)
76
+ if (!isPageKey(entry.key)) continue
67
77
  const hash = entry.value.hash
68
78
  if (typeof hash !== "string") continue
69
79
  const blob = await store.blobs.get(blobTenant(userID), hash).catch(() => undefined)
@@ -82,7 +92,36 @@ async function load(store: RecordingStore, userID: string, record: RecordingReco
82
92
  const clock = clockAlignment(samples, record.anchor)
83
93
  const events = raw.map((e) => ({ ...e, ts: e.src === "console" ? clock.ts(e.t ?? 0) : (e.at ?? 0) }) as RecordingEvent)
84
94
  events.sort(compare)
85
- return { events, clock: clock.summary, audio }
95
+ return { events, clock: clock.summary, audio, clips, replies }
96
+ }
97
+
98
+ const sha = (hash: string) => hash.replace(/^sha256:/, "")
99
+
100
+ /** Developer mode: clips[] joined with their audio.clip events (what each belongs to), replies[] with their ref. */
101
+ function extras(loaded: Loaded): { clips: Record<string, unknown>[]; replies: Record<string, unknown>[] } {
102
+ const clipEvents = new Map<number, RecordingEvent>()
103
+ for (const e of loaded.events) if (e.src === "console" && e.kind === "audio.clip" && typeof e.clip === "number") clipEvents.set(e.clip, e)
104
+ const sentences = new Map<string, string>()
105
+ for (const e of loaded.events) {
106
+ const ref = e.ref as ExtraEntry["ref"]
107
+ if (e.src === "console" && e.kind === "tts.sentence.queued" && ref && typeof e.text === "string") sentences.set(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`, e.text)
108
+ }
109
+ const clips = [...loaded.clips].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
110
+ const e = clipEvents.get(n)
111
+ return {
112
+ n,
113
+ file: clipFile(n),
114
+ bytes: entry.bytes,
115
+ sha256: sha(entry.hash),
116
+ ...(e ? { for: e.for, preMs: e.preMs, postMs: e.postMs, ts: e.ts } : {}),
117
+ }
118
+ })
119
+ const replies = [...loaded.replies].sort((a, b) => a[0] - b[0]).map(([n, entry]) => {
120
+ const ref = entry.ref
121
+ const text = ref ? sentences.get(`${ref.messageID}:${ref.ordinal}:${ref.seq ?? ""}:${ref.i}`) : undefined
122
+ return { n, file: replyFile(n), bytes: entry.bytes, sha256: sha(entry.hash), ...(ref ? { ref } : {}), ...(text !== undefined ? { text } : {}), at: entry.at }
123
+ })
124
+ return { clips, replies }
86
125
  }
87
126
 
88
127
  const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
@@ -176,7 +215,15 @@ function manifestEntry(record: RecordingRecord, loaded: Loaded): Record<string,
176
215
  clock: loaded.clock,
177
216
  audio: { format: RECORDING_AUDIO_TYPE, sampleRate: 16000, channels: 1, bitsPerSample: 16 },
178
217
  segments: list,
179
- counts: { events: loaded.events.length, consoleGaps: record.consoleGaps, redactions: record.redactions, segmentsRedacted: redacted, segmentsSkipped: skipped },
218
+ ...(record.developer || loaded.clips.size || loaded.replies.size ? { developer: record.developer === true, ...extras(loaded) } : {}),
219
+ counts: {
220
+ events: loaded.events.length,
221
+ consoleGaps: record.consoleGaps,
222
+ redactions: record.redactions,
223
+ segmentsRedacted: redacted,
224
+ segmentsSkipped: skipped,
225
+ ...(loaded.clips.size || loaded.replies.size || record.developer ? { clips: loaded.clips.size, replies: loaded.replies.size } : {}),
226
+ },
180
227
  }
181
228
  }
182
229
 
@@ -200,6 +247,13 @@ async function* recordingEntries(store: RecordingStore, userID: string, record:
200
247
  if (!blob) continue
201
248
  yield { name: `${prefix}${audioFile(n)}`, body: async () => blob.body }
202
249
  }
250
+ for (const [files, name] of [[loaded.clips, clipFile], [loaded.replies, replyFile]] as const) {
251
+ for (const [n, extra] of [...files].sort((a, b) => a[0] - b[0])) {
252
+ const blob = await store.blobs.get(blobTenant(userID), extra.hash).catch(() => undefined)
253
+ if (!blob) continue
254
+ yield { name: `${prefix}${name(n)}`, body: async () => blob.body }
255
+ }
256
+ }
203
257
  }
204
258
 
205
259
  /** The zip entries of one recording (at the root) or of several (root manifest + one folder each). */