@huynle/hub 0.9.2 → 0.10.1
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 +320 -0
- package/Dockerfile +5 -1
- package/README.md +154 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +534 -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 +81 -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 +547 -58
- 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 +227 -237
- 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-CsPjam8D.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-CuRIffka.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.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-DUuQ9jmd.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-bX8B6NLo.js +33 -0
- package/web/dist/assets/index-rW6LKQj1.css +1 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.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-DyANQ1WS.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.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
|
@@ -16,7 +16,13 @@ import { directoryRoutes } from "./directory-routes"
|
|
|
16
16
|
import { type ReadDeps, readRoutes, type ReadRegistry, type SpokeRequester } from "./read-routes"
|
|
17
17
|
import { agentAllows, type AgentActor, type AgentAllow, type BearerVerifier, type Credential, type Resolver } from "./actor"
|
|
18
18
|
import { appTokenRoutes, appTokenVerifier } from "./app-tokens"
|
|
19
|
+
import type { AppConnect } from "./connect"
|
|
20
|
+
import { connectRoutes } from "./connect-routes"
|
|
21
|
+
import { speechRoutes, type SpeechRouteDeps } from "./speech-routes"
|
|
22
|
+
import { appSessionCheck, type AppSessionDeps, type AppStreams, conversationRoutes, type ConversationPorts, type StreamSource } from "./conversation"
|
|
23
|
+
import { APP_FRONT_FEATURES, APP_FRONT_SHIPPED } from "../../protocol"
|
|
19
24
|
import type { Decisions } from "./decisions"
|
|
25
|
+
import { appDecisionRoutes } from "./app-decisions"
|
|
20
26
|
import type { Delivery } from "./delivery"
|
|
21
27
|
import { MessageFeed, messageRoutes } from "./message-routes"
|
|
22
28
|
import type { SessionTokens } from "./session-tokens"
|
|
@@ -31,6 +37,7 @@ import { actingRoutes } from "./acting-routes"
|
|
|
31
37
|
import type { Actions } from "../actions"
|
|
32
38
|
import { launchRoutes, type RouteLaunches } from "./launch-routes"
|
|
33
39
|
import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
|
|
40
|
+
import { type RouteSubscriptions, subscriptionRoutes } from "./subscription-routes"
|
|
34
41
|
import { pinAuthorizer } from "../profiles/apply"
|
|
35
42
|
import { profileRoutes } from "../profiles/routes"
|
|
36
43
|
import { ProfileUsage } from "../profiles/usage"
|
|
@@ -61,6 +68,8 @@ import type { ConciergeService } from "../concierge/service"
|
|
|
61
68
|
import type { SessionIntent } from "../../protocol"
|
|
62
69
|
import type { SessionIntents } from "./intents"
|
|
63
70
|
import { intentRoutes } from "./intent-routes"
|
|
71
|
+
import { contextCardRoutes } from "../context-cards/routes"
|
|
72
|
+
import type { ContextCardService } from "../context-cards/service"
|
|
64
73
|
|
|
65
74
|
export const AGENT_PREFIX = "/api/agent"
|
|
66
75
|
|
|
@@ -89,6 +98,23 @@ export interface AgentRoute {
|
|
|
89
98
|
/** What the agent API needs from the hub. */
|
|
90
99
|
export interface AgentDeps {
|
|
91
100
|
readonly store: Store
|
|
101
|
+
/** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
|
|
102
|
+
readonly connect?: AppConnect
|
|
103
|
+
/** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
|
|
104
|
+
readonly speech?: SpeechRouteDeps
|
|
105
|
+
/**
|
|
106
|
+
* (AF3) Conversation for apps: the stream and conversation/utterance/interrupt routes (with grants, acting, audit,
|
|
107
|
+
* registry and directory), the prompt and log widened to conversation:write/read for the app's sessions.
|
|
108
|
+
*/
|
|
109
|
+
readonly conversation?: {
|
|
110
|
+
readonly ports: ConversationPorts
|
|
111
|
+
readonly source: StreamSource
|
|
112
|
+
readonly streams: AppStreams
|
|
113
|
+
readonly cookieName: string
|
|
114
|
+
readonly heartbeatMs?: number
|
|
115
|
+
readonly recheckMs?: number
|
|
116
|
+
readonly bufferMaxBytes?: number
|
|
117
|
+
}
|
|
92
118
|
/** (REC-01) Voice recordings: the Concierge's agent calls (recordConciergeCalls). */
|
|
93
119
|
readonly recording?: ConciergeCallRecorder
|
|
94
120
|
/** Hub-minted session tokens (P1-T5). */
|
|
@@ -125,6 +151,8 @@ export interface AgentDeps {
|
|
|
125
151
|
readonly launches?: RouteLaunches
|
|
126
152
|
/** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
|
|
127
153
|
readonly webhooks?: RouteWebhooks
|
|
154
|
+
/** Session subscriptions (decision 74qawely): the /api/agent/subscriptions routes when given with the directory. */
|
|
155
|
+
readonly subscriptions?: RouteSubscriptions
|
|
128
156
|
/** Profiles (HWA-14): the /api/agent/profiles and /sessions/:addr/profile routes when given with the directory. */
|
|
129
157
|
readonly profiles?: ProfileStore
|
|
130
158
|
/** Workspaces (HWA-06): the /api/agent/workspaces routes when given. */
|
|
@@ -181,9 +209,13 @@ export interface AgentDeps {
|
|
|
181
209
|
readonly timeZone?: (userID: string) => Promise<string | undefined>
|
|
182
210
|
/** (CON-13) The board's inGrant (board-routes.ts BoardRouteDeps.coverage). */
|
|
183
211
|
readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
|
|
212
|
+
/** (Concierge grant requests) The board's project-level inGrant (board-routes.ts projectGrant). */
|
|
213
|
+
readonly projectGrant?: (userID: string, conciergeSessionID: string, directory: string) => Promise<readonly string[]>
|
|
184
214
|
}
|
|
185
215
|
/** (CON-02) Session intents: GET/PUT /api/agent/sessions/:addr/intent with the directory; reads carry `intent`. */
|
|
186
216
|
readonly intents?: SessionIntents
|
|
217
|
+
/** Context cards (decision 9604b0a1): GET /api/agent/sessions/:addr/context-cards. */
|
|
218
|
+
readonly contextCards?: Pick<ContextCardService, "list" | "registry">
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
/**
|
|
@@ -191,7 +223,7 @@ export interface AgentDeps {
|
|
|
191
223
|
* order after the device lookup; see BearerVerifier.
|
|
192
224
|
*/
|
|
193
225
|
export function agentVerifiers(deps: AgentDeps): BearerVerifier[] {
|
|
194
|
-
return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store)]
|
|
226
|
+
return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store), ...(deps.connect ? [deps.connect.verifier] : [])]
|
|
195
227
|
}
|
|
196
228
|
|
|
197
229
|
const userView = (user: UserRecord) => ({
|
|
@@ -204,27 +236,34 @@ const userView = (user: UserRecord) => ({
|
|
|
204
236
|
/** The built-in /api/agent routes. */
|
|
205
237
|
export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
206
238
|
const feed = deps.feed ?? new MessageFeed()
|
|
239
|
+
const appDecisions = appDecisionParts(deps, feed)
|
|
207
240
|
const routes: AgentRoute[] = [
|
|
208
241
|
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
209
242
|
...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
243
|
+
...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
210
244
|
...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
|
|
211
|
-
...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.messageState ? { state: deps.messageState } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
|
|
212
|
-
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
245
|
+
...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(appDecisions ? { answerable: appDecisions.answerable } : {}), ...(deps.messageState ? { state: deps.messageState } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
|
|
246
|
+
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps), ...appSessionReads(deps) }) : []),
|
|
213
247
|
{
|
|
214
248
|
method: "GET",
|
|
215
249
|
path: `${AGENT_PREFIX}/whoami`,
|
|
216
250
|
allow: { session: true, app: true },
|
|
217
251
|
handle: ({ actor, user }) => Response.json({ actor, user: userView(user) }, { headers: { "cache-control": "no-store" } }),
|
|
218
252
|
},
|
|
219
|
-
...appTokenRoutes(deps.store),
|
|
253
|
+
...appTokenRoutes(deps.store, deps.conversation ? { revoked: (userID) => deps.conversation!.streams.pulse(userID) } : {}),
|
|
254
|
+
...(deps.connect ? connectRoutes(deps.connect) : []),
|
|
255
|
+
...(deps.speech ? speechRoutes(deps.speech) : []),
|
|
220
256
|
...(deps.audit ? auditRoutes(deps.audit) : []),
|
|
221
257
|
...(deps.grants ? grantRoutes(deps.grants, deps.now, deps.grantRequests) : []),
|
|
222
258
|
...(deps.acting ? actingSwitchRoutes(deps.acting) : []),
|
|
223
259
|
...actVerbRoutes(deps),
|
|
260
|
+
...conversationVerbRoutes(deps),
|
|
261
|
+
...(appDecisions?.routes ?? []),
|
|
224
262
|
...(deps.launches && deps.grants && deps.acting && deps.audit
|
|
225
263
|
? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
|
|
226
264
|
: []),
|
|
227
265
|
...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
|
|
266
|
+
...(deps.subscriptions && deps.directory ? subscriptionRoutes({ subscriptions: deps.subscriptions, directory: deps.directory, store: deps.store, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
228
267
|
...(deps.profiles && deps.directory
|
|
229
268
|
? profileRoutes({
|
|
230
269
|
profiles: deps.profiles,
|
|
@@ -348,6 +387,65 @@ function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs:
|
|
|
348
387
|
return intents ? { intents: (userID: string, sessionIDs: readonly string[]) => intents.many(userID, sessionIDs) } : {}
|
|
349
388
|
}
|
|
350
389
|
|
|
390
|
+
/** (AF3) "The app's sessions" (contract §1.2), when grants, registry and directory are given. */
|
|
391
|
+
function appSessionDeps(deps: AgentDeps): AppSessionDeps | undefined {
|
|
392
|
+
const { grants, registry, directory, launches } = deps
|
|
393
|
+
if (!grants || !registry || !directory) return undefined
|
|
394
|
+
return {
|
|
395
|
+
registry,
|
|
396
|
+
directory,
|
|
397
|
+
grants,
|
|
398
|
+
...(launches ? { launches: { list: (userID: string) => launches.list(userID) } } : {}),
|
|
399
|
+
...(deps.concierge?.protects ? { protects: deps.concierge.protects } : {}),
|
|
400
|
+
...(deps.conversation ? { deletedHere: (sessionID: string) => deps.conversation!.streams.wasDeleted(sessionID) } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** (AF4) Decisions from apps (app-decisions.ts), when Decisions, grants, acting, audit, registry and directory are given. */
|
|
405
|
+
function appDecisionParts(deps: AgentDeps, feed: MessageFeed): ReturnType<typeof appDecisionRoutes> | undefined {
|
|
406
|
+
const owned = appSessionDeps(deps)
|
|
407
|
+
const { decisions, acting, audit } = deps
|
|
408
|
+
if (!owned || !decisions || !acting || !audit || !deps.delivery) return undefined
|
|
409
|
+
return appDecisionRoutes({ ...owned, decisions, acting, audit, store: deps.store, publish: (userID, message) => feed.publish(userID, message) })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** (AF3) The prompt and log widened to conversation:write/read: the app's own sessions only. */
|
|
413
|
+
function appSessionReads(deps: AgentDeps): { appSession?: (actor: AgentActor, userID: string, addr: string) => Promise<unknown> } {
|
|
414
|
+
const owned = appSessionDeps(deps)
|
|
415
|
+
return owned ? { appSession: (actor, userID, addr) => appSessionCheck(owned, actor, userID, addr) } : {}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** (AF3) The conversation routes, when every part they need is given. */
|
|
419
|
+
function conversationVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
420
|
+
const owned = appSessionDeps(deps)
|
|
421
|
+
const { conversation, grants, acting, audit } = deps
|
|
422
|
+
if (!conversation || !owned || !grants || !acting || !audit) return []
|
|
423
|
+
const verifiers = agentVerifiers(deps)
|
|
424
|
+
return conversationRoutes({
|
|
425
|
+
...owned,
|
|
426
|
+
grants,
|
|
427
|
+
acting,
|
|
428
|
+
audit,
|
|
429
|
+
store: deps.store,
|
|
430
|
+
ports: conversation.ports,
|
|
431
|
+
source: conversation.source,
|
|
432
|
+
streams: conversation.streams,
|
|
433
|
+
cookieName: conversation.cookieName,
|
|
434
|
+
verify: async (token) => {
|
|
435
|
+
for (const verifier of verifiers) {
|
|
436
|
+
const result = await verifier.verify(token)
|
|
437
|
+
if (result !== undefined) return result
|
|
438
|
+
}
|
|
439
|
+
return undefined
|
|
440
|
+
},
|
|
441
|
+
...(deps.draining ? { draining: deps.draining } : {}),
|
|
442
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
443
|
+
...(conversation.heartbeatMs !== undefined ? { heartbeatMs: conversation.heartbeatMs } : {}),
|
|
444
|
+
...(conversation.recheckMs !== undefined ? { recheckMs: conversation.recheckMs } : {}),
|
|
445
|
+
...(conversation.bufferMaxBytes !== undefined ? { bufferMaxBytes: conversation.bufferMaxBytes } : {}),
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
351
449
|
/** The act verbs, when every part they need is given. */
|
|
352
450
|
function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
353
451
|
const { actions, acting, grants, audit, registry, directory, spokes } = deps
|
|
@@ -366,6 +464,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
366
464
|
...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
|
|
367
465
|
...currentConciergeActor(deps),
|
|
368
466
|
}),
|
|
467
|
+
appSessionReads(deps),
|
|
369
468
|
)
|
|
370
469
|
}
|
|
371
470
|
|
|
@@ -442,7 +541,10 @@ export function agentApi(options: AgentApiOptions): AgentApi {
|
|
|
442
541
|
const { actor, credential, user, device } = resolved
|
|
443
542
|
return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
|
|
444
543
|
} catch (cause) {
|
|
445
|
-
|
|
544
|
+
const failed = agentFailure(cause)
|
|
545
|
+
// (AF1) A refused connected-app access token says so the OAuth way (RFC 6750 §3.1).
|
|
546
|
+
if (failed.status === 401 && /^\s*Bearer\s+hca_/i.test(request.headers.get("authorization") ?? "")) failed.headers.set("www-authenticate", 'Bearer error="invalid_token"')
|
|
547
|
+
return failed
|
|
446
548
|
}
|
|
447
549
|
}
|
|
448
550
|
|
|
@@ -463,7 +565,7 @@ export function wellKnown(info: WellKnownInfo): Response {
|
|
|
463
565
|
// Pairing codes also work on an OIDC hub (a signed-in device issues them).
|
|
464
566
|
const auth = info.auth.mode === "oidc" ? { modes: ["oidc", "pairing"], oidc: { issuer: info.auth.issuer } } : { modes: ["pairing"] }
|
|
465
567
|
return Response.json(
|
|
466
|
-
{ publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: [] },
|
|
568
|
+
{ publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: APP_FRONT_FEATURES.filter((flag) => APP_FRONT_SHIPPED[flag]) },
|
|
467
569
|
{ headers: { "cache-control": "public, max-age=60" } },
|
|
468
570
|
)
|
|
469
571
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Speech for apps (AF2, docs/plans/2026-11-08-app-front-contracts.md §4): POST /api/agent/speech/transcribe (raw
|
|
2
|
+
// audio → {text}) and POST /api/agent/speech/speak ({text} → audio/mpeg), for app credentials holding `speech:use`
|
|
3
|
+
// (hand-made hat_ tokens and connected apps) only: the user's devices get 403 and use the console's /api/transcribe
|
|
4
|
+
// and /api/speak. The hub speaks with THAT user's speech settings and key (http.ts speechFor(user.id)); apps never
|
|
5
|
+
// read or change speech settings, models or keys (those routes stay console-only, behind the console gate).
|
|
6
|
+
//
|
|
7
|
+
// - Limits: the console's maxAudioBytes (HUB_MAX_AUDIO_BYTES) and maxSpeechChars (HUB_MAX_SPEECH_CHARS, after
|
|
8
|
+
// control characters are stripped and speakable(); longer text is refused, not cut), plus per-credential quotas
|
|
9
|
+
// (SPEECH_RATE_LIMITS, overridden by hub.json agents.speechQuotas): fixed windows aligned to the clock (a minute,
|
|
10
|
+
// an hour) in the shared Counters, so every pod of a cluster counts together and retryAfter is exact. Fixed
|
|
11
|
+
// windows allow up to twice a limit across a window edge (the end of one minute and the start of the next).
|
|
12
|
+
// Admission is atomic per window: increment first, check the returned count, undo this call's increments when
|
|
13
|
+
// any window is over (SpeechQuota.admit). A request that is refused for its input is not counted; one that
|
|
14
|
+
// reaches the provider is (whatever the provider answers).
|
|
15
|
+
// - Errors: 400 invalid (unknown audio type, empty or oversized audio, nothing or too much to speak, a bad body),
|
|
16
|
+
// 403 forbidden (no speech:use; not an app credential), 429 rate_limited {retryAfter, limit} + Retry-After,
|
|
17
|
+
// 422 unsupported {feature: "speech", kind} when the user has no provider for the kind, 502 upstream_failed
|
|
18
|
+
// {providerStatus?} when the provider fails (a generic message; the provider's text is only logged).
|
|
19
|
+
// - Recording (REC-01) is off for app actors: these handlers never call the recording taps, so x-hub-recording*
|
|
20
|
+
// headers are ignored and neither audio nor text is kept.
|
|
21
|
+
// - Audit per credential, never content: every request is counted per kind, outcome and clock hour; the user's agent
|
|
22
|
+
// audit lists at most one speech.transcribe / speech.speak entry per credential, kind and outcome every
|
|
23
|
+
// AUDIT_SLOT_MS (15 min) with the hour's count so far ("12 this hour so far"), the hour's first one also with the
|
|
24
|
+
// previous hour's total, so use and abuse are visible without speech flooding the audit ring.
|
|
25
|
+
import { SPEECH_PATHS, SPEECH_RATE_LIMITS } from "../../protocol"
|
|
26
|
+
import type { Clock, Counters } from "../backend/types"
|
|
27
|
+
import { audioFormat, SpeechError } from "../speech/provider"
|
|
28
|
+
import { speakable } from "../../speakable"
|
|
29
|
+
import type { AgentActor } from "./actor"
|
|
30
|
+
import type { AuditDecision, AuditInput, AuditOutcome } from "./audit"
|
|
31
|
+
import { bodyError, readBounded } from "./body"
|
|
32
|
+
import { partyFromActor } from "./envelope"
|
|
33
|
+
import { AgentError } from "./errors"
|
|
34
|
+
import type { AgentContext, AgentRoute } from "./routes"
|
|
35
|
+
|
|
36
|
+
export type SpeechQuotaKind = keyof typeof SPEECH_RATE_LIMITS
|
|
37
|
+
|
|
38
|
+
/** Per-credential speech quotas (the contract's SPEECH_RATE_LIMITS shape). */
|
|
39
|
+
export interface SpeechQuotas {
|
|
40
|
+
readonly transcribe: { readonly perMinute: number; readonly perHour: number }
|
|
41
|
+
readonly speak: { readonly perMinute: number; readonly perHour: number; readonly charsPerHour: number }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** hub.json `agents.speechQuotas`: any subset of SpeechQuotas (positive whole numbers). */
|
|
45
|
+
export interface SpeechQuotaOverrides {
|
|
46
|
+
readonly transcribe?: Partial<SpeechQuotas["transcribe"]>
|
|
47
|
+
readonly speak?: Partial<SpeechQuotas["speak"]>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The defaults with hub.json's overrides on top. */
|
|
51
|
+
export function speechQuotas(overrides: SpeechQuotaOverrides = {}): SpeechQuotas {
|
|
52
|
+
return {
|
|
53
|
+
transcribe: { ...SPEECH_RATE_LIMITS.transcribe, ...overrides.transcribe },
|
|
54
|
+
speak: { ...SPEECH_RATE_LIMITS.speak, ...overrides.speak },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MINUTE_MS = 60_000
|
|
59
|
+
const HOUR_MS = 60 * MINUTE_MS
|
|
60
|
+
/** At most one audit entry per credential, kind and outcome this often (it carries the hour's count so far). */
|
|
61
|
+
export const AUDIT_SLOT_MS = 15 * MINUTE_MS
|
|
62
|
+
/** C0/C1 control characters (tabs and line breaks aside, which speakable() folds): never sent to the provider. */
|
|
63
|
+
const CONTROLS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g
|
|
64
|
+
/** A speak body is {text}: the text is at most maxSpeechChars after speakable(), so 64 KiB is plenty. */
|
|
65
|
+
export const SPEAK_BODY_MAX = 64 * 1024
|
|
66
|
+
|
|
67
|
+
interface Window {
|
|
68
|
+
readonly index: number
|
|
69
|
+
readonly remaining: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const windowOf = (t: number, size: number): Window => {
|
|
73
|
+
const index = Math.floor(t / size)
|
|
74
|
+
return { index, remaining: (index + 1) * size - t }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Fixed per-credential windows in the shared Counters (cluster-wide). */
|
|
78
|
+
export class SpeechQuota {
|
|
79
|
+
private readonly now: Clock
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly counters: Counters,
|
|
83
|
+
private readonly quotas: SpeechQuotas,
|
|
84
|
+
now?: Clock,
|
|
85
|
+
) {
|
|
86
|
+
this.now = now ?? Date.now
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private checks(credential: string, kind: SpeechQuotaKind, chars: number) {
|
|
90
|
+
const t = this.now()
|
|
91
|
+
const minute = windowOf(t, MINUTE_MS)
|
|
92
|
+
const hour = windowOf(t, HOUR_MS)
|
|
93
|
+
const quota = this.quotas[kind]
|
|
94
|
+
const key = (name: string, window: Window) => `speech:${kind}:${name}:${credential}:${window.index}`
|
|
95
|
+
const checks: { name: string; key: string; window: Window; max: number; by: number }[] = [
|
|
96
|
+
{ name: "perMinute", key: key("m", minute), window: minute, max: quota.perMinute, by: 1 },
|
|
97
|
+
{ name: "perHour", key: key("h", hour), window: hour, max: quota.perHour, by: 1 },
|
|
98
|
+
]
|
|
99
|
+
if (kind === "speak") checks.push({ name: "charsPerHour", key: key("c", hour), window: hour, max: this.quotas.speak.charsPerHour, by: chars })
|
|
100
|
+
return checks
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private refused(kind: SpeechQuotaKind, check: { name: string; window: Window; max: number }): AgentError {
|
|
104
|
+
return new AgentError("rate_limited", `Too many speech requests (${kind} ${check.name} limit: ${check.max})`, {
|
|
105
|
+
hint: "Wait and try again",
|
|
106
|
+
extra: { retryAfter: Math.max(1, Math.ceil(check.window.remaining / 1000)), limit: check.name },
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* An early, non-counting refusal before the body is read (a full request window). Advisory only: `admit` decides.
|
|
112
|
+
*/
|
|
113
|
+
async peek(credential: string, kind: SpeechQuotaKind): Promise<void> {
|
|
114
|
+
for (const check of this.checks(credential, kind, 0)) {
|
|
115
|
+
if (check.by > 0 && (await this.counters.get(check.key)) >= check.max) throw this.refused(kind, check)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Counts one request (and `chars` characters, speak) of the credential, atomically per window: each window is
|
|
121
|
+
* incremented first and its returned value checked, so parallel requests (on any pod) can never pass a limit. When
|
|
122
|
+
* one window is over, every increment this call made is undone and it throws rate_limited. While an undo is in
|
|
123
|
+
* flight a concurrent request may be refused that would have fit: refusals err on the safe side, admissions never.
|
|
124
|
+
*/
|
|
125
|
+
async admit(credential: string, kind: SpeechQuotaKind, chars = 0): Promise<void> {
|
|
126
|
+
const done: { key: string; window: Window; by: number }[] = []
|
|
127
|
+
for (const check of this.checks(credential, kind, chars)) {
|
|
128
|
+
if (check.by <= 0) continue
|
|
129
|
+
const value = await this.counters.incr(check.key, check.window.remaining, check.by)
|
|
130
|
+
done.push(check)
|
|
131
|
+
if (value > check.max) {
|
|
132
|
+
await this.undo(done)
|
|
133
|
+
throw this.refused(kind, check)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async undo(done: readonly { key: string; window: Window; by: number }[]): Promise<void> {
|
|
139
|
+
for (const step of done) {
|
|
140
|
+
try {
|
|
141
|
+
await this.counters.incr(step.key, Math.max(1, step.window.remaining), -step.by)
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
// The window keeps the extra count until it ends: stricter, never looser.
|
|
144
|
+
console.error(`[hub] speech quota undo failed: ${(cause as Error).message}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the speech routes need from the hub (http.ts): the user's own speech. */
|
|
151
|
+
export interface AppSpeech {
|
|
152
|
+
/** Whether the user has a provider for each kind (an OpenRouter key, or an openai-compatible base URL). */
|
|
153
|
+
ready(userID: string): { readonly stt: boolean; readonly tts: boolean }
|
|
154
|
+
transcribe(userID: string, audio: Uint8Array, format: string): Promise<string>
|
|
155
|
+
/** The audio, streamed when the provider streams. */
|
|
156
|
+
speak(userID: string, text: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SpeechRouteDeps {
|
|
160
|
+
readonly speech: AppSpeech
|
|
161
|
+
readonly counters: Counters
|
|
162
|
+
readonly maxAudioBytes: number
|
|
163
|
+
readonly maxSpeechChars: number
|
|
164
|
+
readonly quotas?: SpeechQuotaOverrides
|
|
165
|
+
readonly audit?: { record(userID: string, input: AuditInput): Promise<unknown> }
|
|
166
|
+
readonly now?: Clock
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Runs checks made before the body is read; a refusal then says Connection: close when a body was sent (body.ts). */
|
|
170
|
+
async function unread(request: Request, checks: () => Promise<void>): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
await checks()
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
if (!(cause instanceof AgentError) || request.body === null) throw cause
|
|
175
|
+
throw new AgentError(cause.code, cause.message, { ...cause.options, close: true })
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The credential a quota counts against: the app's hand-made token or connection (appOnly ran before). */
|
|
180
|
+
const credentialOf = (actor: AgentActor): string => (actor.kind === "app" ? `app:${actor.tokenID}` : `${actor.kind}:${actor.userID}`)
|
|
181
|
+
|
|
182
|
+
export function speechRoutes(deps: SpeechRouteDeps): AgentRoute[] {
|
|
183
|
+
const now = deps.now ?? Date.now
|
|
184
|
+
const quota = new SpeechQuota(deps.counters, speechQuotas(deps.quotas), now)
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Counts every request per kind and outcome and clock hour; lists at most one entry per AUDIT_SLOT_MS with the
|
|
188
|
+
* hour's count so far, the hour's first one also with the previous hour's total (see the header).
|
|
189
|
+
*/
|
|
190
|
+
const audit = async (context: AgentContext, verb: "speech.transcribe" | "speech.speak", decision: AuditDecision, outcome: AuditOutcome) => {
|
|
191
|
+
const { actor, user } = context
|
|
192
|
+
if (!deps.audit || actor.kind !== "app") return
|
|
193
|
+
const t = now()
|
|
194
|
+
const hour = windowOf(t, HOUR_MS)
|
|
195
|
+
const slot = windowOf(t, AUDIT_SLOT_MS)
|
|
196
|
+
const base = `speech:audit:${verb}:${decision}:${outcome}:${credentialOf(actor)}`
|
|
197
|
+
try {
|
|
198
|
+
// Kept one hour longer, so the next hour's first entry can read it.
|
|
199
|
+
const count = await deps.counters.incr(`${base}:n:${hour.index}`, hour.remaining + HOUR_MS)
|
|
200
|
+
if ((await deps.counters.incr(`${base}:s:${slot.index}`, slot.remaining)) !== 1) return
|
|
201
|
+
const firstOfHour = (await deps.counters.incr(`${base}:f:${hour.index}`, hour.remaining)) === 1
|
|
202
|
+
const previous = firstOfHour ? await deps.counters.get(`${base}:n:${hour.index - 1}`) : 0
|
|
203
|
+
const reason = `${count} this hour so far${previous > 0 ? `; previous hour: ${previous}` : ""}`
|
|
204
|
+
await deps.audit.record(user.id, { actor: partyFromActor(actor), verb, target: {}, decision, outcome, reason })
|
|
205
|
+
} catch (cause) {
|
|
206
|
+
console.error(`[hub] speech audit failed: ${(cause as Error).message}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Runs `work`, auditing its rate-limit refusal, provider failure or success; input refusals are not audited. */
|
|
211
|
+
const audited = async (context: AgentContext, verb: "speech.transcribe" | "speech.speak", work: () => Promise<Response>): Promise<Response> => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await work()
|
|
214
|
+
await audit(context, verb, "allow", "ok")
|
|
215
|
+
return response
|
|
216
|
+
} catch (cause) {
|
|
217
|
+
if (cause instanceof AgentError && cause.code === "rate_limited") await audit(context, verb, "rate_limited", "refused")
|
|
218
|
+
else if (cause instanceof AgentError && cause.code === "upstream_failed") await audit(context, verb, "allow", "failed")
|
|
219
|
+
else if (cause instanceof AgentError && cause.code === "unsupported") await audit(context, verb, "allow", "unsupported")
|
|
220
|
+
throw cause
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const unsupported = (kind: "stt" | "tts") =>
|
|
225
|
+
new AgentError("unsupported", kind === "stt" ? "No speech-to-text provider is set up for this user" : "No text-to-speech provider is set up for this user", {
|
|
226
|
+
hint: "The user sets one up in Settings → Speech",
|
|
227
|
+
extra: { feature: "speech", kind },
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A provider failure as 502 upstream_failed: a generic message and the provider's HTTP status only. The provider's
|
|
232
|
+
* own text (its name, model, masked key fragments) is logged on the hub, never sent to the app.
|
|
233
|
+
*/
|
|
234
|
+
const upstream = (cause: unknown, verb: string): AgentError => {
|
|
235
|
+
if (cause instanceof AgentError) return cause
|
|
236
|
+
console.error(`[hub] app speech provider failed (${verb}): ${(cause as Error)?.message ?? cause}`)
|
|
237
|
+
const status = cause instanceof SpeechError && typeof cause.status === "number" && Number.isInteger(cause.status) ? cause.status : undefined
|
|
238
|
+
return new AgentError("upstream_failed", `The speech provider failed${status !== undefined ? ` (HTTP ${status})` : ""}`, {
|
|
239
|
+
hint: "Try again later",
|
|
240
|
+
...(status !== undefined ? { extra: { providerStatus: status } } : {}),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** (O3) Apps only: the user's devices have the console's own routes. */
|
|
245
|
+
const appOnly = (actor: AgentActor, consoleRoute: string): void => {
|
|
246
|
+
if (actor.kind !== "app") throw new AgentError("forbidden", "These routes are for app credentials (hat_ or a connected app)", { hint: `The console uses POST ${consoleRoute}` })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
method: "POST",
|
|
252
|
+
path: SPEECH_PATHS.transcribe,
|
|
253
|
+
allow: { scope: "speech:use" },
|
|
254
|
+
handle: (context) =>
|
|
255
|
+
audited(context, "speech.transcribe", async () => {
|
|
256
|
+
const { request, actor, user } = context
|
|
257
|
+
appOnly(actor, "/api/transcribe")
|
|
258
|
+
const credential = credentialOf(actor)
|
|
259
|
+
// Refused before the audio is read: the cheap checks first, answered with Connection: close (body.ts).
|
|
260
|
+
await unread(request, async () => {
|
|
261
|
+
await quota.peek(credential, "transcribe")
|
|
262
|
+
const type = request.headers.get("content-type")
|
|
263
|
+
if (!audioFormat(type)) throw new AgentError("invalid", `Unsupported audio type: ${(type ?? "none").slice(0, 100)}`, { hint: "Send webm, mp4/m4a, aac, ogg, wav, mpeg or flac" })
|
|
264
|
+
if (!deps.speech.ready(user.id).stt) throw unsupported("stt")
|
|
265
|
+
})
|
|
266
|
+
const format = audioFormat(request.headers.get("content-type"))!
|
|
267
|
+
const read = await readBounded(request, deps.maxAudioBytes)
|
|
268
|
+
if (!read.ok) throw bodyError(read, deps.maxAudioBytes)
|
|
269
|
+
if (read.bytes.length === 0) throw new AgentError("invalid", "Empty recording", { extra: { limit: deps.maxAudioBytes } })
|
|
270
|
+
await quota.admit(credential, "transcribe")
|
|
271
|
+
let text: string
|
|
272
|
+
try {
|
|
273
|
+
text = await deps.speech.transcribe(user.id, read.bytes, format)
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
throw upstream(cause, "transcribe")
|
|
276
|
+
}
|
|
277
|
+
return Response.json({ text }, { headers: { "cache-control": "no-store" } })
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
method: "POST",
|
|
282
|
+
path: SPEECH_PATHS.speak,
|
|
283
|
+
allow: { scope: "speech:use" },
|
|
284
|
+
handle: (context) =>
|
|
285
|
+
audited(context, "speech.speak", async () => {
|
|
286
|
+
const { request, actor, user } = context
|
|
287
|
+
appOnly(actor, "/api/speak")
|
|
288
|
+
const credential = credentialOf(actor)
|
|
289
|
+
await unread(request, () => quota.peek(credential, "speak"))
|
|
290
|
+
const read = await readBounded(request, SPEAK_BODY_MAX)
|
|
291
|
+
if (!read.ok) throw bodyError(read, SPEAK_BODY_MAX)
|
|
292
|
+
let input: unknown
|
|
293
|
+
try {
|
|
294
|
+
input = JSON.parse(Buffer.from(read.bytes).toString("utf8"))
|
|
295
|
+
} catch {
|
|
296
|
+
throw new AgentError("invalid", "Invalid JSON")
|
|
297
|
+
}
|
|
298
|
+
if (typeof input !== "object" || input === null || Array.isArray(input) || typeof (input as { text?: unknown }).text !== "string") {
|
|
299
|
+
throw new AgentError("invalid", "Body must be {text: string}")
|
|
300
|
+
}
|
|
301
|
+
const text = speakable((input as { text: string }).text.replace(CONTROLS, ""))
|
|
302
|
+
if (!text) throw new AgentError("invalid", "Nothing to speak", { extra: { limit: deps.maxSpeechChars } })
|
|
303
|
+
if (text.length > deps.maxSpeechChars) throw new AgentError("invalid", `The text is longer than ${deps.maxSpeechChars} characters (after speakable())`, { extra: { limit: deps.maxSpeechChars } })
|
|
304
|
+
if (!deps.speech.ready(user.id).tts) throw unsupported("tts")
|
|
305
|
+
await quota.admit(credential, "speak", text.length)
|
|
306
|
+
let audio: ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>
|
|
307
|
+
try {
|
|
308
|
+
audio = await deps.speech.speak(user.id, text, request.signal)
|
|
309
|
+
} catch (cause) {
|
|
310
|
+
throw upstream(cause, "speak")
|
|
311
|
+
}
|
|
312
|
+
return new Response(audio, { headers: { "content-type": "audio/mpeg", "cache-control": "no-store" } })
|
|
313
|
+
}),
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
}
|