@huynle/hub 0.9.2 → 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 +296 -0
- package/Dockerfile +5 -1
- package/README.md +152 -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-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 +64 -3
- 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 +516 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// errors (HubError, HubTransportError), the answer types, the SSE parser of the watch stream, and the pure
|
|
3
3
|
// envelope renderer/sanitiser and address parser shared with the hub, launches, and webhook verification.
|
|
4
4
|
export * from "./address.ts"
|
|
5
|
+
export * from "./app-front.ts"
|
|
5
6
|
export * from "./artifacts.ts"
|
|
6
7
|
export * from "./artifact-jobs.ts"
|
|
7
8
|
export * from "./team-intake.ts"
|
|
@@ -368,7 +368,8 @@ export interface Decision {
|
|
|
368
368
|
readonly at: number
|
|
369
369
|
readonly expiresAt: number
|
|
370
370
|
readonly escalatedAt?: number
|
|
371
|
-
|
|
371
|
+
/** answeredBy "app": an app answered it (`app`: its party `app:<id>`, `appLabel`: its label then). */
|
|
372
|
+
readonly answer?: { readonly option?: string; readonly text?: string; readonly answeredBy: "user" | "timeout" | "app"; readonly at: number; readonly deviceID?: string; readonly app?: string; readonly appLabel?: string }
|
|
372
373
|
/** Why it closed without an answer (state closed). */
|
|
373
374
|
readonly closed?: { readonly at: number; readonly reason: string }
|
|
374
375
|
}
|
|
@@ -593,6 +594,43 @@ export interface WebhookDelivery {
|
|
|
593
594
|
readonly last?: { readonly at: number; readonly status?: number; readonly error?: string }
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
/** A session subscription's target (decision 74qawely). */
|
|
598
|
+
export type SubscriptionTarget =
|
|
599
|
+
| { readonly kind: "session"; readonly sessionID: string; readonly addr: string }
|
|
600
|
+
| { readonly kind: "role"; readonly addr: string }
|
|
601
|
+
| { readonly kind: "team"; readonly addr: string }
|
|
602
|
+
| { readonly kind: "children" }
|
|
603
|
+
|
|
604
|
+
/** A session subscription as the hub shows it: the subscriber hears its targets' events as batched hub messages. */
|
|
605
|
+
export interface SubscriptionView {
|
|
606
|
+
readonly id: string
|
|
607
|
+
readonly subscriber: string
|
|
608
|
+
readonly targets: readonly SubscriptionTarget[]
|
|
609
|
+
readonly types: readonly string[]
|
|
610
|
+
readonly delivery: "queue" | "steer" | "silent"
|
|
611
|
+
readonly batchMs: number
|
|
612
|
+
readonly maxPerHour: number
|
|
613
|
+
readonly expiresAt: number
|
|
614
|
+
readonly createdAt: number
|
|
615
|
+
/** user, ses:<id> or app:<token id>. */
|
|
616
|
+
readonly by: string
|
|
617
|
+
readonly note?: string
|
|
618
|
+
readonly pending: number
|
|
619
|
+
readonly stats: { readonly delivered: number; readonly dropped: number; readonly suppressed: number; readonly lastAt?: number }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** POST /api/agent/subscriptions: batch and expires in seconds; subscriber required for users and apps. */
|
|
623
|
+
export interface SubscribeInput {
|
|
624
|
+
readonly subscriber?: string
|
|
625
|
+
readonly to: string | readonly string[]
|
|
626
|
+
readonly types?: readonly string[]
|
|
627
|
+
readonly delivery?: "queue" | "steer" | "silent"
|
|
628
|
+
readonly batch?: number
|
|
629
|
+
readonly maxPerHour?: number
|
|
630
|
+
readonly expires?: number
|
|
631
|
+
readonly note?: string
|
|
632
|
+
}
|
|
633
|
+
|
|
596
634
|
/** A launch as the hub reports it (the spec with its defaults filled in). */
|
|
597
635
|
export interface LaunchView {
|
|
598
636
|
readonly id: string
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-spoke-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,11 +52,17 @@ class Bounded<V> {
|
|
|
52
52
|
* Who put each inbox item into a session, from OpenCode's session.inbox.enqueued events and the spoke's own
|
|
53
53
|
* deliveries: a user item is the user's (typed, or sent from the phone), a synthetic item with hub metadata is
|
|
54
54
|
* the hub's; anything else (OpenCode's own notes, compaction) has no origin.
|
|
55
|
+
*
|
|
56
|
+
* (Session findability) A user item with hub metadata but no messageID is a prompt the hub carried (the console's
|
|
57
|
+
* turn or steer, a Voice utterance, a launch's prompt): it stays the user's (it is a user turn: the hop chain starts
|
|
58
|
+
* over), and is also marked `carried`, so the hub never counts it again as a prompt typed in the terminal.
|
|
55
59
|
*/
|
|
56
60
|
export class InboxOrigins {
|
|
57
61
|
private readonly origins: Bounded<InboxOrigin>
|
|
62
|
+
private readonly carriedIDs: Bounded<true>
|
|
58
63
|
constructor(max = 1000) {
|
|
59
64
|
this.origins = new Bounded(max)
|
|
65
|
+
this.carriedIDs = new Bounded(max)
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
observe(event: { readonly type: string; readonly data?: unknown }): void {
|
|
@@ -67,7 +73,17 @@ export class InboxOrigins {
|
|
|
67
73
|
const metadata = record(record(item.payload).metadata)
|
|
68
74
|
// A hub message (hub metadata with its messageID) is never the user's, whatever item type OpenCode gives it.
|
|
69
75
|
if (metadata.source === PROMPT_SOURCE && typeof metadata.messageID === "string" && (item.type === "synthetic" || item.type === "user")) this.origins.set(data.inboxID, "hub")
|
|
70
|
-
else if (item.type === "user")
|
|
76
|
+
else if (item.type === "user") {
|
|
77
|
+
this.origins.set(data.inboxID, "user")
|
|
78
|
+
if (metadata.source === PROMPT_SOURCE) this.carriedIDs.set(data.inboxID, true)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether the item is a user prompt the hub carried; forgets it (read once, like take). */
|
|
83
|
+
carried(inboxID: string): boolean {
|
|
84
|
+
const carried = this.carriedIDs.get(inboxID) === true
|
|
85
|
+
this.carriedIDs.delete(inboxID)
|
|
86
|
+
return carried
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
/** Marks an inbox item the spoke delivered for the hub. */
|
|
@@ -44,9 +44,10 @@ export type OpHandlers = { readonly [O in HandledOp]?: OpHandler<O> }
|
|
|
44
44
|
export type DeliverRequest = Extract<ServerRequest, { op: "deliver" }>
|
|
45
45
|
/**
|
|
46
46
|
* Features that change how an op is served rather than adding one; each needs the op it modifies, except
|
|
47
|
-
* "turnEvents" (the spoke reports every turn's end as the harness confirms it)
|
|
47
|
+
* "turnEvents" (the spoke reports every turn's end as the harness confirms it) and "inboxCarried" (its user inbox
|
|
48
|
+
* items the hub carried are marked `carried: true`), which are about events, not an op.
|
|
48
49
|
*/
|
|
49
|
-
export type ModifierFeature = "permissionMessage" | "launch" | "hubPrompt" | "turnEvents"
|
|
50
|
+
export type ModifierFeature = "permissionMessage" | "launch" | "hubPrompt" | "turnEvents" | "inboxCarried"
|
|
50
51
|
|
|
51
52
|
export interface HarnessSpokeOptions extends Omit<HarnessHelloOptions, "ops" | "features"> {
|
|
52
53
|
readonly server: string
|
|
@@ -65,7 +66,7 @@ export interface HarnessSpokeOptions extends Omit<HarnessHelloOptions, "ops" | "
|
|
|
65
66
|
readonly onState?: ConnectionOptions["onState"]
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const MODIFIER_NEEDS: Record<ModifierFeature, string | null> = { permissionMessage: "permission", launch: "create", hubPrompt: "deliver", turnEvents: null }
|
|
69
|
+
const MODIFIER_NEEDS: Record<ModifierFeature, string | null> = { permissionMessage: "permission", launch: "create", hubPrompt: "deliver", turnEvents: null, inboxCarried: null }
|
|
69
70
|
const ALWAYS: readonly SpokeFeature[] = ["sessionTokens", "actTokens"]
|
|
70
71
|
const CORE: readonly string[] = CORE_OPS
|
|
71
72
|
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @huynle/hub-voice-client
|
|
2
|
+
|
|
3
|
+
Hub's hands-free voice loop as a package: an always-on microphone with on-device speech detection (Silero VAD),
|
|
4
|
+
optional end-of-turn detection (Smart Turn v3), speculative per-segment transcription, endpointing, voice commands
|
|
5
|
+
("stop", "pause listening"), sentence-by-sentence speech of the agent's replies with barge-in, and progress cues
|
|
6
|
+
(earcons and short phrases). The Hub console's Voice mode is built on it; other web apps can embed the same loop.
|
|
7
|
+
|
|
8
|
+
Internal workspace package (`private`, not published on its own). The console imports it from source
|
|
9
|
+
(`packages/voice-client/src`); an app that does not build it from source uses the standalone build (below).
|
|
10
|
+
|
|
11
|
+
## The transport
|
|
12
|
+
|
|
13
|
+
Everything the loop sends to or hears from its session goes through a `VoiceTransport`, the interface frozen by the
|
|
14
|
+
app-front contract (`docs/plans/2026-11-08-app-front-contracts.md` §7, `packages/protocol/src/app-front.ts`):
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
interface VoiceTransport {
|
|
18
|
+
conversation(on: boolean, options?: { front?: boolean }): Promise<{ on: boolean; front?: boolean }>
|
|
19
|
+
utterance(text: string): Promise<{ inboxID: string }>
|
|
20
|
+
interrupt(): Promise<void>
|
|
21
|
+
transcribe(audio: Blob, options?: { signal?: AbortSignal }): Promise<{ text: string }>
|
|
22
|
+
speak(text: string, options?: { signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>> // audio/mpeg
|
|
23
|
+
events(listener: (event: VoiceEvent) => void): () => void // say | heard | conversation | status | reset
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **The package never holds a Hub token and never calls the Hub.** The console's transport
|
|
28
|
+
(`web/src/voice/console-transport.ts`) uses its cookie routes and SSE; an app's transport calls **its own server**,
|
|
29
|
+
which keeps the app's Hub credentials and proxies to `/api/agent/speech/*` and `/api/agent/sessions/:addr/*`
|
|
30
|
+
(contract §4, §5). **`appTransport({base})`** is that app transport, ready made (below).
|
|
31
|
+
- `events` delivers the session's `say`, `heard` and `conversation` events (and, from an app stream, `status` and
|
|
32
|
+
`reset`: a `reset` drops the replies still to speak). `VoiceEvents` is a small multicast source for transports that
|
|
33
|
+
push events in.
|
|
34
|
+
- A transport may accept more than the contract (`VoiceClientTransport`: a `keepalive` for the page-hide
|
|
35
|
+
`conversation(false)`, request `headers` for the recording plug-in, `agent` in the answer); every plain
|
|
36
|
+
`VoiceTransport` is accepted.
|
|
37
|
+
|
|
38
|
+
### `appTransport`: the app's own server
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { appTransport } from "@huynle/hub-voice-client"
|
|
42
|
+
const transport = appTransport({ base: `/hub/voice/sessions/${sessionID}`, onEnd: (reason) => console.log(reason) })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
It calls six routes under `base` on the page's origin, with the page's own cookie (`credentials: "same-origin"`, or
|
|
46
|
+
your `fetch`): `POST {base}/conversation {on, front?}`, `POST {base}/utterance {text}`, `POST {base}/interrupt`,
|
|
47
|
+
`POST {base}/transcribe` (the raw audio, its type as `Content-Type`), `POST {base}/speak {text}` (audio/mpeg back) and
|
|
48
|
+
`GET {base}/stream` (the hub's app stream, relayed as SSE). Your server proxies them to the hub with the user's token
|
|
49
|
+
(the reference: `examples/app-front-bff`). `events` opens one relayed stream for all listeners, maps `say`, `heard`,
|
|
50
|
+
`conversation`, `status` and `reset`, and resumes with `Last-Event-ID` after a drop or `end` `slow`/`token_expired`,
|
|
51
|
+
with exponential backoff (jittered, capped; `reconnectDelayMs`, `maxReconnectDelayMs`). A stream that closes without
|
|
52
|
+
progress counts as a failure; `end` `revoked`/`grant`/`gone`, a 4xx from your server, or `maxReconnects` (5)
|
|
53
|
+
failures in a row stop it (`onEnd(reason)`, `unavailable` for the last). Refusals throw
|
|
54
|
+
`AppTransportError {status, code?, retryAfter?}` (the hub's envelope passed through).
|
|
55
|
+
|
|
56
|
+
### Your own transport
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { Conversation, VoiceEvents, sharedPlayer, unlockAudio } from "@huynle/hub-voice-client"
|
|
60
|
+
|
|
61
|
+
const events = new VoiceEvents() // fed from your server's relay of the session's stream
|
|
62
|
+
const transport = {
|
|
63
|
+
conversation: (on, o) => post(`/my/voice/conversation`, { on, ...o }),
|
|
64
|
+
utterance: (text) => post(`/my/voice/utterance`, { text }),
|
|
65
|
+
interrupt: () => post(`/my/voice/interrupt`, {}),
|
|
66
|
+
transcribe: async (audio) => (await fetch("/my/voice/transcribe", { method: "POST", body: audio })).json(),
|
|
67
|
+
speak: async (text, o) => (await fetch("/my/voice/speak", { method: "POST", body: JSON.stringify({ text }), signal: o?.signal })).body!,
|
|
68
|
+
events: (listener) => events.subscribe(listener),
|
|
69
|
+
}
|
|
70
|
+
unlockAudio() // inside the tap that starts Voice
|
|
71
|
+
const conversation = new Conversation({ sessionID, transport, player: sharedPlayer(), ui, options: { front: () => true } })
|
|
72
|
+
await conversation.start()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`ui` (`ConversationUI`) receives the loop's state, mic level, user and agent bubbles, notes and the turn timing.
|
|
76
|
+
`VoiceController` and `VoiceNotifier` (one Voice session at a time, the "Voice mode on" notification) are optional
|
|
77
|
+
app-level helpers.
|
|
78
|
+
|
|
79
|
+
## Plug-ins (all optional)
|
|
80
|
+
|
|
81
|
+
| Option | What | The console's |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `speakable` | rewrites a reply before it is split into sentences | spoken diagrams: Mermaid fences read as "a diagram" (`web/src/lib/mermaid.ts`) |
|
|
84
|
+
| `voiceKey` | the TTS voice the cue cache and reply loudness are kept per | `GET /api/settings/speech` |
|
|
85
|
+
| `recorder` (`ConversationRecorder`) | records every step of the conversation | Voice recording (REC-02, `web/src/voice/recorder.ts`) |
|
|
86
|
+
| `workletURL` | the microphone AudioWorklet | `/vad-worklet.js` (the same file as `src/vad-worklet.js`) |
|
|
87
|
+
|
|
88
|
+
**Recording uploads are off by default**: without a `recorder` nothing is recorded and no recording headers are sent.
|
|
89
|
+
Speech settings, models and keys are never read or changed by the package (the console's settings pages do that).
|
|
90
|
+
|
|
91
|
+
## Assets, workers and the standalone build
|
|
92
|
+
|
|
93
|
+
`loadSilero()` and `loadSmartTurn()` start module workers (`silero-worker.ts`, `smart-turn-worker.ts`) that run
|
|
94
|
+
onnxruntime-web's single-threaded wasm build (no SharedArrayBuffer, so no COOP/COEP headers) on the bundled models.
|
|
95
|
+
They and the AudioWorklet are loaded with `new URL(…, import.meta.url)`, which Vite (and other bundlers that support
|
|
96
|
+
the pattern) emits as hashed files; the models are `?url` imports in the workers.
|
|
97
|
+
|
|
98
|
+
The package has no dependencies of its own: building it needs `onnxruntime-web` 1.23.2 (the root
|
|
99
|
+
devDependencies' version), and a Vite-compatible bundler when built from source.
|
|
100
|
+
|
|
101
|
+
`npm run build:voice-client` (root) builds `packages/voice-client/dist/` for apps that do not bundle the sources:
|
|
102
|
+
|
|
103
|
+
- `index.js`: the package as one ES module (the exports of `src/index.ts`);
|
|
104
|
+
- `assets/`: the two workers, onnxruntime-web's `.wasm` (12 MB, compresses to ~3 MB), the two `.onnx` models
|
|
105
|
+
(Silero VAD 2.3 MB, Smart Turn 8.7 MB) and the AudioWorklet, hashed and referenced relative to `index.js`;
|
|
106
|
+
- `licenses/`: the models' licence files.
|
|
107
|
+
|
|
108
|
+
Serve `dist/` from the page's own origin (module workers and worklets must be same-origin) and cache `assets/` for
|
|
109
|
+
good (the names are content-hashed).
|
|
110
|
+
|
|
111
|
+
## Content-Security-Policy for embedding apps
|
|
112
|
+
|
|
113
|
+
The page's policy needs, beyond the app's usual sources:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
worker-src 'self'; # the two module workers (same-origin files; blob: and data: are not needed)
|
|
117
|
+
script-src 'self'; # the AudioWorklet module (addModule) and the package's chunks
|
|
118
|
+
connect-src 'self'; # the workers fetch the .wasm and .onnx files; your transport calls your server
|
|
119
|
+
media-src 'self' blob:; # replies and cues play from blob: URLs (MediaSource or whole clips)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**wasm and workers.** onnxruntime-web compiles its wasm inside the workers. A worker runs under the policy sent with
|
|
123
|
+
**its own script** (not the page's): when your server sends no CSP on `assets/*-worker-*.js` (the console's sends its
|
|
124
|
+
policy with the HTML only), nothing more is needed. When it sends one on every response (many frameworks do), that
|
|
125
|
+
policy must allow `script-src 'self' 'wasm-unsafe-eval'` and `connect-src 'self'`, or speech detection silently falls
|
|
126
|
+
back to the energy detector and Smart Turn to the timers. Nothing is inlined, no `eval`, no CORS.
|
|
127
|
+
|
|
128
|
+
The console's own policy is `src/central/csp.ts`; `web/e2e/voice-smart-turn.ts` and `web/e2e/voice-recording.ts` run the
|
|
129
|
+
loop under it, enforced.
|
|
130
|
+
|
|
131
|
+
## Licences
|
|
132
|
+
|
|
133
|
+
The package is MIT (as Hub). The bundled models keep their own licences, shipped next to them and copied into the
|
|
134
|
+
standalone build:
|
|
135
|
+
|
|
136
|
+
- `src/vad/silero_vad.onnx`: Silero VAD v5, MIT (`src/vad/LICENSE`), https://github.com/snakers4/silero-vad
|
|
137
|
+
- `src/turn/smart-turn-v3.2-cpu.onnx`: Pipecat Smart Turn v3.2 (int8), BSD-2-Clause (`src/turn/LICENSE`),
|
|
138
|
+
https://huggingface.co/pipecat-ai/smart-turn-v3
|
|
139
|
+
|
|
140
|
+
## Tests
|
|
141
|
+
|
|
142
|
+
`packages/voice-client/test`: the transport is the contract's type exactly, a plain contract transport drives a whole
|
|
143
|
+
conversation, the package imports nothing outside itself (no `web/`, no `src/`, no other workspace package, no
|
|
144
|
+
`solid-js`, no hard-coded `/api/` calls) and the models ship with their licences. The loop's behaviour tests are
|
|
145
|
+
`test/web-*.test.ts` (through the console's adapters) and the browser e2e `web/e2e/voice-*.ts`.
|
|
146
|
+
`npm run typecheck` includes `tsc -p packages/voice-client` (the package on its own).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020-present Silero Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// POST /api/agent/sessions/:addr/stop {why?} (body optional)
|
|
8
8
|
//
|
|
9
9
|
// Users always; sessions only through their act token (allow.act; a shell token never acts); app tokens with
|
|
10
|
-
// scope sessions:act.
|
|
10
|
+
// scope sessions:act. (AF3) The prompt also admits conversation:write (typed turns of a conversation): such an app
|
|
11
|
+
// token, without sessions:act, may prompt only its own sessions (`appSession`: 404 not_found for any other).
|
|
12
|
+
// Answers 200 with the engine's result, never cached. Errors: invalid 400, needs_user /
|
|
11
13
|
// forbidden 403 (needs_user carries `evaluation` and, once P3-T8 escalates, `decisionID`), not_found 404, gone 410,
|
|
12
14
|
// unsupported 422, paused 423, rate_limited 429, offline 503.
|
|
13
15
|
import { DELIVER_ID_MAX, DELIVERY_MODES, type DeliveryMode, type FormAnswer, formAnswerProblem, type PromptImage, promptImagesProblem } from "../../protocol"
|
|
14
16
|
import type { Acting } from "./acting"
|
|
15
17
|
import { AgentError } from "./errors"
|
|
18
|
+
import type { AgentActor } from "./actor"
|
|
16
19
|
import type { AgentRoute } from "./routes"
|
|
17
20
|
import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
|
|
18
21
|
|
|
@@ -20,6 +23,13 @@ import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
|
|
|
20
23
|
const PREFIX = "/api/agent/sessions/:addr"
|
|
21
24
|
const NO_STORE = { "cache-control": "no-store" }
|
|
22
25
|
const ACT = { act: true, scope: "sessions:act" } as const
|
|
26
|
+
/** (AF3) Typed turns: sessions:act, or conversation:write for the app's own sessions. */
|
|
27
|
+
const PROMPT = { act: true, scopes: ["sessions:act", "conversation:write"] } as const
|
|
28
|
+
|
|
29
|
+
export interface ActingRouteOptions {
|
|
30
|
+
/** (AF3) Throws not_found unless the addressed session is the app's (contract §1.2). */
|
|
31
|
+
readonly appSession?: (actor: AgentActor, userID: string, addr: string) => Promise<unknown>
|
|
32
|
+
}
|
|
23
33
|
/** Longest `why`. */
|
|
24
34
|
export const WHY_MAX = 1000
|
|
25
35
|
|
|
@@ -56,7 +66,7 @@ function flag(body: Body, key: string): boolean | undefined {
|
|
|
56
66
|
|
|
57
67
|
const ok = (result: unknown) => Response.json(result, { headers: NO_STORE })
|
|
58
68
|
|
|
59
|
-
export function actingRoutes(acting: Acting): AgentRoute[] {
|
|
69
|
+
export function actingRoutes(acting: Acting, options: ActingRouteOptions = {}): AgentRoute[] {
|
|
60
70
|
return [
|
|
61
71
|
{
|
|
62
72
|
method: "POST",
|
|
@@ -97,8 +107,12 @@ export function actingRoutes(acting: Acting): AgentRoute[] {
|
|
|
97
107
|
{
|
|
98
108
|
method: "POST",
|
|
99
109
|
path: `${PREFIX}/prompt`,
|
|
100
|
-
allow:
|
|
110
|
+
allow: PROMPT,
|
|
101
111
|
async handle({ request, params, actor, user }) {
|
|
112
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:act")) {
|
|
113
|
+
if (!options.appSession) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
114
|
+
await options.appSession(actor, user.id, params.addr!)
|
|
115
|
+
}
|
|
102
116
|
const body = await bodyOf(request)
|
|
103
117
|
if (typeof body.text !== "string") throw invalid("text must be a string")
|
|
104
118
|
const delivery = body.delivery
|
|
@@ -135,6 +135,8 @@ export const CONCIERGE_NO_GRANT_REASON = "No grant lets it approve here: the use
|
|
|
135
135
|
export interface Caller {
|
|
136
136
|
readonly actor: AgentActor
|
|
137
137
|
readonly userID: string
|
|
138
|
+
/** (Concierge front hardening) Record the user actor's device on the audit entry (a relay by voice). */
|
|
139
|
+
readonly auditDevice?: boolean
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
export interface ApproveInput {
|
|
@@ -648,6 +650,7 @@ export class Acting {
|
|
|
648
650
|
decision,
|
|
649
651
|
...(reason ? { reason } : {}),
|
|
650
652
|
outcome,
|
|
653
|
+
...(caller.auditDevice && actor.kind === "user" && actor.deviceID ? { deviceID: actor.deviceID } : {}),
|
|
651
654
|
})
|
|
652
655
|
try {
|
|
653
656
|
if (actor.kind !== "user") {
|
|
@@ -664,8 +667,9 @@ export class Acting {
|
|
|
664
667
|
acting.ensureActing(userID)
|
|
665
668
|
}
|
|
666
669
|
// (CON-13) What the spoke said when it refused, never a bare 500.
|
|
670
|
+
// (AF3 observation 2) An app never gets the spoke's words (paths, commands): a generic refusal; the hub logs them.
|
|
667
671
|
const result = await plan.run(grant).catch((cause: unknown) => {
|
|
668
|
-
throw refusedBySession(cause)
|
|
672
|
+
throw refusedBySession(cause, { generic: actor.kind === "app" })
|
|
669
673
|
})
|
|
670
674
|
await record("allow", "ok", why)
|
|
671
675
|
return result
|
|
@@ -818,11 +822,16 @@ const gone = () => new AgentError("gone", "Already answered, expired or ended")
|
|
|
818
822
|
/**
|
|
819
823
|
* (CON-13) The session's harness refused the act (OpenCode said why, e.g. "Unknown form field: Questions"): 400
|
|
820
824
|
* invalid with its words (bounded, one line), never a 500 "Internal error" a model would repeat to the user. The
|
|
821
|
-
* audit keeps it as allowed · failed. A disconnect stays offline; hub errors pass through.
|
|
825
|
+
* audit keeps it as allowed · failed. A disconnect stays offline; hub errors pass through. (AF3) `generic` (app actors):
|
|
826
|
+
* "The session refused it" only, to the app and in the audit; the spoke's words go to the hub's log.
|
|
822
827
|
*/
|
|
823
|
-
export function refusedBySession(cause: unknown): unknown {
|
|
828
|
+
export function refusedBySession(cause: unknown, options: { readonly generic?: boolean } = {}): unknown {
|
|
824
829
|
if (cause instanceof AgentError || cause instanceof NeedsUser || cause instanceof BusyError) return cause
|
|
825
830
|
if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) return new AgentError("offline", "The session's spoke disconnected")
|
|
831
|
+
if (options.generic) {
|
|
832
|
+
console.warn(`[hub] an app's act was refused by the session: ${sanitizeText(cause instanceof Error ? cause.message : String(cause)).replace(/\s+/g, " ").trim().slice(0, 300)}`)
|
|
833
|
+
return new AgentError("invalid", "The session refused it", { extra: { refusedBy: "session" } })
|
|
834
|
+
}
|
|
826
835
|
const said = sanitizeText(cause instanceof Error ? cause.message : String(cause)).replace(/\s+/g, " ").trim().slice(0, 300) || "no reason given"
|
|
827
836
|
return new AgentError("invalid", `The session refused it: ${said}`, { extra: { refusedBy: "session" } })
|
|
828
837
|
}
|
|
@@ -40,6 +40,9 @@ export interface AppActor {
|
|
|
40
40
|
readonly tokenID: string
|
|
41
41
|
readonly scopes: readonly string[]
|
|
42
42
|
readonly label?: string
|
|
43
|
+
/** (AF1) A connected app: its client (tokenID is then the connection ID, stable across refresh). */
|
|
44
|
+
readonly clientID?: string
|
|
45
|
+
readonly connection?: true
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export type AgentActor = UserActor | SessionActor | AppActor
|
|
@@ -166,6 +169,8 @@ export interface AgentAllow {
|
|
|
166
169
|
readonly session?: boolean
|
|
167
170
|
/** App tokens holding this scope may call it. */
|
|
168
171
|
readonly scope?: string
|
|
172
|
+
/** (AF3) App tokens holding any of these scopes may call it (the route narrows what each admits). */
|
|
173
|
+
readonly scopes?: readonly string[]
|
|
169
174
|
/** Any app token may call it (introspection such as whoami). */
|
|
170
175
|
readonly app?: boolean
|
|
171
176
|
/**
|
|
@@ -188,6 +193,6 @@ export function agentAllows(allow: AgentAllow, actor: AgentActor): boolean {
|
|
|
188
193
|
case "session":
|
|
189
194
|
return allow.act === true ? canActAsSession(actor) : allow.session === true
|
|
190
195
|
case "app":
|
|
191
|
-
return allow.app === true || (allow.scope !== undefined && actor.scopes.includes(allow.scope))
|
|
196
|
+
return allow.app === true || (allow.scope !== undefined && actor.scopes.includes(allow.scope)) || (allow.scopes?.some((scope) => actor.scopes.includes(scope)) ?? false)
|
|
192
197
|
}
|
|
193
198
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Decisions from apps (AF4, docs/plans/2026-11-08-app-front-contracts.md §6; Hub decision w6lfbfaq 1; user decision
|
|
2
|
+
// 2026-11-09): an app holding `decisions:answer` lists and answers confirm-tier Decisions of ITS sessions.
|
|
3
|
+
//
|
|
4
|
+
// GET /api/agent/decisions?answerable=1[&state=] decisions:answer {decisions}: only those this app may answer
|
|
5
|
+
// POST /api/agent/decisions/:id/answer {option?, text?} decisions:answer {decision} (answer.answeredBy "app")
|
|
6
|
+
// The list shares its path with the asker's GET /api/agent/decisions (message-routes.ts), which hands `answerable`
|
|
7
|
+
// requests here (`answerable`). Both are for app credentials only: the user answers on the console (/api/decisions),
|
|
8
|
+
// sessions never answer Decisions.
|
|
9
|
+
//
|
|
10
|
+
// "The app's sessions" for Decisions (user decision 2026-11-09): the asker (`from`) is a SESSION that the app launched,
|
|
11
|
+
// or that an active grant the app holds covers and whose verbs allow `answer` (a prompt-only grant does not imply
|
|
12
|
+
// answering); subagents count with their root; a deleted session, a Concierge session and every other asker (another
|
|
13
|
+
// session, an app, the user) read as unknown: 404 not_found, so IDs never leak. Re-checked on every request, so a grant
|
|
14
|
+
// revoked or narrowed after the list refuses the answer. Then (Decisions.answerByApp): open (else 410 gone), confirm
|
|
15
|
+
// tier with no action and not Concierge-asked (403 forbidden "This decision needs the user"), a once-off option (a
|
|
16
|
+
// lasting one: 403), the kill switch (423 paused) and the version-checked settle: the first answer wins across the
|
|
17
|
+
// console and every app (the later one: 410 gone). A body with `via` is 400 invalid (an app's answer is never the
|
|
18
|
+
// user's tap, voice or text). The asker hears which app answered; the agent audit lists every attempt on an app's
|
|
19
|
+
// session as `decision.answer` by app:<id> (unowned IDs are not audited: guessing cannot flood it). Failures carry the
|
|
20
|
+
// hub's own words only (agentFailure: an internal error is a generic "Internal error").
|
|
21
|
+
import type { Store } from "../store"
|
|
22
|
+
import type { AppActor } from "./actor"
|
|
23
|
+
import { auditOf } from "./acting"
|
|
24
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
25
|
+
import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
|
|
26
|
+
import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
|
|
27
|
+
import { type AppSessionDeps, isAppSession, locateSession } from "./conversation"
|
|
28
|
+
import { DECISION_STATES, type Decision, type DecisionState, type Decisions } from "./decisions"
|
|
29
|
+
import { partyFromActor } from "./envelope"
|
|
30
|
+
import { AgentError } from "./errors"
|
|
31
|
+
import type { Message } from "./messages"
|
|
32
|
+
import type { GrantVerb } from "./policy"
|
|
33
|
+
import type { AgentContext, AgentRoute } from "./routes"
|
|
34
|
+
|
|
35
|
+
// A literal: routes.ts imports this module.
|
|
36
|
+
const PREFIX = "/api/agent/decisions"
|
|
37
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
38
|
+
|
|
39
|
+
/** The grant verbs that make a session an app's for Decisions (user decision 2026-11-09). */
|
|
40
|
+
export const DECISION_VERBS: readonly GrantVerb[] = ["answer"]
|
|
41
|
+
|
|
42
|
+
export interface AppDecisionDeps extends AppSessionDeps {
|
|
43
|
+
readonly decisions: Pick<Decisions, "list" | "answerByApp">
|
|
44
|
+
readonly acting: Pick<ActingSwitch, "ensureActing">
|
|
45
|
+
readonly audit: Pick<AgentAudit, "record">
|
|
46
|
+
readonly store: Pick<Store, "audit">
|
|
47
|
+
/** The asker's reply, to the message feed (watch streams, waiters). */
|
|
48
|
+
readonly publish?: (userID: string, message: Message) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const forbiddenFor = () => new AgentError("forbidden", "This route is for app credentials", { hint: "Answer Decisions in the console" })
|
|
52
|
+
|
|
53
|
+
/** Per request: the app's ownership of Decisions' askers, reading grants and launches once. */
|
|
54
|
+
function ownership(deps: AppSessionDeps, actor: AppActor, userID: string) {
|
|
55
|
+
let grants: ReturnType<AppSessionDeps["grants"]["list"]> | undefined
|
|
56
|
+
let launches: ReturnType<NonNullable<AppSessionDeps["launches"]>["list"]> | undefined
|
|
57
|
+
const cached: AppSessionDeps = {
|
|
58
|
+
...deps,
|
|
59
|
+
grants: { list: (id: string) => (grants ??= deps.grants.list(id)) },
|
|
60
|
+
...(deps.launches ? { launches: { list: (id: string) => (launches ??= deps.launches!.list(id)) } } : {}),
|
|
61
|
+
}
|
|
62
|
+
/** The asking session when it is the app's (Decisions), undefined otherwise. */
|
|
63
|
+
return async (decision: Decision): Promise<string | undefined> => {
|
|
64
|
+
if (decision.from.kind !== "session") return undefined
|
|
65
|
+
let target
|
|
66
|
+
try {
|
|
67
|
+
target = locateSession(cached, userID, decision.from.sessionID)
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
if (cause instanceof AgentError && cause.code === "not_found") return undefined
|
|
70
|
+
throw cause
|
|
71
|
+
}
|
|
72
|
+
return (await isAppSession(cached, actor, userID, target, DECISION_VERBS)) ? target.sessionID : undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function answerBody(request: Request): Promise<{ option?: string; text?: string }> {
|
|
77
|
+
const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
|
|
78
|
+
const body: unknown = JSON.parse(raw)
|
|
79
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
80
|
+
const input = body as Record<string, unknown>
|
|
81
|
+
if ("via" in input) throw new AgentError("invalid", "An app's answer has no via", { hint: "Send {option} or {text}" })
|
|
82
|
+
for (const key of ["option", "text"] as const) {
|
|
83
|
+
if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
|
|
84
|
+
}
|
|
85
|
+
return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stateQuery(raw: string | null): DecisionState | undefined {
|
|
89
|
+
if (raw === null || raw === "") return undefined
|
|
90
|
+
if (!DECISION_STATES.includes(raw as DecisionState)) throw new AgentError("invalid", `state is one of ${DECISION_STATES.join(", ")}`)
|
|
91
|
+
return raw as DecisionState
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function appDecisionRoutes(deps: AppDecisionDeps): { readonly answerable: (context: AgentContext) => Promise<Response>; readonly routes: AgentRoute[] } {
|
|
95
|
+
/** GET /api/agent/decisions?answerable=1: the Decisions this app may answer (any state unless `state`). */
|
|
96
|
+
async function answerable({ actor, user, url }: AgentContext): Promise<Response> {
|
|
97
|
+
if (actor.kind !== "app") throw forbiddenFor()
|
|
98
|
+
if (!actor.scopes.includes("decisions:answer")) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
99
|
+
if (url.searchParams.get("answerable") !== "1") throw new AgentError("invalid", "answerable takes 1")
|
|
100
|
+
const state = stateQuery(url.searchParams.get("state"))
|
|
101
|
+
const owner = ownership(deps, actor, user.id)
|
|
102
|
+
const decisions: Decision[] = []
|
|
103
|
+
for (const decision of await deps.decisions.list(user.id, state ? { state } : {})) {
|
|
104
|
+
// The cheap rules first: only a confirm-tier Decision without an action that no Concierge asked.
|
|
105
|
+
if (decision.tier !== "confirm" || decision.action !== undefined || decision.as === "concierge") continue
|
|
106
|
+
if (await owner(decision)) decisions.push(decision)
|
|
107
|
+
}
|
|
108
|
+
return Response.json({ decisions }, { headers: NO_STORE })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
answerable,
|
|
113
|
+
routes: [
|
|
114
|
+
{
|
|
115
|
+
method: "POST",
|
|
116
|
+
path: `${PREFIX}/:id/answer`,
|
|
117
|
+
allow: { scope: "decisions:answer" },
|
|
118
|
+
async handle({ actor, user, request, params }) {
|
|
119
|
+
if (actor.kind !== "app") throw forbiddenFor()
|
|
120
|
+
const input = await answerBody(request)
|
|
121
|
+
const owner = ownership(deps, actor, user.id)
|
|
122
|
+
let sessionID: string | undefined
|
|
123
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
124
|
+
deps.audit.record(user.id, { actor: partyFromActor(actor), verb: "decision.answer", target: { sessionID: sessionID! }, decision, ...(reason ? { reason } : {}), outcome })
|
|
125
|
+
try {
|
|
126
|
+
const answered = await deps.decisions.answerByApp(user.id, params.id!, input, { party: `app:${actor.tokenID}`, ...(actor.label ? { label: actor.label } : {}) }, {
|
|
127
|
+
owned: async (decision) => (sessionID = await owner(decision)) !== undefined,
|
|
128
|
+
ready: () => deps.acting.ensureActing(user.id),
|
|
129
|
+
})
|
|
130
|
+
if (answered.reply) deps.publish?.(user.id, answered.reply)
|
|
131
|
+
await record("allow", "ok", `Decision ${params.id!}`)
|
|
132
|
+
await deps.store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: `app:${actor.tokenID} ${answered.decision.answer?.option ?? "text"}` })
|
|
133
|
+
return Response.json({ decision: answered.decision }, { headers: NO_STORE })
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
// Only attempts on the app's own sessions' Decisions (unknown or foreign IDs are not audited).
|
|
136
|
+
if (sessionID !== undefined) {
|
|
137
|
+
const [decision, outcome] = cause instanceof AgentError && cause.code === "gone" ? (["allow", "gone"] as const) : auditOf(cause)
|
|
138
|
+
await record(decision, outcome, cause instanceof AgentError ? cause.message : undefined).catch((error: unknown) => console.error(`[hub] audit of an app's decision answer failed: ${(error as Error).message}`))
|
|
139
|
+
}
|
|
140
|
+
throw cause
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -18,9 +18,13 @@ import type { AgentRoute } from "./routes"
|
|
|
18
18
|
/**
|
|
19
19
|
* Scopes an app token may hold: reading sessions; sending (msg, ask, reply, decline, mute) and reading messages;
|
|
20
20
|
* acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
|
|
21
|
-
* needs a grant held by the token (with the act verb, or `launch` covering the repo)
|
|
21
|
+
* needs a grant held by the token (with the act verb, or `launch` covering the repo); (AF2) `speech:use`: transcribe
|
|
22
|
+
* and speak with the user's speech provider (agent/speech-routes.ts); (AF3) `conversation:read` (the stream and log
|
|
23
|
+
* of the app's sessions) and `conversation:write` (conversation, utterance, interrupt and typed turns, each behind a
|
|
24
|
+
* prompt grant) (agent/conversation.ts); (AF4) `decisions:answer`: list and answer confirm-tier Decisions of the app's
|
|
25
|
+
* sessions (launched by it, or under a grant allowing answer) (agent/app-decisions.ts).
|
|
22
26
|
*/
|
|
23
|
-
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe", "profiles:read", "profiles:manage", "profiles:assign", "workspaces:read", "workspaces:manage", "artifacts:read", "artifacts:write", "artifacts:ai", "teams:submit", "teams:read", "teams:manage"]
|
|
27
|
+
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe", "profiles:read", "profiles:manage", "profiles:assign", "workspaces:read", "workspaces:manage", "artifacts:read", "artifacts:write", "artifacts:ai", "teams:submit", "teams:read", "teams:manage", "speech:use", "conversation:read", "conversation:write", "decisions:answer"]
|
|
24
28
|
export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
|
|
25
29
|
/** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
|
|
26
30
|
export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
|
|
@@ -115,7 +119,7 @@ export async function issueAppToken(store: Store, userID: string, input: { reado
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** POST, GET and DELETE /api/agent/app-tokens: user actors only. */
|
|
118
|
-
export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
122
|
+
export function appTokenRoutes(store: Store, options: { readonly revoked?: (userID: string) => void } = {}): AgentRoute[] {
|
|
119
123
|
return [
|
|
120
124
|
{
|
|
121
125
|
method: "POST",
|
|
@@ -146,6 +150,8 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
|
146
150
|
})
|
|
147
151
|
if (!found) throw new AgentError("not_found", "No such app token")
|
|
148
152
|
await store.audit({ actor: user.id, action: "app-token.revoke", target: id })
|
|
153
|
+
// (AF3) Its open streams end now, not at their next re-check.
|
|
154
|
+
options.revoked?.(user.id)
|
|
149
155
|
return new Response(null, { status: 204, headers: NO_STORE })
|
|
150
156
|
},
|
|
151
157
|
},
|
|
@@ -55,6 +55,8 @@ export interface AuditInput {
|
|
|
55
55
|
readonly outcome: AuditOutcome
|
|
56
56
|
/** (CON-06) Done by one of the user's Concierge sessions (current or past). */
|
|
57
57
|
readonly as?: "concierge"
|
|
58
|
+
/** (Concierge front hardening) The user's device the act came from (a relay by voice), when the caller records it. */
|
|
59
|
+
readonly deviceID?: string
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export interface AgentAuditEntry extends AuditInput {
|
|
@@ -141,6 +143,7 @@ export class AgentAudit {
|
|
|
141
143
|
...(reason ? { reason } : {}),
|
|
142
144
|
outcome: input.outcome,
|
|
143
145
|
...(as ? { as } : {}),
|
|
146
|
+
...(typeof input.deviceID === "string" && /^[A-Za-z0-9_-]{1,100}$/.test(input.deviceID) ? { deviceID: input.deviceID } : {}),
|
|
144
147
|
}
|
|
145
148
|
await state.put(userID, ENTRIES, keyOf(seq), entry)
|
|
146
149
|
if (seq > this.limit) await state.delete(userID, ENTRIES, keyOf(seq - this.limit))
|