@huynle/hub 0.9.1 → 0.10.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 +309 -0
- package/Dockerfile +5 -1
- package/README.md +153 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -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/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- 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/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +88 -5
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +517 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/push-target.ts +11 -2
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
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, 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 } from "../protocol"
|
|
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"
|
|
12
12
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
13
13
|
import { speakable } from "../speakable"
|
|
14
14
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
@@ -22,7 +22,11 @@ import { createUnfurl } from "./media-unfurl"
|
|
|
22
22
|
import { createFetchBudget } from "./fetch-budget"
|
|
23
23
|
import { createTimelineMedia } from "./media-timeline"
|
|
24
24
|
import { Webhooks } from "./agent/webhooks"
|
|
25
|
-
import {
|
|
25
|
+
import { subscriptionActor, Subscriptions } from "./agent/subscriptions"
|
|
26
|
+
import { subscriptionResolver } from "./agent/subscription-routes"
|
|
27
|
+
import { APP_SCOPES, issueAppToken } from "./agent/app-tokens"
|
|
28
|
+
import { AppConnect } from "./agent/connect"
|
|
29
|
+
import { clientAddress } from "./client-address"
|
|
26
30
|
import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent, conciergeEvent, type AgentEvent } from "./agent/events"
|
|
27
31
|
import { EventWriters } from "./agent/event-writers"
|
|
28
32
|
import { MemoryEventLog } from "./backend/memory-events"
|
|
@@ -36,6 +40,7 @@ import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from
|
|
|
36
40
|
import { Acting, type Escalate } from "./agent/acting"
|
|
37
41
|
import { Escalations } from "./agent/escalation"
|
|
38
42
|
import { GrantRequests } from "./agent/grant-requests"
|
|
43
|
+
import { ConciergeGrantRequests, conciergeGrantRequestRoutes, watchedProjects } from "./concierge/grant-request"
|
|
39
44
|
import { partyFromActor } from "./agent/envelope"
|
|
40
45
|
import { Delivery } from "./agent/delivery"
|
|
41
46
|
import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
|
|
@@ -45,12 +50,14 @@ import { hubDirectory } from "./agent/directory-routes"
|
|
|
45
50
|
import type { Directory } from "./agent/directory"
|
|
46
51
|
import { installResponse } from "./install/render"
|
|
47
52
|
import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
|
|
53
|
+
import type { AppSpeech } from "./agent/speech-routes"
|
|
48
54
|
import { MCP_PATH, mcpHttp } from "./mcp-http"
|
|
49
55
|
import { SessionTokens } from "./agent/session-tokens"
|
|
50
56
|
import { SessionTokenPush } from "./session-token-push"
|
|
51
57
|
import { TokenKeys } from "./token-keys"
|
|
52
58
|
import { EventLog } from "./events-log"
|
|
53
|
-
import { BusyError, Registry, type Timers } from "./registry"
|
|
59
|
+
import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./registry"
|
|
60
|
+
import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
|
|
54
61
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
55
62
|
import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
|
|
56
63
|
import { audioFormat, SpeechError } from "./speech/provider"
|
|
@@ -95,11 +102,17 @@ import { TeamStaffingService } from "./teams/staffing"
|
|
|
95
102
|
import { intakeActor, TeamIntakeService } from "./teams/intake"
|
|
96
103
|
import { TeamStore } from "./teams/store"
|
|
97
104
|
import { ActivityTracker } from "./concierge/activity"
|
|
105
|
+
import { HumanActivity, withHumanActivity } from "./human-activity"
|
|
98
106
|
import { ResultTracker } from "./concierge/board-results"
|
|
99
107
|
import { BoardRevisions } from "./concierge/board"
|
|
100
108
|
import { conciergeBoard } from "./concierge/board-routes"
|
|
109
|
+
import { renderFrontBoard } from "./concierge/board-render"
|
|
101
110
|
import { ContextPusher } from "./concierge/context"
|
|
102
111
|
import { C1_ENTITLEMENTS } from "./concierge/entitlements"
|
|
112
|
+
import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
|
|
113
|
+
import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
|
|
114
|
+
import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
|
|
115
|
+
import type { ChatClient } from "./llm/chat"
|
|
103
116
|
import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge/inbound"
|
|
104
117
|
import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
|
|
105
118
|
import { watchedSessions } from "./concierge/notifier"
|
|
@@ -113,11 +126,16 @@ import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Sup
|
|
|
113
126
|
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
|
|
114
127
|
import { CONCIERGE_FORCED_PUSH_MS, INTERNAL_AGENTS, KNOWN_PRIMARY_AGENTS } from "./concierge/context"
|
|
115
128
|
import { grantState } from "./agent/grants"
|
|
116
|
-
import {
|
|
129
|
+
import { AppStreams, MAX_UTTERANCE_CHARS, type ConversationPorts, type StreamSource } from "./agent/conversation"
|
|
130
|
+
import { newMessageID } from "./agent/messages"
|
|
131
|
+
import { catalogHarnesses, eventVisibility, TeamDiscovery } from "./teams/discovery"
|
|
117
132
|
import { inRevertWindow, RevertWindows } from "./revert-windows"
|
|
118
133
|
import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
|
|
119
134
|
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"
|
|
120
135
|
|
|
136
|
+
/** How long shutdown waits for buffered session activity to be written (HumanActivity.stop). */
|
|
137
|
+
const HUMAN_ACTIVITY_STOP_MS = 3_000
|
|
138
|
+
|
|
121
139
|
export interface Speech {
|
|
122
140
|
transcribe(audio: Uint8Array, format: string): Promise<string>
|
|
123
141
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
@@ -151,8 +169,12 @@ export interface CentralOptions {
|
|
|
151
169
|
readonly now?: () => number
|
|
152
170
|
/** Shared state for new code (default: in memory, see ./backend). */
|
|
153
171
|
readonly backends?: Backends
|
|
172
|
+
/** Context cards: a fake model client and the trigger debounce (tests). */
|
|
173
|
+
readonly contextCards?: { readonly chat?: ChatClient; readonly debounceMs?: number }
|
|
154
174
|
/** More bearer verifiers and /api/agent routes, after the built-in ones (tests). */
|
|
155
175
|
readonly agent?: { readonly verifiers?: readonly BearerVerifier[]; readonly routes?: readonly AgentRoute[] }
|
|
176
|
+
/** (AF3) App conversation streams: heartbeat, re-check interval and unsent-bytes limit (tests shorten them). */
|
|
177
|
+
readonly appStreams?: { readonly heartbeatMs?: number; readonly recheckMs?: number; readonly bufferMaxBytes?: number }
|
|
156
178
|
readonly timing?: {
|
|
157
179
|
readonly helloTimeoutMs?: number
|
|
158
180
|
readonly requestTimeoutMs?: number
|
|
@@ -162,6 +184,10 @@ export interface CentralOptions {
|
|
|
162
184
|
/** (S4 M3) The longest one timeline image may take from the spoke, all chunks together (default 60 s). */
|
|
163
185
|
readonly mediaDeadlineMs?: number
|
|
164
186
|
readonly heartbeatMs?: number
|
|
187
|
+
/** (Session findability) How often buffered activity is checked for a due write (default 15 s). */
|
|
188
|
+
readonly humanActivityFlushMs?: number
|
|
189
|
+
/** (Session findability) At most one activity write per session per this long (default 60 s). */
|
|
190
|
+
readonly humanActivityWriteMs?: number
|
|
165
191
|
readonly staleMs?: number
|
|
166
192
|
/** Per node op request timeouts (see NODE_TIMEOUTS_MS). */
|
|
167
193
|
readonly nodeTimeoutsMs?: SpokeHubOptions["nodeTimeoutsMs"]
|
|
@@ -210,6 +236,8 @@ export interface Central {
|
|
|
210
236
|
readonly launchTokens: LaunchTokens
|
|
211
237
|
/** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
|
|
212
238
|
readonly webhooks: Webhooks
|
|
239
|
+
/** Session subscriptions (decision 74qawely): records and the leader's dispatcher (tests run a pass with tick). */
|
|
240
|
+
readonly subscriptions: Subscriptions
|
|
213
241
|
/** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
|
|
214
242
|
readonly agentEvents: AgentEvents
|
|
215
243
|
/** The agent directory (names, roles, address resolution). */
|
|
@@ -220,6 +248,8 @@ export interface Central {
|
|
|
220
248
|
readonly agentEventWriters: EventWriters
|
|
221
249
|
/** (CON-02) Session intent retention (its leader runs it every 6 h; tests run a pass with runOnce). */
|
|
222
250
|
readonly intentRetention: IntentRetention
|
|
251
|
+
/** Context cards (decision 9604b0a1; docs/context-cards.md): providers, engine, store, budget, settings. */
|
|
252
|
+
readonly contextCards: ContextCardService
|
|
223
253
|
/** (REC-01) Voice & Concierge recordings (developer): the owner-only store, taps, export and retention. */
|
|
224
254
|
readonly recordings: RecordingService
|
|
225
255
|
/** The JSON API's route table (method, path, access), for audits and tests. */
|
|
@@ -350,7 +380,7 @@ const VIEW_PREF_MAX_ENTRIES = 500
|
|
|
350
380
|
const VIEW_PREF_MAX_CHARS = 4096
|
|
351
381
|
const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
|
|
352
382
|
/** Longest utterance accepted for conversation mode (characters, after trimming). */
|
|
353
|
-
export
|
|
383
|
+
export { MAX_UTTERANCE_CHARS }
|
|
354
384
|
/** Longest typed prompt or steer accepted (characters, after trimming); a bigger one would not fit in a frame. */
|
|
355
385
|
export const MAX_PROMPT_CHARS = 100_000
|
|
356
386
|
/** The shell's answer when the hub has no built web app (e.g. the plugin installed from GitHub). */
|
|
@@ -366,6 +396,8 @@ const SPEECH_TEST_TEXT = "Hi! This is how I will sound when I read replies to yo
|
|
|
366
396
|
const SPEECH_TEST_MAX_CHARS = 300
|
|
367
397
|
// Primary-navigation destinations (web/src/app/nav.ts) and one sub-path level, plus legacy routes the console
|
|
368
398
|
// redirects (birds-eye → watchtower, nav.ts LEGACY_ROUTES); test/central-web.test.ts checks the registry.
|
|
399
|
+
/** (AF1) The consent page: the console's SPA, never framed. */
|
|
400
|
+
const CONSENT_PAGE = /^\/connect\/consent\/hcf_[0-9a-f]{24}$/
|
|
369
401
|
const APP_ROUTES = /^\/(?:|pair|approve|new|settings(?:\/[a-z-]+)?|panes|concierge(?:\/[^/]+)?|s\/[^/]+|(?:artifacts|teams|watchtower|birds-eye|profiles)(?:\/[^/]+)?)$/
|
|
370
402
|
const STATIC_PATH = /^(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+$/
|
|
371
403
|
/** Where a one-time sign-in link may land: home, or a session (with Voice mode starting: `?voice=1`). */
|
|
@@ -419,6 +451,8 @@ class HttpError extends Error {
|
|
|
419
451
|
}
|
|
420
452
|
|
|
421
453
|
/** A core op the session's spoke does not serve (another harness's spoke): 409 code unsupported, like plugin_outdated. */
|
|
454
|
+
/** (AF3) The same, as an /api/agent error. */
|
|
455
|
+
const unsupportedAgentError = (feature: string, harness: string) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
|
|
422
456
|
const unsupportedError = (lacking: { readonly feature: Unsupported["feature"] | "fork" | "revert"; readonly harness: string }) => new HttpError(409, unsupportedMessage(lacking), "unsupported")
|
|
423
457
|
|
|
424
458
|
const error = (status: number, message: string, code?: string, headers?: Record<string, string>) =>
|
|
@@ -454,6 +488,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
454
488
|
const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
|
|
455
489
|
/** SSE subscribers, one user each. */
|
|
456
490
|
const listeners = new Set<Listener>()
|
|
491
|
+
/** (AF3) Open app conversation streams of this process: caps and revocation pulses. */
|
|
492
|
+
const appStreams = new AppStreams()
|
|
457
493
|
/** Recent messages per user, replayed to a stream that reconnects with Last-Event-ID. */
|
|
458
494
|
const eventLog = new EventLog()
|
|
459
495
|
/** Whether a listener wants a message (lite streams skip other sessions' streamed text). */
|
|
@@ -582,6 +618,29 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
582
618
|
const secure = publicURL.startsWith("https:")
|
|
583
619
|
|
|
584
620
|
auth = new Auth({ store, publicURL, now, mode: config.auth.mode, kv: backends.kv, counters: backends.counters })
|
|
621
|
+
// (AF1) Connect an app: registered clients, the consent flow, connected-app tokens. Ending a connection deletes the
|
|
622
|
+
// grants it holds and the webhooks it registered (app:<connection>); its launches keep running.
|
|
623
|
+
const appConnect = new AppConnect({
|
|
624
|
+
store,
|
|
625
|
+
kv: backends.kv,
|
|
626
|
+
counters: backends.counters,
|
|
627
|
+
publicURL: () => publicURL,
|
|
628
|
+
scopes: APP_SCOPES,
|
|
629
|
+
now,
|
|
630
|
+
ended: async (userID, connectionID) => {
|
|
631
|
+
const holder = `app:${connectionID}` as const
|
|
632
|
+
for (const grant of await grants.list(userID)) if (grant.holder === holder && grant.state === "active") await grants.revoke(userID, grant.id)
|
|
633
|
+
for (const hook of await webhooks.list(userID, holder)) await webhooks.remove(userID, hook.id, holder)
|
|
634
|
+
// (AF3) Its open streams end now (end revoked).
|
|
635
|
+
appStreams.pulse(userID)
|
|
636
|
+
},
|
|
637
|
+
})
|
|
638
|
+
/** The client address for per-address limits (HUB_TRUSTED_PROXIES: client-address.ts). */
|
|
639
|
+
const trustedProxies = parseDenyCidrs((config.trustedProxies ?? []).join(","), "HUB_TRUSTED_PROXIES")
|
|
640
|
+
const addressOf = (request: Request, server: Server<SpokeSocketData>) => clientAddress(request, server.requestIP(request)?.address, trustedProxies)
|
|
641
|
+
/** An app credential of the user: a hand-made token or a connected app (launch flags, webhooks, grants). */
|
|
642
|
+
const appCredential = (userID: string, id: string) => (store.agentData(userID).appTokens ?? []).some((t) => t.id === id) || appConnect.alive(userID, id)
|
|
643
|
+
const appCredentialScopes = (userID: string, id: string) => store.agentData(userID).appTokens?.find((t) => t.id === id)?.scopes ?? appConnect.scopesOf(userID, id)
|
|
585
644
|
// options.fetch is for speech providers only; sign-in always talks to the real issuer.
|
|
586
645
|
const oidc = config.auth.mode === "oidc" ? new OidcClient({ config: config.auth, publicURL, now, kv: backends.kv, counters: backends.counters }) : undefined
|
|
587
646
|
/** Speech settings scope of a user: "local" (pairing mode) uses the server default directly. */
|
|
@@ -608,6 +667,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
608
667
|
...options.timing,
|
|
609
668
|
})
|
|
610
669
|
spokes.ready().catch((cause) => console.error(`[hub] cluster routing did not start: ${(cause as Error).message}`))
|
|
670
|
+
// (voice front P2) The Concierge front's relays: checked and run by the hub (relayToSession below).
|
|
671
|
+
spokes.relayToSession = (owner, connID, req) => relayToSession(owner, connID, req)
|
|
611
672
|
// Cluster mode: this pod publishes its connections (spokes and nodes) per user and mirrors the other pods', so the
|
|
612
673
|
// registries' reads cover the whole cluster (design §9).
|
|
613
674
|
const view = config.cluster
|
|
@@ -926,6 +987,19 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
926
987
|
}
|
|
927
988
|
})
|
|
928
989
|
registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
|
|
990
|
+
// (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
|
|
991
|
+
// owner's consoles get each change as {type: "activity", sessionID, activity}.
|
|
992
|
+
const humanActivity = new HumanActivity({
|
|
993
|
+
state: backends.state,
|
|
994
|
+
now,
|
|
995
|
+
...(options.timing?.humanActivityFlushMs !== undefined ? { flushEveryMs: options.timing.humanActivityFlushMs } : {}),
|
|
996
|
+
...(options.timing?.humanActivityWriteMs !== undefined ? { writeEveryMs: options.timing.humanActivityWriteMs } : {}),
|
|
997
|
+
onChange: (userID, sessionID, activity) => broadcast({ type: "activity", sessionID, activity }, { tenant: userID }),
|
|
998
|
+
})
|
|
999
|
+
// A prompt typed in the terminal (exact: what the hub carried is marked so, see Registry.onTerminalPrompt).
|
|
1000
|
+
registry.onTerminalPrompt((rootID, tenant) => humanActivity.human(tenant, rootID, "prompt-tui"))
|
|
1001
|
+
registry.onAgentWork((rootID, tenant, what) => humanActivity.agent(tenant, rootID, what))
|
|
1002
|
+
humanActivity.start()
|
|
929
1003
|
// (S4 M3) Timeline images, cached per user on this pod; a deleted session or a committed revert forgets them.
|
|
930
1004
|
const timelineMedia = createTimelineMedia({
|
|
931
1005
|
request: (connID, req) => spokes.request(connID, req),
|
|
@@ -940,9 +1014,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
940
1014
|
timelineMedia.forgetSession(sessionID)
|
|
941
1015
|
}
|
|
942
1016
|
})
|
|
943
|
-
registry.onSessionDeleted((sessionID) => {
|
|
1017
|
+
registry.onSessionDeleted((sessionID, tenant) => {
|
|
944
1018
|
mediaRevertCommits.delete(sessionID)
|
|
945
1019
|
timelineMedia.forgetSession(sessionID)
|
|
1020
|
+
// (AF3) Its app streams end now (gone); the directory remembers it for every pod (their re-checks, new connects).
|
|
1021
|
+
appStreams.deleted(tenant, sessionID)
|
|
1022
|
+
void directory.markDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] directory delete mark failed: ${(cause as Error).message}`))
|
|
946
1023
|
})
|
|
947
1024
|
// (CON-02) Session intents; the journal is created below (appends happen only once requests arrive).
|
|
948
1025
|
const intents = new SessionIntents({
|
|
@@ -956,14 +1033,17 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
956
1033
|
* Permission, form, prompt and stop execution, shared by the console routes and the agent act verbs. Every
|
|
957
1034
|
* prompt the hub carries derives the session's intent once (CON-02).
|
|
958
1035
|
*/
|
|
959
|
-
const actions = withPromptIntents(createActions({
|
|
1036
|
+
const actions = withHumanActivity(withPromptIntents(createActions({
|
|
960
1037
|
registry,
|
|
961
1038
|
spokes,
|
|
962
1039
|
humanTurn,
|
|
963
1040
|
kv: backends.kv,
|
|
964
1041
|
// Cluster mode: a turn also holds the session's lease, so two pods never start turns in it at once.
|
|
965
1042
|
...(config.cluster ? { turns: { locks: backends.locks, owner: leaseOwner, ttlMs: config.turnTimeoutMs + TURN_LEASE_SLACK_MS } } : {}),
|
|
966
|
-
}), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title)
|
|
1043
|
+
}), intents, (sessionID) => registry.sessionTenant(sessionID), (sessionID) => registry.session(sessionID)?.title), humanActivity, {
|
|
1044
|
+
tenant: (sessionID) => registry.sessionTenant(sessionID),
|
|
1045
|
+
root: (sessionID) => registry.rootOf(sessionID) ?? sessionID,
|
|
1046
|
+
})
|
|
967
1047
|
leader("delivery", { onElected: () => delivery.start(), onDeposed: () => delivery.stop() })
|
|
968
1048
|
// Launches (design §6): placement onto the user's nodes, prepare on the node, the session through the machine's
|
|
969
1049
|
// service spoke, then its end; restart recovery runs on start. Every change goes to launchFeed: the owner's
|
|
@@ -1006,7 +1086,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1006
1086
|
...(options.timing?.launchSettleMs !== undefined ? { settleMs: options.timing.launchSettleMs } : {}),
|
|
1007
1087
|
...(options.timing?.launchTickMs !== undefined ? { tickMs: options.timing.launchTickMs } : {}),
|
|
1008
1088
|
// FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
|
|
1009
|
-
launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(
|
|
1089
|
+
launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !appCredential(userID, by.slice(4)) },
|
|
1010
1090
|
profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
|
|
1011
1091
|
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
1012
1092
|
...(config.cluster
|
|
@@ -1061,8 +1141,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1061
1141
|
users: () => store.users().map((u) => u.id),
|
|
1062
1142
|
resolve: options.resolveHost ?? systemResolveHost,
|
|
1063
1143
|
...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
|
|
1064
|
-
|
|
1065
|
-
|
|
1144
|
+
...(config.webhooksPrivateHosts?.length ? { privateHosts: config.webhooksPrivateHosts } : {}),
|
|
1145
|
+
appAlive: (userID, tokenID) => {
|
|
1146
|
+
const scopes = appCredentialScopes(userID, tokenID)
|
|
1147
|
+
return Array.isArray(scopes) && WEBHOOK_APP_SCOPES.some((scope) => scopes.includes(scope))
|
|
1148
|
+
},
|
|
1149
|
+
appScopes: appCredentialScopes,
|
|
1066
1150
|
userAgent: `hub/${options.version ?? "0.0.0"}`,
|
|
1067
1151
|
})
|
|
1068
1152
|
const webhookLeader = leader("webhooks", {
|
|
@@ -1075,16 +1159,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1075
1159
|
let teamIntake: TeamIntakeService | undefined // HWA-36: drains queued intake on staffing/presence events (below)
|
|
1076
1160
|
/** (CON-04) Journal appends for the Concierge (context refresh, CON-05's notifier); set once the service exists. */
|
|
1077
1161
|
let conciergeOnAppend: ((userID: string, event: AgentEvent) => void) | undefined
|
|
1162
|
+
/** Session subscriptions (set below, once team discovery exists): matching appends schedule a pass. */
|
|
1163
|
+
let subscriptions: Subscriptions | undefined
|
|
1078
1164
|
// CON-01: the Concierge watchlist's lastActiveAt per session, from appends (best effort, never awaited; observe never
|
|
1079
1165
|
// rejects, and the catch keeps it that way: an unhandled rejection would end the process).
|
|
1080
1166
|
const conciergeActivity = new ActivityTracker({ state: backends.state, now })
|
|
1081
1167
|
// CON-03: the board's last result per root session, from appends (best effort, as above).
|
|
1082
1168
|
const conciergeResults = new ResultTracker({ state: backends.state, now })
|
|
1169
|
+
/** Context cards (set below, once intents and the journal exist); journal appends trigger them. */
|
|
1170
|
+
let contextCards: ContextCardService | undefined
|
|
1083
1171
|
const agentEvents = new AgentEvents({
|
|
1084
1172
|
log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
|
|
1085
1173
|
kv: backends.kv,
|
|
1086
1174
|
now,
|
|
1087
|
-
onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
|
|
1175
|
+
onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
|
|
1088
1176
|
})
|
|
1089
1177
|
const agentEventWriters = new EventWriters({
|
|
1090
1178
|
events: agentEvents,
|
|
@@ -1144,6 +1232,58 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1144
1232
|
})
|
|
1145
1233
|
leader("session-intents", { onElected: () => intentRetention.start(), onDeposed: () => intentRetention.stop() })
|
|
1146
1234
|
if (artifactMaintenance) leader("artifacts", { onElected: () => artifactMaintenance.start(), onDeposed: () => artifactMaintenance.stop() })
|
|
1235
|
+
// Context cards (decision 9604b0a1; docs/context-cards.md): the hub calls a light model for a per-session
|
|
1236
|
+
// pick-up card. Automatic runs happen on the "context-cards" leader (other pods forward their triggers to it).
|
|
1237
|
+
const ownSession = (userID: string, sessionID: string) => registry.sessionTenant(sessionID) === userID
|
|
1238
|
+
const cards = new ContextCardService({
|
|
1239
|
+
state: backends.state,
|
|
1240
|
+
config: config.contextCards ?? {},
|
|
1241
|
+
keySources: () => ({ ...(config.apiKey ? { envKey: config.apiKey } : {}), speech: [store.speechSettings(), ...(config.speech ? [config.speech] : [])] }),
|
|
1242
|
+
...(options.contextCards?.chat ? { chat: options.contextCards.chat } : {}),
|
|
1243
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
1244
|
+
inputs: {
|
|
1245
|
+
session: (userID, sessionID) => (ownSession(userID, sessionID) ? registry.session(sessionID) : undefined),
|
|
1246
|
+
intent: async (userID, sessionID) => (await intents.read(userID, sessionID))?.text,
|
|
1247
|
+
timeline: async (userID, sessionID) => {
|
|
1248
|
+
if (!ownSession(userID, sessionID)) return []
|
|
1249
|
+
const page = (await spokes.request(ownerOf(sessionID), { op: "timeline", sessionID, limit: TRANSCRIPT_ROWS, order: "desc" })) as { items?: unknown[] }
|
|
1250
|
+
return page?.items ?? []
|
|
1251
|
+
},
|
|
1252
|
+
summary: async (userID, sessionID) => (ownSession(userID, sessionID) ? spokes.request(ownerOf(sessionID), { op: "summary", sessionID }) : undefined),
|
|
1253
|
+
},
|
|
1254
|
+
isLeader: () => cardsLeader.isLeader(),
|
|
1255
|
+
forward: (userID, sessionID, trigger, humanAt) => void cardsLeader.kick({ userID, sessionID, trigger, ...(humanAt !== undefined ? { humanAt } : {}) }),
|
|
1256
|
+
onCard: (userID, card) => {
|
|
1257
|
+
broadcast({ type: "contextCard", sessionID: card.sessionID, provider: card.provider, ...(card.primary ? { primary: true } : {}), generatedAt: card.generatedAt }, { tenant: userID })
|
|
1258
|
+
void agentEvents.append(userID, contextCardEvent(card)).catch((cause) => console.error(`[hub] session.contextCard event failed: ${(cause as Error).message}`))
|
|
1259
|
+
},
|
|
1260
|
+
autoName: async (userID, sessionID) => {
|
|
1261
|
+
if (ownSession(userID, sessionID)) await autoName(sessionID)
|
|
1262
|
+
},
|
|
1263
|
+
// A session deleted while its card was made gets none (the directory marks it; the registry may still list it).
|
|
1264
|
+
exists: async (userID, sessionID) => !(store.agentData(userID).sessions ?? []).some((s) => s.sessionID === sessionID && s.deletedAt !== undefined),
|
|
1265
|
+
title: (userID, sessionID) => (ownSession(userID, sessionID) ? registry.session(sessionID)?.title : undefined),
|
|
1266
|
+
// (Session findability) The automatic cards' gate: the persisted human activity of the root session.
|
|
1267
|
+
lastHumanAt: (userID, sessionID) => humanActivity.lastAt(userID, registry.rootOf(sessionID) ?? sessionID),
|
|
1268
|
+
users: allUsers,
|
|
1269
|
+
now,
|
|
1270
|
+
...(options.contextCards?.debounceMs !== undefined ? { debounceMs: options.contextCards.debounceMs } : {}),
|
|
1271
|
+
})
|
|
1272
|
+
contextCards = cards
|
|
1273
|
+
const cardsLeader = leader("context-cards", {
|
|
1274
|
+
onElected: () => cards.start(),
|
|
1275
|
+
onDeposed: () => cards.stop(),
|
|
1276
|
+
onKick: (message) => {
|
|
1277
|
+
const m = (message ?? {}) as Record<string, unknown>
|
|
1278
|
+
const trigger = m.trigger as AutoTrigger
|
|
1279
|
+
if (typeof m.userID !== "string" || typeof m.sessionID !== "string" || !CONTEXT_CARD_TRIGGERS.includes(trigger) || (trigger as string) === "manual") return
|
|
1280
|
+
cards.forwarded(m.userID, m.sessionID, trigger, typeof m.humanAt === "number" ? m.humanAt : undefined)
|
|
1281
|
+
},
|
|
1282
|
+
})
|
|
1283
|
+
// User-return cards follow the user's own touches (typed or sent prompts, Voice, answers, explicit opens; rooted),
|
|
1284
|
+
// never a prompt the hub carried for a launch or a Concierge proposal, nor the console's automatic read marks.
|
|
1285
|
+
humanActivity.onTouch((userID, rootID) => cards.human(userID, rootID))
|
|
1286
|
+
registry.onSessionDeleted((sessionID, tenant) => void cards.forget(tenant, sessionID).catch((cause) => console.error(`[hub] context card cleanup failed: ${(cause as Error).message}`)))
|
|
1147
1287
|
for (const loop of leaders) void loop.start()
|
|
1148
1288
|
|
|
1149
1289
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
@@ -1178,7 +1318,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1178
1318
|
registry.recordingTap = recordingService.registryTap
|
|
1179
1319
|
registry.onSessionDeleted((sessionID, tenant) => void recordingService.sessionDeleted(tenant, sessionID).catch((cause) => console.error(`[hub] recording session end failed: ${(cause as Error).message}`)))
|
|
1180
1320
|
void leader("voice-recordings", { onElected: () => recordingService.retention.start(), onDeposed: () => recordingService.retention.stop() }).start()
|
|
1181
|
-
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
|
|
1321
|
+
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs, connections: (owner, id) => appConnect.alive(owner, id), changed: (owner) => appStreams.pulse(owner) })
|
|
1182
1322
|
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
1183
1323
|
/** An agent as envelopes name it (a session by its directory name). */
|
|
1184
1324
|
const describeActor = (userID: string, actor: AgentActor) => {
|
|
@@ -1220,6 +1360,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1220
1360
|
now,
|
|
1221
1361
|
describe: describeActor,
|
|
1222
1362
|
published: (userID, message) => feed.publish(userID, message),
|
|
1363
|
+
// (Concierge grant requests) A new @@concierge grant changes what the Concierge may do: refresh its context soon.
|
|
1364
|
+
granted: (userID, grant) => void (grant.holder === "@@concierge" && conciergeTouch(userID, true)),
|
|
1223
1365
|
})
|
|
1224
1366
|
registry.onPermissionClosed((requestID, _sessionID, tenant) => {
|
|
1225
1367
|
void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
|
|
@@ -1288,6 +1430,26 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1288
1430
|
})
|
|
1289
1431
|
void coordinatorLeader.start() // created after the other leaders were started
|
|
1290
1432
|
const teamDiscovery = new TeamDiscovery({ teams: teamStore, seats: teamSeats, directory, harnesses: catalogHarnesses(registry) })
|
|
1433
|
+
// Session subscriptions (decision 74qawely, docs/subscriptions.md): journal events of a subscriber's targets, batched
|
|
1434
|
+
// into hub messages through Delivery (as app:subscriptions), from the `subscriptions` leader.
|
|
1435
|
+
const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
|
|
1436
|
+
subscriptions = new Subscriptions({
|
|
1437
|
+
state: backends.state,
|
|
1438
|
+
events: agentEvents,
|
|
1439
|
+
send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
|
|
1440
|
+
hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
|
|
1441
|
+
entries: (userID, sessionIDs) => directory.entries(userID, sessionIDs),
|
|
1442
|
+
resolveTarget: subscriptionResolver(directory, teamDiscovery),
|
|
1443
|
+
scope: async (userID, actor) => eventVisibility(await teamDiscovery.census(userID, actor)),
|
|
1444
|
+
appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes("events:subscribe")) === true,
|
|
1445
|
+
// The Concierge admits messages from the user and its own services only (CON-04): it follows sessions through its watchlist.
|
|
1446
|
+
refuseSubscriber: async (userID, sessionID) => ((await isConciergeSession(conciergeTimedIdentity, userID, sessionID)) ? "A Concierge session follows sessions through its watchlist, not subscriptions (hub watchlist watch <addr>)" : undefined),
|
|
1447
|
+
users: allUsers,
|
|
1448
|
+
isLeader: () => subscriptionLeader.isLeader(),
|
|
1449
|
+
now,
|
|
1450
|
+
...(loopTickMs !== undefined ? { tickMs: loopTickMs, observeDelayMs: loopTickMs } : {}),
|
|
1451
|
+
})
|
|
1452
|
+
void subscriptionLeader.start() // created after the other leaders were started
|
|
1291
1453
|
|
|
1292
1454
|
// ---- CON-04 the Concierge: settings, launch, supervise, hide, context ----
|
|
1293
1455
|
const conciergeSettings = new SettingsStore(backends.state, now)
|
|
@@ -1304,12 +1466,30 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1304
1466
|
let conciergeActing: Acting | undefined
|
|
1305
1467
|
const conciergeCoverage = async (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) =>
|
|
1306
1468
|
conciergeActing ? conciergeActing.coverage({ userID, actor: { kind: "session", userID, machineID: "", sessionID: conciergeSessionID, via: "act" } }, sessionID, requestID) : { inGrant: false, auto: false }
|
|
1307
|
-
|
|
1469
|
+
// (Concierge grant requests) The board's project-level inGrant: the verbs the current Concierge's grants allow there.
|
|
1470
|
+
const conciergeProjectGrant = async (userID: string, conciergeSessionID: string, directory: string) => {
|
|
1471
|
+
const actor = { kind: "session" as const, userID, machineID: "", sessionID: conciergeSessionID, via: "act" as const }
|
|
1472
|
+
const verbs = ["approve", "deny", "answer", "prompt", "stop"] as const
|
|
1473
|
+
const usable = await Promise.all(verbs.map((verb) => grants.usable(actor, verb, { directory })))
|
|
1474
|
+
return verbs.filter((_, i) => usable[i]!.length > 0)
|
|
1475
|
+
}
|
|
1476
|
+
const conciergeBoardDeps = { cards, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant, identity: conciergeIdentity, state: backends.state, activity: conciergeActivity, directory, store, registry, decisions, labels: conciergeLabels, intents, results: conciergeResults, armed: conciergeArmed, timeZone: async (userID: string) => (await conciergeSettings.get(userID)).timeZone, now }
|
|
1308
1477
|
const conciergeRevisions = new BoardRevisions(backends.state)
|
|
1478
|
+
// (Concierge grant requests) The Concierge asks for a scoped grant through the grant-request path (tap Decision).
|
|
1479
|
+
const conciergeGrantRequests = new ConciergeGrantRequests({
|
|
1480
|
+
requests: grantRequests,
|
|
1481
|
+
grants,
|
|
1482
|
+
settings: (userID) => conciergeSettings.get(userID),
|
|
1483
|
+
watched: async (userID) => watchedProjects(conciergeBoardDeps, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs),
|
|
1484
|
+
})
|
|
1309
1485
|
const conciergeContext = new ContextPusher({
|
|
1310
1486
|
registry,
|
|
1311
1487
|
send: (connID, req) => spokes.request(connID, req),
|
|
1312
|
-
|
|
1488
|
+
// (P2) The compact board for the Concierge's voice front goes along (to spokes that take it).
|
|
1489
|
+
board: async (userID) => {
|
|
1490
|
+
const view = await conciergeBoard(conciergeBoardDeps, conciergeRevisions, userID, (await conciergeIdentity.current(userID)).hiddenSessionIDs)
|
|
1491
|
+
return { rendered: view.rendered, front: renderFrontBoard(view.board) }
|
|
1492
|
+
},
|
|
1313
1493
|
notes: conciergeNotes,
|
|
1314
1494
|
settings: conciergeSettings,
|
|
1315
1495
|
grant: async (userID, grantID) => {
|
|
@@ -1320,6 +1500,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1320
1500
|
return undefined
|
|
1321
1501
|
}
|
|
1322
1502
|
},
|
|
1503
|
+
// (Concierge grant requests) The @@concierge grants the user approved at its request (named after the grant line).
|
|
1504
|
+
requested: async (userID) => (await grants.list(userID)).filter((g) => g.holder === "@@concierge"),
|
|
1323
1505
|
// (CON-09a) Voice and voice confirm are in the instructions only when speech is set up for the user.
|
|
1324
1506
|
voice: (userID) => options.speech !== undefined || !speechSettings.view(speechScope(userID)).ready.reason,
|
|
1325
1507
|
now,
|
|
@@ -1510,7 +1692,59 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1510
1692
|
onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
|
|
1511
1693
|
})
|
|
1512
1694
|
void conciergeLeader.start()
|
|
1513
|
-
|
|
1695
|
+
/** (AF2) Speech for apps: the user's own speech (speechFor), never the recording taps (REC-01 off for apps). */
|
|
1696
|
+
const appSpeech: AppSpeech = {
|
|
1697
|
+
ready: (userID) => (options.speech ? { stt: true, tts: true } : speechSettings.ready(speechScope(userID))),
|
|
1698
|
+
transcribe: (userID, audio, format) => speechFor(userID).transcribe(audio, format),
|
|
1699
|
+
speak: (userID, text, signal) => speakAudio(speechFor(userID), text, signal),
|
|
1700
|
+
}
|
|
1701
|
+
const speechDeps = { speech: appSpeech, counters: backends.counters, maxAudioBytes: config.maxAudioBytes, maxSpeechChars: config.maxSpeechChars, ...(config.speechQuotas ? { quotas: config.speechQuotas } : {}), audit, now }
|
|
1702
|
+
/**
|
|
1703
|
+
* (AF3) The app conversation stream's events: this process's live stream (the console's listeners and EventLog,
|
|
1704
|
+
* which keeps streamed text too) or, in a cluster, the live fan-out and the shared EventLog (no streamed text).
|
|
1705
|
+
*/
|
|
1706
|
+
const appStreamSource: StreamSource = {
|
|
1707
|
+
clustered: live !== undefined,
|
|
1708
|
+
subscribe(userID, handler) {
|
|
1709
|
+
if (live) {
|
|
1710
|
+
const subscription = live.subscribe(userID, (event) => handler({ ...(event.id !== undefined ? { id: event.id } : {}), message: event.message }))
|
|
1711
|
+
return { ready: subscription.ready, close: () => subscription.close() }
|
|
1712
|
+
}
|
|
1713
|
+
eventLog.open(userID)
|
|
1714
|
+
const listener: Listener = {
|
|
1715
|
+
userID,
|
|
1716
|
+
// Never a device: no message is ever "not for" it.
|
|
1717
|
+
deviceID: "",
|
|
1718
|
+
lite: false,
|
|
1719
|
+
send: (message, id) => {
|
|
1720
|
+
if (message.type !== "catalog") handler({ ...(id !== undefined ? { id } : {}), message })
|
|
1721
|
+
},
|
|
1722
|
+
// The user was disabled or removed: the stream's re-check ends it (end revoked).
|
|
1723
|
+
close: () => appStreams.pulse(userID),
|
|
1724
|
+
// Draining: the stream ends itself on the drain signal (retry:).
|
|
1725
|
+
end: () => {},
|
|
1726
|
+
}
|
|
1727
|
+
listeners.add(listener)
|
|
1728
|
+
return { ready: Promise.resolve(), close: () => void listeners.delete(listener) }
|
|
1729
|
+
},
|
|
1730
|
+
async since(userID, lastID) {
|
|
1731
|
+
if (live) {
|
|
1732
|
+
const missed = await backends.events.since(userID, lastID)
|
|
1733
|
+
return missed && "events" in missed ? missed.events.map(({ id, message }) => ({ id, message })) : undefined
|
|
1734
|
+
}
|
|
1735
|
+
return eventLog.since(userID, lastID)?.map(({ id, message }) => ({ id, message }))
|
|
1736
|
+
},
|
|
1737
|
+
latest: async (userID) => (live ? backends.events.latest(userID) : eventLog.latest(userID)),
|
|
1738
|
+
}
|
|
1739
|
+
/** (AF3) The console's conversation machinery, for the app conversation routes. */
|
|
1740
|
+
const conversationPorts: ConversationPorts = {
|
|
1741
|
+
conversation: (sessionID, on, front, { taps }) => setConversation(sessionID, on, front, undefined, taps),
|
|
1742
|
+
utterance: (sessionID, text, prompt) => sendUtterance(sessionID, text, prompt),
|
|
1743
|
+
interrupt: (sessionID) => interruptSession(sessionID, false),
|
|
1744
|
+
conversationOf: (sessionID) => registry.conversationOf(sessionID),
|
|
1745
|
+
stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
|
|
1746
|
+
}
|
|
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 } }
|
|
1514
1748
|
const resolveActor = createResolver({
|
|
1515
1749
|
cookieName: COOKIE,
|
|
1516
1750
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -1585,6 +1819,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1585
1819
|
}
|
|
1586
1820
|
if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
|
|
1587
1821
|
if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
|
|
1822
|
+
// (AF1) Connect an app: public OAuth endpoints (never behind the console gate) and the consent page.
|
|
1823
|
+
if (method === "GET" && pathname === CONNECT_PATHS.metadata) return appConnect.metadata()
|
|
1824
|
+
if (method === "GET" && pathname === CONNECT_PATHS.authorize) return await appConnect.authorize(url, addressOf(request, server))
|
|
1825
|
+
if (method === "POST" && pathname === CONNECT_PATHS.token) return await appConnect.token(request, addressOf(request, server))
|
|
1826
|
+
if (method === "POST" && pathname === CONNECT_PATHS.revoke) return await appConnect.revoke(request, addressOf(request, server))
|
|
1827
|
+
if (method === "GET" && CONSENT_PAGE.test(pathname)) return await consentPage(request.headers.get("accept-encoding"))
|
|
1588
1828
|
if (method === "GET" && pathname === "/.well-known/hub") {
|
|
1589
1829
|
return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
|
|
1590
1830
|
}
|
|
@@ -1742,6 +1982,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1742
1982
|
return Response.json({ prefs }, { headers: NO_STORE })
|
|
1743
1983
|
}),
|
|
1744
1984
|
route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
|
|
1985
|
+
// (Session findability) The user's touched sessions: {activity: {sessionID: {at?, kind?, buckets}}}, and the
|
|
1986
|
+
// stored intents of the user's sessions for the list's second line: {intents: {sessionID: {text, source}}}.
|
|
1987
|
+
route("GET", "/api/activity/mine", "user", async ({ user }) => Response.json({ activity: await humanActivity.list(user.id) }, { headers: NO_STORE })),
|
|
1988
|
+
route("GET", "/api/intents/mine", "user", async ({ user }) =>
|
|
1989
|
+
Response.json({ intents: Object.fromEntries([...(await intents.all(user.id))].map(([id, intent]) => [id, { text: intent.text, source: intent.source }])) }, { headers: NO_STORE })),
|
|
1990
|
+
// (AF1) The consent page's flow: read it, approve with the scopes kept, or deny. Approve and deny need the device
|
|
1991
|
+
// cookie (same origin, checked above), never a device token; a personal client's flow exists only for its owner.
|
|
1992
|
+
route("GET", "/api/connect/flows/:flow", "user", async ({ params, user }) => connectAnswer(async () => Response.json(await appConnect.flowView(user, params.flow!), { headers: NO_STORE }))),
|
|
1993
|
+
route("POST", "/api/connect/flows/:flow/approve", "user", async ({ request, params, user }) => connectDecision(request, user, params.flow!, "approve")),
|
|
1994
|
+
route("POST", "/api/connect/flows/:flow/deny", "user", async ({ request, params, user }) => connectDecision(request, user, params.flow!, "deny")),
|
|
1745
1995
|
// A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
|
|
1746
1996
|
route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
|
|
1747
1997
|
const login = await auth.cliLogin(params.code!)
|
|
@@ -1783,8 +2033,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1783
2033
|
const speech = speechFor(user.id)
|
|
1784
2034
|
// (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
|
|
1785
2035
|
const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
|
|
1786
|
-
|
|
1787
|
-
const audio = speech.speakStream ? await speech.speakStream(text, request.signal) : await speech.speak(text)
|
|
2036
|
+
const audio = await speakAudio(speech, text, request.signal)
|
|
1788
2037
|
return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
|
|
1789
2038
|
}),
|
|
1790
2039
|
// (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
|
|
@@ -1858,6 +2107,20 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1858
2107
|
return Response.json(view, { headers: NO_STORE })
|
|
1859
2108
|
}),
|
|
1860
2109
|
// (RF2) The user's media allowlist (Settings → Media) and whether this hub fetches web images at all.
|
|
2110
|
+
// Context cards: the primary card of each listed session (the session list's second line), ≤ 200 IDs.
|
|
2111
|
+
route("GET", "/api/context-cards/primary", "user", async ({ url, user }) => {
|
|
2112
|
+
const ids = (url.searchParams.get("sessions") ?? "").split(",").filter(Boolean)
|
|
2113
|
+
if (ids.length > 200 || ids.some((id) => id.length > 200)) return error(400, "At most 200 session IDs")
|
|
2114
|
+
const found = await cards.primaries(user.id, ids.filter((id) => registry.sessionTenant(id) === undefined || registry.sessionTenant(id) === user.id))
|
|
2115
|
+
return Response.json({ cards: Object.fromEntries(found) }, { headers: NO_STORE })
|
|
2116
|
+
}),
|
|
2117
|
+
route("GET", "/api/settings/context-cards", "user", async ({ user }) => Response.json(await cards.settingsView(user.id), { headers: NO_STORE })),
|
|
2118
|
+
route("PUT", "/api/settings/context-cards", "user", async ({ request, user }) => {
|
|
2119
|
+
const input = await body(request)
|
|
2120
|
+
if (typeof input.enabled !== "boolean" || Object.keys(input).some((k) => k !== "enabled")) return error(400, "Expected {enabled: true | false}")
|
|
2121
|
+
await store.audit({ actor: user.id, action: "context-cards.setting", target: user.id, detail: input.enabled ? "on" : "off" })
|
|
2122
|
+
return Response.json(await cards.setEnabled(user.id, input.enabled), { headers: NO_STORE })
|
|
2123
|
+
}),
|
|
1861
2124
|
route("GET", "/api/settings/media", "user", ({ user }) => Response.json(mediaSettingsView(user.id), { headers: NO_STORE })),
|
|
1862
2125
|
route("PUT", "/api/settings/media", "user", async ({ request, user }) => {
|
|
1863
2126
|
await store.setMediaAllowlist(user.id, mediaSettingsInput(await jsonBody(request)))
|
|
@@ -1889,19 +2152,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1889
2152
|
return new Response(null, { status: 204 })
|
|
1890
2153
|
}),
|
|
1891
2154
|
route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session!)),
|
|
1892
|
-
route("POST", "/api/sessions/:session/utterance", "session", ({ request, params }) => utterance(request, params.session
|
|
2155
|
+
route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
|
|
1893
2156
|
route(
|
|
1894
2157
|
"POST",
|
|
1895
2158
|
"/api/sessions/:session/interrupt",
|
|
1896
2159
|
"session",
|
|
1897
2160
|
async ({ params }) => {
|
|
1898
|
-
|
|
1899
|
-
const front = registry.frontRoute(params.session!)
|
|
1900
|
-
// A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
|
|
1901
|
-
const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(params.session!), false)) : false
|
|
1902
|
-
if (!viaFront) await spokes.request(ownerOf(params.session!, "interrupt"), { op: "interrupt", sessionID: params.session! })
|
|
1903
|
-
registry.cancelTurns(params.session!)
|
|
1904
|
-
recordingTap(params.session!, "interrupt", {})
|
|
2161
|
+
await interruptSession(params.session!)
|
|
1905
2162
|
return new Response(null, { status: 204 })
|
|
1906
2163
|
},
|
|
1907
2164
|
),
|
|
@@ -1931,25 +2188,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1931
2188
|
route("POST", "/api/sessions/:session/revert", "session", ({ request, params, user }) => revertSession(request, params.session!, user.id)),
|
|
1932
2189
|
route("POST", "/api/sessions/:session/revert/restore", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertRestore")),
|
|
1933
2190
|
route("POST", "/api/sessions/:session/revert/commit", "session", ({ params, user }) => revertEnd(params.session!, user.id, "revertCommit")),
|
|
1934
|
-
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params }) => conversation(request, params.session
|
|
2191
|
+
route("POST", "/api/sessions/:session/conversation", "session", ({ request, params, user }) => conversation(request, params.session!, user.id), true),
|
|
1935
2192
|
route("GET", "/api/sessions/:session/models", "session", async ({ params }) =>
|
|
1936
2193
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "models", sessionID: params.session! }), { headers: NO_STORE })),
|
|
1937
|
-
route("GET", "/api/sessions/:session/model-prefs", "session",
|
|
1938
|
-
const prefs = store.modelPrefs(user.id, params.session!)
|
|
1939
|
-
const defaults = await spokes.request(ownerOf(params.session!), {
|
|
1940
|
-
op: "setModelPrefs",
|
|
1941
|
-
sessionID: params.session!,
|
|
1942
|
-
voice: prefs.voice ?? null,
|
|
1943
|
-
worker: prefs.worker ?? null,
|
|
1944
|
-
})
|
|
1945
|
-
return Response.json({ prefs, defaults: defaults.defaults }, { headers: NO_STORE })
|
|
1946
|
-
}),
|
|
2194
|
+
route("GET", "/api/sessions/:session/model-prefs", "session", ({ params, user }) => getModelPrefs(params.session!, user.id)),
|
|
1947
2195
|
route("PUT", "/api/sessions/:session/model-prefs", "session", async ({ request, params, user }) => {
|
|
1948
2196
|
const change = modelPrefsInput(await jsonBody(request))
|
|
2197
|
+
// Only an obsolete Worker model (an older console): nothing changes.
|
|
2198
|
+
if (!("voice" in change)) return getModelPrefs(params.session!, user.id)
|
|
1949
2199
|
await checkModelSelections(ownerOf(params.session!), params.session!, change)
|
|
1950
2200
|
const reply = await spokes.request(ownerOf(params.session!), { op: "setModelPrefs", sessionID: params.session!, ...change })
|
|
1951
2201
|
const prefs = await store.setModelPrefs(user.id, params.session!, change)
|
|
1952
|
-
return Response.json({ prefs, defaults: reply.defaults }, { headers: NO_STORE })
|
|
2202
|
+
return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
|
|
1953
2203
|
}),
|
|
1954
2204
|
route("GET", "/api/sessions/:session/timeline", "session", async ({ params, url }) => {
|
|
1955
2205
|
const limitRaw = url.searchParams.get("limit")
|
|
@@ -2012,15 +2262,32 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2012
2262
|
route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
|
|
2013
2263
|
route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
|
|
2014
2264
|
Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
|
|
2265
|
+
// Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
|
|
2266
|
+
route("GET", "/api/sessions/:session/context-cards", "session", async ({ params, user }) => Response.json(await cards.view(user.id, params.session!), { headers: NO_STORE })),
|
|
2267
|
+
route("POST", "/api/sessions/:session/context-cards/:provider/refresh", "session", async ({ params, user, request }) => {
|
|
2268
|
+
if (!cards.registry.get(params.provider!)) return error(404, "No such context card")
|
|
2269
|
+
// {force: true} regenerates even when nothing changed (still deduped and rate limited).
|
|
2270
|
+
const input = request.headers.get("content-type")?.includes("json") ? await body(request) : {}
|
|
2271
|
+
const outcome = await cards.refresh(user.id, params.session!, params.provider!, { force: input.force === true })
|
|
2272
|
+
if (outcome.status === "skipped" && outcome.reason === "rate_limited") {
|
|
2273
|
+
const seconds = Math.max(1, Math.ceil((outcome.retryAfterMs ?? 1000) / 1000))
|
|
2274
|
+
return Response.json({ outcome: "skipped", reason: "rate_limited", retryAfter: seconds }, { status: 429, headers: { ...NO_STORE, "retry-after": String(seconds) } })
|
|
2275
|
+
}
|
|
2276
|
+
return Response.json(outcome.status === "generated" ? { outcome: "generated", card: outcome.card } : outcome.status === "skipped" ? { outcome: "skipped", reason: outcome.reason } : { outcome: "failed", error: "The context card could not be made" }, { headers: NO_STORE })
|
|
2277
|
+
}),
|
|
2015
2278
|
route("GET", "/api/sessions/:session/diff", "session", async ({ params, url }) => {
|
|
2016
2279
|
const file = url.searchParams.get("file")
|
|
2017
2280
|
if (!file || file.length > 4096) return error(400, "Expected ?file=<path>")
|
|
2018
2281
|
const { patch } = await spokes.request(ownerOf(params.session!), { op: "diff", sessionID: params.session!, file })
|
|
2019
2282
|
return patch ? Response.json(patch, { headers: NO_STORE }) : error(404, "No changes for that file")
|
|
2020
2283
|
}),
|
|
2021
|
-
route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device }) => {
|
|
2284
|
+
route("POST", "/api/sessions/:session/read", "session", async ({ params, user, device, request }) => {
|
|
2022
2285
|
const reads = await store.markRead(user.id, params.session!, Date.now())
|
|
2023
2286
|
toUser(user.id, { type: "reads", reads }, device.id)
|
|
2287
|
+
// Only an explicit open (navigating to it, the page or pane shown again) touches the session, never the
|
|
2288
|
+
// console's automatic read marks of a visible pane (the touch also feeds the context cards' user-return).
|
|
2289
|
+
const input = await body(request).catch(() => ({}) as Record<string, unknown>)
|
|
2290
|
+
if (input.open === true) humanActivity.human(user.id, registry.rootOf(params.session!) ?? params.session!, "view")
|
|
2024
2291
|
return Response.json({ reads }, { headers: NO_STORE })
|
|
2025
2292
|
}),
|
|
2026
2293
|
|
|
@@ -2165,6 +2432,22 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2165
2432
|
return new Response(null, { status: 204 })
|
|
2166
2433
|
}
|
|
2167
2434
|
|
|
2435
|
+
/** A connect-flow handler's AgentError as the console's error envelope. */
|
|
2436
|
+
async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
|
|
2437
|
+
try {
|
|
2438
|
+
return await run()
|
|
2439
|
+
} catch (cause) {
|
|
2440
|
+
if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
|
|
2441
|
+
throw cause
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
async function connectDecision(request: Request, user: UserRecord, flow: string, decision: "approve" | "deny"): Promise<Response> {
|
|
2446
|
+
if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Approve or deny on the hub's consent page", "forbidden")
|
|
2447
|
+
const input = await body(request)
|
|
2448
|
+
return connectAnswer(async () => Response.json(await appConnect.decide(user, flow, decision, input.scopes), { headers: NO_STORE }))
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2168
2451
|
async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
|
|
2169
2452
|
const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
|
|
2170
2453
|
if (clientName === undefined) return error(404, "No pending CLI login with that code")
|
|
@@ -2385,6 +2668,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2385
2668
|
})
|
|
2386
2669
|
}
|
|
2387
2670
|
|
|
2671
|
+
/** A session's Voice model preference, re-sent to its spoke (which answers with its defaults). */
|
|
2672
|
+
async function getModelPrefs(sessionID: string, userID: string): Promise<Response> {
|
|
2673
|
+
const prefs = store.modelPrefs(userID, sessionID)
|
|
2674
|
+
const reply = await spokes.request(ownerOf(sessionID), { op: "setModelPrefs", sessionID, voice: prefs.voice ?? null })
|
|
2675
|
+
return Response.json({ prefs, defaults: voiceOnly(reply.defaults) }, { headers: NO_STORE })
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2388
2678
|
/**
|
|
2389
2679
|
* GET …/timeline/messages/:message/media/:ref (S4 M3): 400 for a malformed ref or message ID, 409 offline, 422
|
|
2390
2680
|
* unsupported {feature, harness} when the owning spoke did not announce timelineMedia; else ./media-timeline.ts.
|
|
@@ -2416,8 +2706,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2416
2706
|
* Every selection must be in the session's catalog (provider, model and variant), so a typo cannot leave
|
|
2417
2707
|
* the session failing every turn. A catalog that cannot be fetched is a 502, never an accepted selection.
|
|
2418
2708
|
*/
|
|
2419
|
-
async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null
|
|
2420
|
-
const selections = [change.voice
|
|
2709
|
+
async function checkModelSelections(connID: string, sessionID: string, change: { voice?: ModelSelection | null }): Promise<void> {
|
|
2710
|
+
const selections = [change.voice].filter((m): m is ModelSelection => !!m)
|
|
2421
2711
|
if (selections.length === 0) return
|
|
2422
2712
|
let models: readonly AvailableModel[]
|
|
2423
2713
|
try {
|
|
@@ -2513,19 +2803,55 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2513
2803
|
if (tenant !== undefined) recordingService.hub(tenant, sessionID, kind, fields)
|
|
2514
2804
|
}
|
|
2515
2805
|
|
|
2806
|
+
/** Barge-in; `taps` false (AF3, an app): REC-01's recording taps are left out. */
|
|
2807
|
+
async function interruptSession(sessionID: string, taps = true): Promise<void> {
|
|
2808
|
+
// (voice front P1) While a front is active, an interrupt stops the front (barge-in), not the session's work.
|
|
2809
|
+
const front = registry.frontRoute(sessionID)
|
|
2810
|
+
// A front that cannot be reached failed: the session talks directly (front_failed), never a 502.
|
|
2811
|
+
const viaFront = front ? await spokes.request(front.connID, { op: "interrupt", sessionID: front.sessionID }).then(() => true, () => (registry.frontLost(sessionID), false)) : false
|
|
2812
|
+
if (!viaFront) await spokes.request(ownerOf(sessionID, "interrupt"), { op: "interrupt", sessionID })
|
|
2813
|
+
registry.cancelTurns(sessionID)
|
|
2814
|
+
if (taps) recordingTap(sessionID, "interrupt", {})
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2516
2817
|
/** Conversation mode: steer the utterance into the session (allowed while it is busy) and return at once. */
|
|
2517
|
-
async function utterance(request: Request, sessionID: string): Promise<Response> {
|
|
2818
|
+
async function utterance(request: Request, sessionID: string, deviceID?: string): Promise<Response> {
|
|
2518
2819
|
const raw = (await body(request)).text
|
|
2519
2820
|
const text = typeof raw === "string" ? raw.trim() : ""
|
|
2520
2821
|
if (!text) return error(400, "Empty utterance")
|
|
2521
2822
|
if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
|
|
2823
|
+
return Response.json({ inboxID: await sendUtterance(sessionID, text, undefined, deviceID) }, { status: 202 })
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* An utterance to the session, or to its voice front while one is active (it relays it); tracked for `heard`.
|
|
2828
|
+
* Without `prompt`: the console's steer (the user's own voice: a human turn, recorded by REC-01's taps). (AF3) With
|
|
2829
|
+
* `prompt`: an app's, delivered as an agent prompt with its hub-prompt provenance (from app:<id>, the grant),
|
|
2830
|
+
* steered into the running turn; no human turn, nothing recorded (REC-01 is off for apps).
|
|
2831
|
+
*/
|
|
2832
|
+
async function sendUtterance(sessionID: string, text: string, prompt?: { readonly from: string; readonly to: string; readonly grant: string }, deviceID?: string): Promise<string> {
|
|
2833
|
+
const send = async (connID: string, target: string): Promise<string> => {
|
|
2834
|
+
if (!prompt) return (await spokes.request(connID, { op: "steer", sessionID: target, text })).inboxID
|
|
2835
|
+
for (const feature of ["deliver", "hubPrompt"] as const) {
|
|
2836
|
+
if (!registry.supports(connID, feature)) throw unsupportedAgentError(feature, registry.harnessOf(connID) ?? "unknown")
|
|
2837
|
+
}
|
|
2838
|
+
const messageID = newMessageID()
|
|
2839
|
+
const result = await spokes.request(connID, { op: "deliver", sessionID: target, messageID, text, wake: true, meta: { kind: "prompt", from: prompt.from, to: prompt.to, grant: prompt.grant, delivery: "steer" } })
|
|
2840
|
+
if (!isDeliverResult(result)) throw new Error("The spoke answered a deliver malformed")
|
|
2841
|
+
return result.inboxID ?? messageID
|
|
2842
|
+
}
|
|
2522
2843
|
// (voice front P1) While a front is active, utterances go to the front; it relays them to the session.
|
|
2523
2844
|
const front = registry.frontRoute(sessionID)
|
|
2524
|
-
|
|
2845
|
+
if (!prompt) {
|
|
2846
|
+
await humanTurn(sessionID)
|
|
2847
|
+
// The steer comes back from the spoke marked carried: it is recorded here once, as Voice.
|
|
2848
|
+
const speaker = registry.sessionTenant(sessionID)
|
|
2849
|
+
if (speaker !== undefined) humanActivity.human(speaker, registry.rootOf(sessionID) ?? sessionID, "voice")
|
|
2850
|
+
}
|
|
2525
2851
|
// A front that cannot take it failed: the session talks directly (front_failed), never a 502.
|
|
2526
2852
|
let inboxID = front
|
|
2527
|
-
? await
|
|
2528
|
-
(
|
|
2853
|
+
? await send(front.connID, front.sessionID).then(
|
|
2854
|
+
(id) => id,
|
|
2529
2855
|
(cause) => {
|
|
2530
2856
|
console.warn(`[hub] voice front of ${sessionID} did not take the utterance: ${(cause as Error).message}`)
|
|
2531
2857
|
registry.frontLost(sessionID)
|
|
@@ -2533,10 +2859,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2533
2859
|
},
|
|
2534
2860
|
)
|
|
2535
2861
|
: undefined
|
|
2536
|
-
|
|
2537
|
-
|
|
2862
|
+
// (P2) What the user said to the front: the only thing its relay_to_session may answer. Never an app's text
|
|
2863
|
+
// (AF3): an app's words never become the user's own for a relay.
|
|
2864
|
+
if (inboxID !== undefined && !prompt) registry.frontUtterance(sessionID, inboxID, deviceID, text)
|
|
2865
|
+
if (inboxID === undefined) inboxID = await send(prompt ? ownerOf(sessionID) : ownerOf(sessionID, "steer"), sessionID)
|
|
2866
|
+
if (!prompt) recordingTap(sessionID, "utterance.received", { inboxID, text })
|
|
2538
2867
|
registry.trackUtterance(sessionID, inboxID)
|
|
2539
|
-
return
|
|
2868
|
+
return inboxID
|
|
2540
2869
|
}
|
|
2541
2870
|
|
|
2542
2871
|
/** Renames a session in OpenCode through its owner process, falling back to other processes that report it. */
|
|
@@ -2587,12 +2916,27 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2587
2916
|
throw failure
|
|
2588
2917
|
}
|
|
2589
2918
|
|
|
2590
|
-
async function conversation(request: Request, sessionID: string): Promise<Response> {
|
|
2919
|
+
async function conversation(request: Request, sessionID: string, userID: string): Promise<Response> {
|
|
2591
2920
|
const input = await body(request)
|
|
2592
2921
|
const on = input.on
|
|
2593
2922
|
if (typeof on !== "boolean" || (input.front !== undefined && typeof input.front !== "boolean")) return error(400, "Expected {on: boolean, front?: boolean}")
|
|
2594
|
-
|
|
2595
|
-
|
|
2923
|
+
return Response.json(await setConversation(sessionID, on, input.front, userID))
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/**
|
|
2927
|
+
* Conversation mode on/off. Console (`userID` given): Voice mode always asks for the voice front (when the
|
|
2928
|
+
* session's spoke serves it), unless the device opted out with `front: false`; a console that sends no `front`
|
|
2929
|
+
* (older ones, the TUI's /voice link) gets the default. (AF3) An app (`userID` undefined, `taps` false): the front
|
|
2930
|
+
* only when asked for (`front: true`), never the Concierge's front (its relay_to_session acts as the user), and
|
|
2931
|
+
* REC-01's recording taps are left out (off for apps).
|
|
2932
|
+
*/
|
|
2933
|
+
async function setConversation(sessionID: string, on: boolean, frontInput: boolean | undefined, userID?: string, taps = true): Promise<{ on: boolean; agent?: string; front?: boolean }> {
|
|
2934
|
+
const app = userID === undefined
|
|
2935
|
+
const asked = app ? frontInput === true : frontInput !== false
|
|
2936
|
+
const owner = userID ?? registry.sessionTenant(sessionID)
|
|
2937
|
+
// (P2) The Concierge's front only from a spoke that serves it ("conciergeFront"); else today's path, no front.
|
|
2938
|
+
const concierge = on && asked && owner !== undefined && (await conciergeTimedIdentity.current(owner).then((c) => c.sessionID === sessionID, () => false))
|
|
2939
|
+
const wantsFront = on && asked && (!concierge || (!app && registry.supports(ownerOf(sessionID), "conciergeFront")))
|
|
2596
2940
|
const hadFront = registry.wantsFront(sessionID)
|
|
2597
2941
|
// Turning off always clears the hub's state, so a later reconnect does not switch it back on.
|
|
2598
2942
|
if (!on) registry.setConversation(sessionID, false)
|
|
@@ -2612,8 +2956,95 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2612
2956
|
if (sendFront) registry.frontOpEnded(connID)
|
|
2613
2957
|
}
|
|
2614
2958
|
const front = registry.frontOf(sessionID) !== undefined
|
|
2615
|
-
if (on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
|
|
2616
|
-
return
|
|
2959
|
+
if (taps && on && tenant !== undefined) recordingTap(sessionID, "conversation", { on: true, ...(wantsFront ? { front } : {}) })
|
|
2960
|
+
return { on, ...(typeof result.agent === "string" ? { agent: result.agent } : {}), ...(wantsFront || (!on && hadFront) ? { front } : {}) }
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* (voice front P2) A Concierge front's relay_to_session (spoke request relayToSession from `connID`, a machine of
|
|
2965
|
+
* `owner`): enforced here, not only by the front's prompt. The front must be the active front of the owner's
|
|
2966
|
+
* current Concierge, linked by `connID`, answering the user's latest utterance to it (RELAYS_PER_UTTERANCE at
|
|
2967
|
+
* most); the target one of the owner's own, live, visible root sessions (no front, no Concierge session). It runs
|
|
2968
|
+
* as the user's own prompt (no grant, no Decision): steered into a busy session, a turn in an idle one; text only,
|
|
2969
|
+
* so the target's own permissions apply. Audited as the user's prompt with RELAY_REASON. A refusal throws (the
|
|
2970
|
+
* spoke's ok:false, read back to the front).
|
|
2971
|
+
*/
|
|
2972
|
+
async function relayToSession(owner: string, connID: string, req: { readonly frontID: string; readonly session: string; readonly text: string; readonly previous?: boolean; readonly confirm?: boolean }): Promise<ResultFor["relayToSession"]> {
|
|
2973
|
+
const refuse = (message: string): never => {
|
|
2974
|
+
throw new RelayRefused(message)
|
|
2975
|
+
}
|
|
2976
|
+
// (hardening) Every refusal of the hub's checks is in the audit (Activity shows attempts), as the user's refused
|
|
2977
|
+
// prompt with the reason, the target when it was resolved and the device when the utterance is known.
|
|
2978
|
+
let audited: { sessionID?: string; deviceID?: string } = {}
|
|
2979
|
+
const refused = async (cause: unknown) => {
|
|
2980
|
+
if (!(cause instanceof RelayRefused)) return
|
|
2981
|
+
await audit
|
|
2982
|
+
.record(owner, {
|
|
2983
|
+
actor: { kind: "user" },
|
|
2984
|
+
verb: "prompt",
|
|
2985
|
+
target: audited.sessionID ? { sessionID: audited.sessionID } : {},
|
|
2986
|
+
decision: /at most \d+/.test(cause.message) ? "rate_limited" : "forbidden",
|
|
2987
|
+
reason: `Relay by voice refused: ${cause.message}`,
|
|
2988
|
+
outcome: "refused",
|
|
2989
|
+
...(audited.deviceID ? { deviceID: audited.deviceID } : {}),
|
|
2990
|
+
})
|
|
2991
|
+
.catch((e: unknown) => console.error(`[hub] relay audit failed: ${(e as Error).message}`))
|
|
2992
|
+
}
|
|
2993
|
+
let binding: ReturnType<typeof registry.takeRelay>
|
|
2994
|
+
try {
|
|
2995
|
+
binding = registry.takeRelay(connID, req.frontID)
|
|
2996
|
+
} catch (cause) {
|
|
2997
|
+
if (cause instanceof RelayRefused && cause.deviceID) audited = { deviceID: cause.deviceID }
|
|
2998
|
+
await refused(cause)
|
|
2999
|
+
throw cause
|
|
3000
|
+
}
|
|
3001
|
+
audited = binding.deviceID ? { deviceID: binding.deviceID } : {}
|
|
3002
|
+
try {
|
|
3003
|
+
if (registry.sessionTenant(binding.mainID) !== owner) refuse("Not an active voice front")
|
|
3004
|
+
const identity = await conciergeTimedIdentity.current(owner)
|
|
3005
|
+
if (identity.sessionID !== binding.mainID) refuse("Only the Concierge's voice front relays to other sessions")
|
|
3006
|
+
let sessionID = ""
|
|
3007
|
+
try {
|
|
3008
|
+
sessionID = directory.resolveOne(owner, req.session).sessionID
|
|
3009
|
+
} catch (cause) {
|
|
3010
|
+
refuse(cause instanceof Error ? cause.message : `No session ${req.session}`)
|
|
3011
|
+
}
|
|
3012
|
+
if (registry.sessionTenant(sessionID) !== owner) refuse(`${req.session} is not one of yours`)
|
|
3013
|
+
audited = { ...audited, sessionID }
|
|
3014
|
+
if (sessionID === binding.mainID || registry.hidden(sessionID) || identity.hiddenSessionIDs.includes(sessionID)) refuse("That session takes no relays (a voice front or the Concierge): pass the request to the Concierge instead")
|
|
3015
|
+
const live = registry.session(sessionID) ?? refuse(`${req.session} is offline`)
|
|
3016
|
+
if ((registry.rootOf(sessionID) ?? sessionID) !== sessionID) refuse("Relays go to root sessions, not to subagent sessions")
|
|
3017
|
+
const engine = conciergeActing ?? refuse("This hub cannot relay right now")
|
|
3018
|
+
const actor = { kind: "user" as const, userID: owner, deviceID: binding.deviceID ?? "", via: "cookie" as const }
|
|
3019
|
+
// (hardening, rounds 4 and 5) Only the user's own words, from the hub's record of the utterance: the front's text
|
|
3020
|
+
// is never delivered (board text steering the front, or a restated or inverted request, never reaches the session).
|
|
3021
|
+
if (!binding.words.trim()) refuse("The hub has no words of the user's to relay")
|
|
3022
|
+
const previous = req.previous && binding.previous ? binding.previous : undefined
|
|
3023
|
+
// (hands-on) A question ABOUT a session is not a request FOR it: the front answers or asks the Concierge. For a
|
|
3024
|
+
// redirect ("no, the other one?") the earlier request is the one checked.
|
|
3025
|
+
const question = relayQuestionProblem(previous ?? binding.words)
|
|
3026
|
+
// (guard tuning) A question with a work verb is refused softly: the front may retry ONCE with confirm, for the same
|
|
3027
|
+
// utterance and the same session (after saying aloud what it sends). A hard refusal is never confirmable.
|
|
3028
|
+
let confirmed = false
|
|
3029
|
+
if (question && req.confirm) {
|
|
3030
|
+
if (binding.softTarget !== sessionID) refuse("confirm: true is only for one retry of the same request to the same session, right after the hub's question check offered it. Answer the user or pass it to the Concierge")
|
|
3031
|
+
binding.useConfirm()
|
|
3032
|
+
confirmed = true
|
|
3033
|
+
} else if (question) {
|
|
3034
|
+
if (question === STATUS_QUESTION_SOFT_REFUSAL) binding.markSoft(sessionID)
|
|
3035
|
+
refuse(question)
|
|
3036
|
+
}
|
|
3037
|
+
const { message } = relayMessage({ words: binding.words, ...(previous ? { previous } : {}) })
|
|
3038
|
+
const prompted = await engine.prompt({ actor, userID: owner, auditDevice: true }, sessionID, { text: message, why: confirmed ? RELAY_REASON_CONFIRMED : RELAY_REASON })
|
|
3039
|
+
const title = live.title?.trim() || "The session"
|
|
3040
|
+
const steered = "mode" in prompted && prompted.mode === "steer"
|
|
3041
|
+
registry.noteRelay(binding.mainID, sessionID, title, steered && "inboxID" in prompted ? prompted.inboxID : undefined)
|
|
3042
|
+
return { sessionID, title, mode: steered ? "steer" : "turn" }
|
|
3043
|
+
} catch (cause) {
|
|
3044
|
+
binding.release()
|
|
3045
|
+
await refused(cause)
|
|
3046
|
+
throw cause
|
|
3047
|
+
}
|
|
2617
3048
|
}
|
|
2618
3049
|
|
|
2619
3050
|
/** Enables or disables voice for a session, through a process that reported it (service first). */
|
|
@@ -2680,6 +3111,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2680
3111
|
const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
|
|
2681
3112
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2682
3113
|
if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
|
|
3114
|
+
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
2683
3115
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
2684
3116
|
}
|
|
2685
3117
|
|
|
@@ -2699,6 +3131,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2699
3131
|
)
|
|
2700
3132
|
if (answered.outcome === "unsupported") throw unsupportedError(answered)
|
|
2701
3133
|
if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
|
|
3134
|
+
if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
|
|
2702
3135
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
2703
3136
|
}
|
|
2704
3137
|
|
|
@@ -2892,6 +3325,16 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2892
3325
|
return done
|
|
2893
3326
|
}
|
|
2894
3327
|
|
|
3328
|
+
/** The consent page (the console's SPA) can never be framed, whatever HUB_CSP says. */
|
|
3329
|
+
async function consentPage(acceptEncoding: string | null): Promise<Response> {
|
|
3330
|
+
const page = await staticFile("/", acceptEncoding)
|
|
3331
|
+
const headers = new Headers(page.headers)
|
|
3332
|
+
headers.set("x-frame-options", "DENY")
|
|
3333
|
+
headers.set("cache-control", "no-store")
|
|
3334
|
+
if (!headers.has("content-security-policy")) headers.set("content-security-policy", "frame-ancestors 'none'")
|
|
3335
|
+
return new Response(page.body, { status: page.status, headers })
|
|
3336
|
+
}
|
|
3337
|
+
|
|
2895
3338
|
async function staticFile(pathname: string, acceptEncoding: string | null): Promise<Response> {
|
|
2896
3339
|
const name = APP_ROUTES.test(pathname) ? "index.html" : STATIC_PATH.test(pathname) ? pathname.slice(1) : undefined
|
|
2897
3340
|
const type = name && CONTENT_TYPES[path.extname(name).toLowerCase()]
|
|
@@ -2939,11 +3382,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2939
3382
|
launchFeed,
|
|
2940
3383
|
launchTokens,
|
|
2941
3384
|
webhooks,
|
|
3385
|
+
subscriptions,
|
|
2942
3386
|
agentEvents,
|
|
2943
3387
|
directory,
|
|
2944
3388
|
concierge: conciergeService,
|
|
2945
3389
|
agentEventWriters,
|
|
2946
3390
|
intentRetention,
|
|
3391
|
+
contextCards: cards,
|
|
2947
3392
|
recordings: recordingService,
|
|
2948
3393
|
teams: { store: teamStore, seats: teamSeats, staffing: teamStaffing, intake: teamIntake },
|
|
2949
3394
|
routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
|
|
@@ -2955,6 +3400,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2955
3400
|
// Step down first (releasing the leases), so another pod takes the timed jobs over at once.
|
|
2956
3401
|
await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
|
|
2957
3402
|
launches.shutdown()
|
|
3403
|
+
// Bounded: a hanging store never holds the shutdown (HumanActivity.stop gives up after its timeout).
|
|
3404
|
+
await humanActivity.stop(HUMAN_ACTIVITY_STOP_MS)
|
|
2958
3405
|
conciergeSupervisor.stop()
|
|
2959
3406
|
conciergeContext.stop()
|
|
2960
3407
|
conciergeParts.tiers.stop?.()
|
|
@@ -3097,8 +3544,17 @@ function viewPrefsInput(value: unknown): Partial<ViewPrefs> {
|
|
|
3097
3544
|
}
|
|
3098
3545
|
|
|
3099
3546
|
const MODEL_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
3100
|
-
|
|
3101
|
-
|
|
3547
|
+
/** A spoke's model defaults without the obsolete Worker model (older spokes still report one). */
|
|
3548
|
+
function voiceOnly(defaults: SessionModelPrefs): SessionModelPrefs {
|
|
3549
|
+
return defaults.voice ? { voice: defaults.voice } : {}
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
/**
|
|
3553
|
+
* The Voice model selection (the voice front's). `worker` (the removed voice agent's worker model, sent by older
|
|
3554
|
+
* consoles) is validated and dropped.
|
|
3555
|
+
*/
|
|
3556
|
+
function modelPrefsInput(value: unknown): { voice?: ModelSelection | null } {
|
|
3557
|
+
const invalid = () => new HttpError(400, "Expected a Voice model selection")
|
|
3102
3558
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid()
|
|
3103
3559
|
const record = value as Record<string, unknown>
|
|
3104
3560
|
if (Object.keys(record).some((key) => key !== "voice" && key !== "worker")) throw invalid()
|
|
@@ -3113,7 +3569,7 @@ function modelPrefsInput(value: unknown): { voice?: ModelSelection | null; worke
|
|
|
3113
3569
|
out[key] = { providerID: m.providerID, modelID: m.modelID, ...(typeof m.variant === "string" ? { variant: m.variant } : {}) }
|
|
3114
3570
|
}
|
|
3115
3571
|
if (!("voice" in out) && !("worker" in out)) throw invalid()
|
|
3116
|
-
return out
|
|
3572
|
+
return "voice" in out ? { voice: out.voice! } : {}
|
|
3117
3573
|
}
|
|
3118
3574
|
|
|
3119
3575
|
function param(raw: string): string {
|
|
@@ -3140,6 +3596,11 @@ async function boundedBytes(request: Request, max: number, tooLarge: string): Pr
|
|
|
3140
3596
|
throw new HttpError(413, tooLarge, undefined, read.close)
|
|
3141
3597
|
}
|
|
3142
3598
|
|
|
3599
|
+
/** Passed through as the provider sends it, so the phone (or app) can start playing before synthesis ends. */
|
|
3600
|
+
function speakAudio(speech: Speech, text: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>> {
|
|
3601
|
+
return speech.speakStream ? speech.speakStream(text, signal) : speech.speak(text)
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3143
3604
|
/** A console/public JSON body (a SyntaxError for invalid JSON, as request.json() threw). */
|
|
3144
3605
|
async function jsonBody(request: Request): Promise<unknown> {
|
|
3145
3606
|
return JSON.parse(Buffer.from(await boundedBytes(request, CONSOLE_JSON_BODY_MAX, `Request body too large (at most ${CONSOLE_JSON_BODY_MAX} bytes)`)).toString("utf8"))
|