@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.
- package/CHANGELOG.md +193 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +116 -53
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +92 -7
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +14 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +134 -29
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +91 -6
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +8 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +96 -6
- package/src/spoke/spoke.ts +119 -36
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +23 -2
- package/src/status.ts +33 -6
- package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
- package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- package/web/dist/assets/index-lIOfSYck.js +0 -34
package/src/central/http.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
2167
|
-
|
|
2168
|
-
|
|
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((
|
|
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
|
-
|
|
2199
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
2649
|
-
|
|
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
|
+
}
|