@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -368,7 +368,8 @@ export interface Decision {
368
368
  readonly at: number
369
369
  readonly expiresAt: number
370
370
  readonly escalatedAt?: number
371
- readonly answer?: { readonly option?: string; readonly text?: string; readonly answeredBy: "user" | "timeout"; readonly at: number; readonly deviceID?: string }
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.9.2",
3
+ "version": "0.10.1",
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") this.origins.set(data.inboxID, "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), which is about events, not an op.
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. Answers 200 with the engine's result, never cached. Errors: invalid 400, needs_user /
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: ACT,
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))