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