@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
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// Conversation for apps (AF3, docs/plans/2026-11-08-app-front-contracts.md §1.2, §5; Hub decisions zn7oxf4g,
|
|
2
|
+
// nixct956 2): an app holds a conversation with one of ITS sessions through its server.
|
|
3
|
+
//
|
|
4
|
+
// GET /api/agent/sessions/:addr/stream conversation:read SSE of closed-allowlist frames (below)
|
|
5
|
+
// POST /api/agent/sessions/:addr/conversation conversation:write {on, front?} → {on, agent?, front?}
|
|
6
|
+
// POST /api/agent/sessions/:addr/utterance conversation:write {text 1–4000} → 202 {inboxID} (an action)
|
|
7
|
+
// POST /api/agent/sessions/:addr/interrupt conversation:write → 204
|
|
8
|
+
// Typed turns are the existing act prompt (acting-routes.ts, which admits conversation:write too); the log is widened
|
|
9
|
+
// to conversation:read (read-routes.ts). Both use appSessionCheck from here.
|
|
10
|
+
//
|
|
11
|
+
// "The app's sessions" for conversation (§1.2; user decision 2026-11-09): started by a launch whose `by` is the app's
|
|
12
|
+
// party, or covered by an active grant the app holds that allows `prompt` and whose scope covers the session's project
|
|
13
|
+
// (an answer-only or stop-only grant exposes nothing); subagents count with their root. Every other session, a deleted
|
|
14
|
+
// one and the user's Concierge sessions answer 404 not_found exactly like an unknown address.
|
|
15
|
+
// Writes by an app also need a grant allowing `prompt` that covers the session (403 forbidden for a session it only
|
|
16
|
+
// launched), the user's kill
|
|
17
|
+
// switch off (423 paused); utterances count against the grant's maxActionsPerHour (429, Grants.consume: increment
|
|
18
|
+
// first, then check, so parallel writes never pass it). Every app write is in the agent audit as app:<id>.
|
|
19
|
+
// An utterance carries the same hub-prompt provenance as a typed app turn (a deliver of kind "prompt" from
|
|
20
|
+
// app:<id> under the grant, steered into the running turn), never the console's plain steer (the user's own voice).
|
|
21
|
+
//
|
|
22
|
+
// The stream: `ready` first (once subscribed), then `delta` (the session's own text, never a subagent's, never
|
|
23
|
+
// reasoning; rootID dropped), `say`, `heard`, `status` (state + blocker count, never blocker text) and
|
|
24
|
+
// `conversation`; `reset {refresh}` when a resume point is not covered; `end {reason}` last. `id:` lines carry the
|
|
25
|
+
// hub's live event ids (the console stream's), `event: ping` heartbeats carry the latest cursor. Resume with
|
|
26
|
+
// Last-Event-ID (or ?last=): one pod replays from its live event log (it holds streamed text too); in a cluster the
|
|
27
|
+
// shared log never holds streamed text (cluster/live.ts), so a covered resume replays and then always sends
|
|
28
|
+
// reset {log}. A cursor the log does not cover: ready, then reset {log, status, conversation}.
|
|
29
|
+
// Re-checks every recheckMs and on revocation pulses (AppStreams.pulse): the credential (token_expired, revoked,
|
|
30
|
+
// a scope removed: revoked), the session (gone: deleted, AppStreams.deleted on this pod at once, the directory's
|
|
31
|
+
// deletedAt on any pod) and ownership (grant: also a grant narrowed to drop prompt). Caps per hub process: 4 streams per app
|
|
32
|
+
// credential, 20 per user (AppStreams.reserve: a synchronous check-and-count, so parallel connects never pass
|
|
33
|
+
// them). A reader leaving more than bufferMaxBytes unsent gets end {slow}; the hub's fan-out never waits for it.
|
|
34
|
+
// Streamed text is model output and untrusted: sent as it is (JSON-encoded), never marked up.
|
|
35
|
+
import { APP_STREAM_LIMITS, type AppStreamEndReason, type SessionState } from "../../protocol"
|
|
36
|
+
import type { Registry } from "../registry"
|
|
37
|
+
import { type GrantVerb, inProjects } from "./policy"
|
|
38
|
+
import { type AgentActor, type AppActor, readCredentials, type VerifyResult } from "./actor"
|
|
39
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
40
|
+
import { auditOf } from "./acting"
|
|
41
|
+
import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
|
|
42
|
+
import type { Directory } from "./directory"
|
|
43
|
+
import { partyFromActor } from "./envelope"
|
|
44
|
+
import { AgentError } from "./errors"
|
|
45
|
+
import type { Grant, Grants } from "./grants"
|
|
46
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
|
|
47
|
+
import type { AgentContext, AgentRoute } from "./routes"
|
|
48
|
+
|
|
49
|
+
// A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
|
|
50
|
+
const PREFIX = "/api/agent/sessions/:addr"
|
|
51
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
52
|
+
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
|
|
53
|
+
const RETRY = `retry: ${APP_STREAM_LIMITS.retryMs}\n\n`
|
|
54
|
+
/** Longest utterance (characters), the console's and the apps'. */
|
|
55
|
+
export const MAX_UTTERANCE_CHARS = 4000
|
|
56
|
+
const STATES: ReadonlySet<string> = new Set(["idle", "working", "blocked", "error"])
|
|
57
|
+
|
|
58
|
+
// ---------- the event source (http.ts: the console stream's listeners or the cluster live fan-out) ----------
|
|
59
|
+
|
|
60
|
+
export interface SourceEvent {
|
|
61
|
+
/** The live event id (absent for streamed text in a cluster). */
|
|
62
|
+
readonly id?: string
|
|
63
|
+
readonly message: { readonly type: string; readonly [key: string]: unknown }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface StreamSource {
|
|
67
|
+
/** Cluster mode: the shared log holds no streamed text, so a resume always ends with reset {log}. */
|
|
68
|
+
readonly clustered: boolean
|
|
69
|
+
/** The user's live events from now on. */
|
|
70
|
+
subscribe(userID: string, handler: (event: SourceEvent) => void): { readonly ready: Promise<void>; close(): void }
|
|
71
|
+
/** The user's events after `lastID`, oldest first; undefined when the log does not cover the gap. */
|
|
72
|
+
since(userID: string, lastID: string): Promise<readonly SourceEvent[] | undefined>
|
|
73
|
+
/** The id of the user's latest event. */
|
|
74
|
+
latest(userID: string): Promise<string>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- caps and revocation pulses ----------
|
|
78
|
+
|
|
79
|
+
/** Deleted session IDs AppStreams remembers. */
|
|
80
|
+
const DELETED_KEPT = 5000
|
|
81
|
+
|
|
82
|
+
/** Open app streams of this hub process: the caps (per credential, per user) and the revocation pulses. */
|
|
83
|
+
export class AppStreams {
|
|
84
|
+
private readonly perCredential = new Map<string, number>()
|
|
85
|
+
private readonly perUser = new Map<string, number>()
|
|
86
|
+
private readonly checks = new Map<string, Set<() => void>>()
|
|
87
|
+
/** Sessions deleted while this process ran (bounded): gone at once, before any directory write lands. */
|
|
88
|
+
private readonly removed = new Set<string>()
|
|
89
|
+
|
|
90
|
+
constructor(private readonly limits: { readonly perApp: number; readonly perUser: number } = { perApp: APP_STREAM_LIMITS.streamsPerApp, perUser: APP_STREAM_LIMITS.streamsPerUser }) {}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Takes a stream slot of the credential (and its user), or undefined at a cap. Synchronous check-and-count, so
|
|
94
|
+
* parallel connects can never pass a cap. The release is idempotent.
|
|
95
|
+
*/
|
|
96
|
+
reserve(userID: string, credential: string): (() => void) | undefined {
|
|
97
|
+
const key = `${userID}\n${credential}`
|
|
98
|
+
const mine = this.perCredential.get(key) ?? 0
|
|
99
|
+
const all = this.perUser.get(userID) ?? 0
|
|
100
|
+
if (mine >= this.limits.perApp || all >= this.limits.perUser) return undefined
|
|
101
|
+
this.perCredential.set(key, mine + 1)
|
|
102
|
+
this.perUser.set(userID, all + 1)
|
|
103
|
+
let released = false
|
|
104
|
+
return () => {
|
|
105
|
+
if (released) return
|
|
106
|
+
released = true
|
|
107
|
+
const left = (this.perCredential.get(key) ?? 1) - 1
|
|
108
|
+
if (left > 0) this.perCredential.set(key, left)
|
|
109
|
+
else this.perCredential.delete(key)
|
|
110
|
+
const userLeft = (this.perUser.get(userID) ?? 1) - 1
|
|
111
|
+
if (userLeft > 0) this.perUser.set(userID, userLeft)
|
|
112
|
+
else this.perUser.delete(userID)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Runs `check` on every pulse for the user; returns the unsubscribe. */
|
|
117
|
+
watch(userID: string, check: () => void): () => void {
|
|
118
|
+
let set = this.checks.get(userID)
|
|
119
|
+
if (!set) this.checks.set(userID, (set = new Set()))
|
|
120
|
+
set.add(check)
|
|
121
|
+
return () => {
|
|
122
|
+
set.delete(check)
|
|
123
|
+
if (!set.size) this.checks.delete(userID)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Something that may revoke a stream changed (a token, connection, grant or user): re-check the user's streams now. */
|
|
128
|
+
pulse(userID: string): void {
|
|
129
|
+
for (const check of [...(this.checks.get(userID) ?? [])]) check()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A session of the user was deleted: its streams end (gone) now. */
|
|
133
|
+
deleted(userID: string, sessionID: string): void {
|
|
134
|
+
this.removed.add(sessionID)
|
|
135
|
+
if (this.removed.size > DELETED_KEPT) this.removed.delete(this.removed.values().next().value!)
|
|
136
|
+
this.pulse(userID)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether this process saw the session deleted. */
|
|
140
|
+
wasDeleted(sessionID: string): boolean {
|
|
141
|
+
return this.removed.has(sessionID)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Open streams of a user (tests, diagnostics). */
|
|
145
|
+
open(userID: string): number {
|
|
146
|
+
return this.perUser.get(userID) ?? 0
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- frames ----------
|
|
151
|
+
|
|
152
|
+
type Frame = Record<string, unknown> & { readonly type: string }
|
|
153
|
+
|
|
154
|
+
/** A live message as an app frame for `sessionID` (whose root is `rootID`), or undefined when it is not one. */
|
|
155
|
+
export function appFrame(message: SourceEvent["message"], sessionID: string, rootID: string): Frame | undefined {
|
|
156
|
+
const m = message as Record<string, unknown>
|
|
157
|
+
switch (m.type) {
|
|
158
|
+
case "delta":
|
|
159
|
+
if (m.sessionID !== sessionID || typeof m.text !== "string") return undefined
|
|
160
|
+
return { type: "delta", sessionID, ...(typeof m.messageID === "string" ? { messageID: m.messageID } : {}), ...(isOrdinal(m.ordinal) ? { ordinal: m.ordinal } : {}), text: m.text }
|
|
161
|
+
case "say":
|
|
162
|
+
if (m.sessionID !== sessionID || typeof m.messageID !== "string" || !isOrdinal(m.ordinal) || typeof m.text !== "string") return undefined
|
|
163
|
+
return { type: "say", sessionID, messageID: m.messageID, ordinal: m.ordinal, ...(isOrdinal(m.seq) ? { seq: m.seq } : {}), text: m.text }
|
|
164
|
+
case "heard":
|
|
165
|
+
if (m.sessionID !== sessionID || typeof m.inboxID !== "string") return undefined
|
|
166
|
+
return { type: "heard", sessionID, inboxID: m.inboxID }
|
|
167
|
+
case "conversation":
|
|
168
|
+
if (m.sessionID !== sessionID || typeof m.on !== "boolean") return undefined
|
|
169
|
+
return { type: "conversation", sessionID, on: m.on, ...(typeof m.front === "boolean" ? { front: m.front } : {}), ...(m.reason === "front_failed" ? { reason: "front_failed" } : {}) }
|
|
170
|
+
case "status": {
|
|
171
|
+
if (m.sessionID !== rootID || !Array.isArray(m.sessions)) return undefined
|
|
172
|
+
const entry = (m.sessions as Array<Record<string, unknown>>).find((s) => s?.id === sessionID)
|
|
173
|
+
if (!entry || typeof entry.state !== "string" || !STATES.has(entry.state)) return undefined
|
|
174
|
+
const blockers = Array.isArray(entry.blockers) ? entry.blockers.length : 0
|
|
175
|
+
return { type: "status", sessionID, state: entry.state, blockers, ...(typeof entry.since === "number" && Number.isFinite(entry.since) ? { since: entry.since } : {}) }
|
|
176
|
+
}
|
|
177
|
+
default:
|
|
178
|
+
return undefined
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const isOrdinal = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0
|
|
183
|
+
|
|
184
|
+
// ---------- the stream ----------
|
|
185
|
+
|
|
186
|
+
export interface AppStreamOptions {
|
|
187
|
+
readonly userID: string
|
|
188
|
+
readonly sessionID: string
|
|
189
|
+
readonly rootID: string
|
|
190
|
+
readonly source: StreamSource
|
|
191
|
+
readonly signal: AbortSignal
|
|
192
|
+
/** Last-Event-ID or ?last=. */
|
|
193
|
+
readonly lastID?: string
|
|
194
|
+
/** The session now, for `ready`. */
|
|
195
|
+
ready(): { readonly status: SessionState; readonly conversation: { readonly on: boolean; readonly front?: boolean } }
|
|
196
|
+
/** The re-check: a reason ends the stream; undefined keeps it. A throw keeps it (the next one tries again). */
|
|
197
|
+
check(): Promise<AppStreamEndReason | undefined>
|
|
198
|
+
/** Revocation pulses: `run` re-checks at once; returns the unsubscribe. */
|
|
199
|
+
subscribeChecks?(run: () => void): () => void
|
|
200
|
+
/** Once, when the stream ends for any reason (the cap slot). */
|
|
201
|
+
onClose?(): void
|
|
202
|
+
readonly draining?: AbortSignal
|
|
203
|
+
readonly now?: () => number
|
|
204
|
+
readonly heartbeatMs?: number
|
|
205
|
+
readonly recheckMs?: number
|
|
206
|
+
readonly bufferMaxBytes?: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The SSE response of one app stream (see the header). */
|
|
210
|
+
export function appStream(options: AppStreamOptions): Response {
|
|
211
|
+
const { userID, sessionID, rootID, source } = options
|
|
212
|
+
if (options.draining?.aborted) {
|
|
213
|
+
options.onClose?.()
|
|
214
|
+
return new Response(RETRY, { headers: SSE_HEADERS })
|
|
215
|
+
}
|
|
216
|
+
const now = options.now ?? Date.now
|
|
217
|
+
const max = options.bufferMaxBytes ?? APP_STREAM_LIMITS.bufferMaxBytes
|
|
218
|
+
const encoder = new TextEncoder()
|
|
219
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>
|
|
220
|
+
let open = true
|
|
221
|
+
let cursor: string | undefined
|
|
222
|
+
let lastStatus: string | undefined
|
|
223
|
+
let subscription: { close(): void } | undefined
|
|
224
|
+
const timers: ReturnType<typeof setInterval>[] = []
|
|
225
|
+
const unsubscribe: (() => void)[] = []
|
|
226
|
+
|
|
227
|
+
const finish = () => {
|
|
228
|
+
if (!open) return
|
|
229
|
+
open = false
|
|
230
|
+
for (const timer of timers) clearInterval(timer)
|
|
231
|
+
subscription?.close()
|
|
232
|
+
for (const off of unsubscribe) off()
|
|
233
|
+
options.onClose?.()
|
|
234
|
+
}
|
|
235
|
+
const close = () => {
|
|
236
|
+
finish()
|
|
237
|
+
try {
|
|
238
|
+
controller.close()
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
const enqueue = (chunk: string): boolean => {
|
|
242
|
+
try {
|
|
243
|
+
controller.enqueue(encoder.encode(chunk))
|
|
244
|
+
return true
|
|
245
|
+
} catch {
|
|
246
|
+
finish()
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const end = (reason: AppStreamEndReason) => {
|
|
251
|
+
if (!open) return
|
|
252
|
+
enqueue(`data: ${JSON.stringify({ type: "end", at: now(), reason })}\n\n`)
|
|
253
|
+
close()
|
|
254
|
+
}
|
|
255
|
+
const write = (chunk: string) => {
|
|
256
|
+
if (!open || !enqueue(chunk)) return
|
|
257
|
+
// More than the limit waits unsent: the reader is too slow; never queue without bound.
|
|
258
|
+
if ((controller.desiredSize ?? 0) < 0) end("slow")
|
|
259
|
+
}
|
|
260
|
+
const frame = (data: Frame, id?: string) => {
|
|
261
|
+
if (data.type === "status") {
|
|
262
|
+
const key = JSON.stringify(data)
|
|
263
|
+
if (key === lastStatus) return
|
|
264
|
+
lastStatus = key
|
|
265
|
+
}
|
|
266
|
+
write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`)
|
|
267
|
+
}
|
|
268
|
+
const replayed = new Set<string>()
|
|
269
|
+
const forward = (event: SourceEvent) => {
|
|
270
|
+
if (!open) return
|
|
271
|
+
if (event.id !== undefined) {
|
|
272
|
+
if (replayed.has(event.id)) return
|
|
273
|
+
cursor = event.id
|
|
274
|
+
}
|
|
275
|
+
const data = appFrame(event.message, sessionID, rootID)
|
|
276
|
+
if (data) frame(data, event.id)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let checking = false
|
|
280
|
+
let again = false
|
|
281
|
+
const runCheck = async () => {
|
|
282
|
+
if (!open) return
|
|
283
|
+
if (checking) {
|
|
284
|
+
again = true
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
checking = true
|
|
288
|
+
try {
|
|
289
|
+
do {
|
|
290
|
+
again = false
|
|
291
|
+
const reason = await options.check().catch((cause: unknown) => {
|
|
292
|
+
console.error(`[hub] app stream re-check failed: ${(cause as Error).message}`)
|
|
293
|
+
return undefined
|
|
294
|
+
})
|
|
295
|
+
if (reason) return end(reason)
|
|
296
|
+
} while (again && open)
|
|
297
|
+
} finally {
|
|
298
|
+
checking = false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const run = async () => {
|
|
303
|
+
let pending: SourceEvent[] | undefined = []
|
|
304
|
+
const subscribed = source.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
|
|
305
|
+
subscription = subscribed
|
|
306
|
+
if (!open) subscribed.close()
|
|
307
|
+
try {
|
|
308
|
+
await subscribed.ready
|
|
309
|
+
const missed = options.lastID ? await source.since(userID, options.lastID) : undefined
|
|
310
|
+
const latest = await source.latest(userID)
|
|
311
|
+
if (!open) return
|
|
312
|
+
const covered = options.lastID !== undefined && missed !== undefined
|
|
313
|
+
cursor = covered ? options.lastID : latest
|
|
314
|
+
const state = options.ready()
|
|
315
|
+
frame({ type: "ready", at: now(), sessionID, status: state.status, conversation: { on: state.conversation.on, ...(state.conversation.front !== undefined ? { front: state.conversation.front } : {}) } }, cursor)
|
|
316
|
+
if (covered) {
|
|
317
|
+
for (const event of missed!) {
|
|
318
|
+
if (event.id !== undefined) {
|
|
319
|
+
replayed.add(event.id)
|
|
320
|
+
cursor = event.id
|
|
321
|
+
}
|
|
322
|
+
const data = appFrame(event.message, sessionID, rootID)
|
|
323
|
+
if (data) frame(data, event.id)
|
|
324
|
+
}
|
|
325
|
+
if (source.clustered) frame({ type: "reset", at: now(), refresh: ["log"] })
|
|
326
|
+
} else if (options.lastID !== undefined) frame({ type: "reset", at: now(), refresh: ["log", "status", "conversation"] })
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
console.error(`[hub] app stream did not start: ${(cause as Error).message}`)
|
|
329
|
+
write(RETRY)
|
|
330
|
+
return close()
|
|
331
|
+
}
|
|
332
|
+
const waiting = pending
|
|
333
|
+
pending = undefined
|
|
334
|
+
for (const event of waiting) forward(event)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
338
|
+
{
|
|
339
|
+
start(c) {
|
|
340
|
+
controller = c
|
|
341
|
+
options.signal.addEventListener("abort", () => close(), { once: true })
|
|
342
|
+
if (options.draining) {
|
|
343
|
+
const draining = options.draining
|
|
344
|
+
const onDrain = () => {
|
|
345
|
+
if (!open) return
|
|
346
|
+
enqueue(RETRY)
|
|
347
|
+
close()
|
|
348
|
+
}
|
|
349
|
+
draining.addEventListener("abort", onDrain, { once: true })
|
|
350
|
+
unsubscribe.push(() => draining.removeEventListener("abort", onDrain))
|
|
351
|
+
}
|
|
352
|
+
timers.push(setInterval(() => write(`event: ping\n${cursor ? `id: ${cursor}\n` : ""}data: 0\n\n`), options.heartbeatMs ?? APP_STREAM_LIMITS.heartbeatMs))
|
|
353
|
+
timers.push(setInterval(() => void runCheck(), options.recheckMs ?? APP_STREAM_LIMITS.recheckMs))
|
|
354
|
+
if (options.subscribeChecks) unsubscribe.push(options.subscribeChecks(() => void runCheck()))
|
|
355
|
+
void run()
|
|
356
|
+
},
|
|
357
|
+
cancel() {
|
|
358
|
+
finish()
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{ highWaterMark: max, size: (chunk) => chunk?.byteLength ?? 0 },
|
|
362
|
+
)
|
|
363
|
+
return new Response(stream, { headers: SSE_HEADERS })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------- "the app's sessions" (§1.2) ----------
|
|
367
|
+
|
|
368
|
+
export interface AppSessionDeps {
|
|
369
|
+
readonly registry: Pick<Registry, "rootOf" | "session" | "sessionTenant">
|
|
370
|
+
readonly directory: Pick<Directory, "resolveOne"> & Partial<Pick<Directory, "deleted">>
|
|
371
|
+
readonly grants: Pick<Grants, "list">
|
|
372
|
+
/** (AF3) Sessions this process saw deleted (AppStreams.wasDeleted). */
|
|
373
|
+
readonly deletedHere?: (sessionID: string) => boolean
|
|
374
|
+
/** Launches: `by` is the launcher's party (`app:<id>`). */
|
|
375
|
+
readonly launches?: { list(userID: string): Promise<readonly { readonly by?: string; readonly sessionID?: string }[]> }
|
|
376
|
+
/** (CON-04) Sessions only the user acts on (the Concierge's): never an app's. */
|
|
377
|
+
readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface AppTarget {
|
|
381
|
+
readonly sessionID: string
|
|
382
|
+
readonly rootID: string
|
|
383
|
+
/** The session's directory when a process reports it (or last reported it). */
|
|
384
|
+
readonly directory?: string
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const notFound = (addr: string) => new AgentError("not_found", `No session ${addr}`)
|
|
388
|
+
|
|
389
|
+
/** The addressed session of the user (not_found otherwise; an ambiguity reads as not_found too: no IDs leak). */
|
|
390
|
+
export function locateSession(deps: Pick<AppSessionDeps, "registry" | "directory" | "deletedHere">, userID: string, addr: string): AppTarget {
|
|
391
|
+
let sessionID: string
|
|
392
|
+
try {
|
|
393
|
+
sessionID = deps.directory.resolveOne(userID, addr).sessionID
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
if (cause instanceof AgentError && (cause.code === "not_found" || cause.code === "ambiguous")) throw notFound(addr)
|
|
396
|
+
throw cause
|
|
397
|
+
}
|
|
398
|
+
const tenant = deps.registry.sessionTenant(sessionID)
|
|
399
|
+
if (tenant !== undefined && tenant !== userID) throw notFound(addr)
|
|
400
|
+
const rootID = deps.registry.rootOf(sessionID) ?? sessionID
|
|
401
|
+
// Deleted (it, or its root): gone, like an unknown session.
|
|
402
|
+
for (const id of new Set([sessionID, rootID])) if (deps.deletedHere?.(id) || deps.directory.deleted?.(userID, id)) throw notFound(addr)
|
|
403
|
+
const directory = deps.registry.session(rootID)?.directory ?? deps.registry.session(sessionID)?.directory
|
|
404
|
+
return { sessionID, rootID, ...(directory ? { directory } : {}) }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Whether the target is the app's: launched by it, or covered by an active grant it holds that allows one of `verbs`
|
|
409
|
+
* (any verb when none are given: contract §1.2's base rule, e.g. for AF4). Conversation (stream, log, writes) passes
|
|
410
|
+
* CONVERSATION_VERBS (user decision 2026-11-09: only a prompt grant exposes a conversation).
|
|
411
|
+
*/
|
|
412
|
+
export async function isAppSession(deps: AppSessionDeps, actor: AppActor, userID: string, target: AppTarget, verbs?: readonly GrantVerb[]): Promise<boolean> {
|
|
413
|
+
if (deps.protects && ((await deps.protects(userID, target.rootID)) || (await deps.protects(userID, target.sessionID)))) return false
|
|
414
|
+
const party = `app:${actor.tokenID}`
|
|
415
|
+
if (target.directory) {
|
|
416
|
+
for (const grant of await deps.grants.list(userID)) {
|
|
417
|
+
if (grant.state !== "active" || grant.holder !== party || !inProjects(target.directory, grant.scope.projects)) continue
|
|
418
|
+
if (!verbs || verbs.some((verb) => grant.allow.includes(verb))) return true
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (deps.launches) {
|
|
422
|
+
for (const launch of await deps.launches.list(userID)) {
|
|
423
|
+
if (launch.by === party && launch.sessionID !== undefined && (launch.sessionID === target.rootID || launch.sessionID === target.sessionID)) return true
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return false
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The grant verbs that make a session an app's for conversation (stream, log, writes). */
|
|
430
|
+
export const CONVERSATION_VERBS: readonly GrantVerb[] = ["prompt"]
|
|
431
|
+
|
|
432
|
+
/** The addressed session when it is the app's for conversation; not_found otherwise. Users: their own sessions. */
|
|
433
|
+
export async function appSessionCheck(deps: AppSessionDeps, actor: AgentActor, userID: string, addr: string): Promise<AppTarget> {
|
|
434
|
+
const target = locateSession(deps, userID, addr)
|
|
435
|
+
if (actor.kind === "app" && !(await isAppSession(deps, actor, userID, target, CONVERSATION_VERBS))) throw notFound(addr)
|
|
436
|
+
return target
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------- the routes ----------
|
|
440
|
+
|
|
441
|
+
/** What the conversation routes run on the hub (http.ts: the console's conversation machinery). */
|
|
442
|
+
export interface ConversationPorts {
|
|
443
|
+
/** Conversation mode on/off (front: the voice front), as POST /api/sessions/:id/conversation does. */
|
|
444
|
+
conversation(sessionID: string, on: boolean, front: boolean, options: { readonly taps: boolean }): Promise<{ readonly on: boolean; readonly agent?: string; readonly front?: boolean }>
|
|
445
|
+
/**
|
|
446
|
+
* An utterance to the session (its front while one is active), tracked for `heard`. With `prompt`: delivered as an
|
|
447
|
+
* agent prompt with that provenance (an app's); without: the console's steer (the user's own).
|
|
448
|
+
*/
|
|
449
|
+
utterance(sessionID: string, text: string, prompt?: { readonly from: string; readonly to: string; readonly grant: string }): Promise<string>
|
|
450
|
+
/** Barge-in: stops the front's turn (or the session's). */
|
|
451
|
+
interrupt(sessionID: string): Promise<void>
|
|
452
|
+
/** Conversation mode now (front: present once a front was asked for). */
|
|
453
|
+
conversationOf(sessionID: string): { readonly on: boolean; readonly front?: boolean }
|
|
454
|
+
/** The session's state now. */
|
|
455
|
+
stateOf(sessionID: string): SessionState
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export interface ConversationRouteDeps extends AppSessionDeps {
|
|
459
|
+
readonly ports: ConversationPorts
|
|
460
|
+
readonly source: StreamSource
|
|
461
|
+
readonly streams: AppStreams
|
|
462
|
+
readonly grants: Pick<Grants, "list" | "usable" | "consume">
|
|
463
|
+
readonly acting: Pick<ActingSwitch, "ensureActing">
|
|
464
|
+
readonly audit: Pick<AgentAudit, "record">
|
|
465
|
+
readonly store: { user(id: string): { readonly disabled?: boolean } | undefined }
|
|
466
|
+
/** Verifies a bearer again (the re-check): the hub's bearer verifiers in order. */
|
|
467
|
+
verify(token: string): Promise<VerifyResult>
|
|
468
|
+
readonly cookieName: string
|
|
469
|
+
readonly draining?: AbortSignal
|
|
470
|
+
readonly now?: () => number
|
|
471
|
+
readonly heartbeatMs?: number
|
|
472
|
+
readonly recheckMs?: number
|
|
473
|
+
readonly bufferMaxBytes?: number
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
type Body = Record<string, unknown>
|
|
477
|
+
|
|
478
|
+
async function bodyOf(request: Request, optional = false): Promise<Body> {
|
|
479
|
+
const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
480
|
+
if (!raw.trim() && optional) return {}
|
|
481
|
+
const value: unknown = JSON.parse(raw)
|
|
482
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
483
|
+
return value as Body
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** A port's failure without its text (never pass upstream or internal error text to apps). */
|
|
487
|
+
function portFailure(cause: unknown): unknown {
|
|
488
|
+
if (cause instanceof AgentError) return cause
|
|
489
|
+
const status = (cause as { status?: unknown }).status
|
|
490
|
+
const message = cause instanceof Error ? cause.message : ""
|
|
491
|
+
if (status === 409 || /disconnected|offline|no process|timed out/i.test(message)) return new AgentError("offline", "The session's machine is offline or did not answer")
|
|
492
|
+
console.error(`[hub] conversation write failed: ${message}`)
|
|
493
|
+
return new AgentError("internal", "Internal error")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function conversationRoutes(deps: ConversationRouteDeps): AgentRoute[] {
|
|
497
|
+
const READ = { scope: "conversation:read" } as const
|
|
498
|
+
const WRITE = { scope: "conversation:write" } as const
|
|
499
|
+
|
|
500
|
+
/** The write check order for apps: the app's session (404) → root only → kill switch → prompt grant → hourly cap → kill switch. */
|
|
501
|
+
async function write<T>(context: AgentContext, verb: "conversation" | "utterance" | "interrupt", counts: boolean, run: (target: AppTarget, grant?: Grant) => Promise<T>): Promise<T> {
|
|
502
|
+
const { actor, user, params } = context
|
|
503
|
+
const target = await appSessionCheck(deps, actor, user.id, params.addr!)
|
|
504
|
+
if (target.rootID !== target.sessionID) throw new AgentError("invalid", "A conversation is with a root session, not a subagent session")
|
|
505
|
+
if (!deps.registry.session(target.sessionID)) throw new AgentError("offline", "No process reports this session now")
|
|
506
|
+
if (actor.kind !== "app") return run(target).catch((cause: unknown) => Promise.reject(portFailure(cause)))
|
|
507
|
+
let grant: Grant | undefined
|
|
508
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
509
|
+
deps.audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID: target.sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
|
|
510
|
+
try {
|
|
511
|
+
deps.acting.ensureActing(user.id)
|
|
512
|
+
const usable = target.directory ? await deps.grants.usable(actor, "prompt", { directory: target.directory }) : []
|
|
513
|
+
if (!usable.length) throw new AgentError("forbidden", "No grant lets this app prompt in this session", { hint: "The user grants prompt on the session's project" })
|
|
514
|
+
grant = usable[0]!
|
|
515
|
+
if (counts) await deps.grants.consume(grant)
|
|
516
|
+
deps.acting.ensureActing(user.id)
|
|
517
|
+
const result = await run(target, grant).catch((cause: unknown) => Promise.reject(portFailure(cause)))
|
|
518
|
+
await record("allow", "ok")
|
|
519
|
+
return result
|
|
520
|
+
} catch (cause) {
|
|
521
|
+
const [decision, outcome] = auditOf(cause)
|
|
522
|
+
await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
|
|
523
|
+
throw cause
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return [
|
|
528
|
+
{
|
|
529
|
+
method: "GET",
|
|
530
|
+
path: `${PREFIX}/stream`,
|
|
531
|
+
allow: READ,
|
|
532
|
+
async handle(context) {
|
|
533
|
+
const { actor, user, request, url, params } = context
|
|
534
|
+
const target = await appSessionCheck(deps, actor, user.id, params.addr!)
|
|
535
|
+
// Apps by their credential; the user's own devices (they reach every route) by device.
|
|
536
|
+
const credential = actor.kind === "app" ? `app:${actor.tokenID}` : actor.kind === "user" ? `device:${actor.deviceID}` : `session:${actor.sessionID}`
|
|
537
|
+
const release = deps.streams.reserve(user.id, credential)
|
|
538
|
+
if (!release) {
|
|
539
|
+
throw new AgentError("rate_limited", `At most ${APP_STREAM_LIMITS.streamsPerApp} streams per app and ${APP_STREAM_LIMITS.streamsPerUser} per user`, { hint: "Close a stream first", extra: { retryAfter: 5 } })
|
|
540
|
+
}
|
|
541
|
+
const bearer = readCredentials(request, deps.cookieName).bearer
|
|
542
|
+
let directory = target.directory
|
|
543
|
+
const check = async (): Promise<AppStreamEndReason | undefined> => {
|
|
544
|
+
const owner = deps.store.user(user.id)
|
|
545
|
+
if (!owner || owner.disabled) return "revoked"
|
|
546
|
+
let current: AgentActor = actor
|
|
547
|
+
if (actor.kind === "app") {
|
|
548
|
+
const verified = bearer ? await deps.verify(bearer) : undefined
|
|
549
|
+
if (verified === "expired") return "token_expired"
|
|
550
|
+
if (!verified || verified.kind !== "app" || verified.tokenID !== actor.tokenID || verified.userID !== user.id) return "revoked"
|
|
551
|
+
if (!verified.scopes.includes("conversation:read")) return "revoked"
|
|
552
|
+
current = verified
|
|
553
|
+
}
|
|
554
|
+
let now: AppTarget
|
|
555
|
+
try {
|
|
556
|
+
now = locateSession(deps, user.id, target.sessionID)
|
|
557
|
+
} catch (cause) {
|
|
558
|
+
if (cause instanceof AgentError && cause.code === "not_found") return "gone"
|
|
559
|
+
throw cause
|
|
560
|
+
}
|
|
561
|
+
// A session briefly not reported keeps the directory it had (its grant still covers it).
|
|
562
|
+
directory = now.directory ?? directory
|
|
563
|
+
if (current.kind === "app" && !(await isAppSession(deps, current, user.id, { ...now, ...(directory ? { directory } : {}) }, CONVERSATION_VERBS))) return "grant"
|
|
564
|
+
return undefined
|
|
565
|
+
}
|
|
566
|
+
const lastID = request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined
|
|
567
|
+
return appStream({
|
|
568
|
+
userID: user.id,
|
|
569
|
+
sessionID: target.sessionID,
|
|
570
|
+
rootID: target.rootID,
|
|
571
|
+
source: deps.source,
|
|
572
|
+
signal: request.signal,
|
|
573
|
+
...(lastID ? { lastID } : {}),
|
|
574
|
+
ready: () => ({ status: deps.ports.stateOf(target.sessionID), conversation: deps.ports.conversationOf(target.sessionID) }),
|
|
575
|
+
check,
|
|
576
|
+
subscribeChecks: (run) => deps.streams.watch(user.id, run),
|
|
577
|
+
onClose: release,
|
|
578
|
+
...(deps.draining ? { draining: deps.draining } : {}),
|
|
579
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
580
|
+
...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
|
|
581
|
+
...(deps.recheckMs !== undefined ? { recheckMs: deps.recheckMs } : {}),
|
|
582
|
+
...(deps.bufferMaxBytes !== undefined ? { bufferMaxBytes: deps.bufferMaxBytes } : {}),
|
|
583
|
+
})
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
method: "POST",
|
|
588
|
+
path: `${PREFIX}/conversation`,
|
|
589
|
+
allow: WRITE,
|
|
590
|
+
async handle(context) {
|
|
591
|
+
const body = await bodyOf(context.request)
|
|
592
|
+
const { on, front } = body
|
|
593
|
+
if (typeof on !== "boolean" || (front !== undefined && typeof front !== "boolean")) throw new AgentError("invalid", "Expected {on: boolean, front?: boolean}")
|
|
594
|
+
const result = await write(context, "conversation", false, (target) => deps.ports.conversation(target.sessionID, on, front === true, { taps: context.actor.kind === "user" }))
|
|
595
|
+
return Response.json({ on: result.on, ...(result.agent !== undefined ? { agent: result.agent } : {}), ...(result.front !== undefined ? { front: result.front } : {}) }, { headers: NO_STORE })
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
method: "POST",
|
|
600
|
+
path: `${PREFIX}/utterance`,
|
|
601
|
+
allow: WRITE,
|
|
602
|
+
async handle(context) {
|
|
603
|
+
const body = await bodyOf(context.request)
|
|
604
|
+
const text = typeof body.text === "string" ? body.text.trim() : ""
|
|
605
|
+
if (!text) throw new AgentError("invalid", "text must be a non-empty string", { extra: { limit: MAX_UTTERANCE_CHARS } })
|
|
606
|
+
if (text.length > MAX_UTTERANCE_CHARS) throw new AgentError("invalid", `An utterance is at most ${MAX_UTTERANCE_CHARS} characters`, { extra: { limit: MAX_UTTERANCE_CHARS } })
|
|
607
|
+
const { actor, params } = context
|
|
608
|
+
const inboxID = await write(context, "utterance", true, (target, grant) =>
|
|
609
|
+
deps.ports.utterance(target.sessionID, text, actor.kind === "app" && grant ? { from: `app:${actor.tokenID}`, to: params.addr!, grant: grant.id } : undefined),
|
|
610
|
+
)
|
|
611
|
+
return Response.json({ inboxID }, { status: 202, headers: NO_STORE })
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
method: "POST",
|
|
616
|
+
path: `${PREFIX}/interrupt`,
|
|
617
|
+
allow: WRITE,
|
|
618
|
+
async handle(context) {
|
|
619
|
+
await bodyOf(context.request, true)
|
|
620
|
+
await write(context, "interrupt", false, (target) => deps.ports.interrupt(target.sessionID))
|
|
621
|
+
return new Response(null, { status: 204, headers: NO_STORE })
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
]
|
|
625
|
+
}
|