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