@huynle/hub 0.6.1 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,128 @@
1
+ // Speak while it writes: cuts a root session's streamed assistant text into sentences as soon as each one is
2
+ // complete, so the phone can synthesize and play the first sentence while the rest is still being written.
3
+ // Sentences are raw slices of the text (Markdown and code kept): /api/speak runs speakable() on each, and the
4
+ // slices joined make up the streamed text, so the phone can merge them with the bubble streamed from deltas.
5
+
6
+ /** Tokens ending in "." that rarely end a sentence (compared lowercase). */
7
+ const ABBREVIATIONS = new Set(["e.g.", "i.e.", "mr.", "mrs.", "ms.", "dr.", "st.", "vs.", "cf.", "prof.", "jr.", "sr.", "approx.", "no.", "fig.", "etc."])
8
+ /** A line break, or sentence-ending punctuation with closing quotes/brackets. */
9
+ const BOUNDARY = /\n|[.?!…]+["'”’)\]]*/g
10
+ const CLOSING_FENCE = /\n[ \t]*```/g
11
+ const WORDY = /[\p{L}\p{N}]/u
12
+
13
+ export interface SentenceStreamOptions {
14
+ /** A run this long without a sentence end is cut at a comma or space. */
15
+ readonly maxChars?: number
16
+ }
17
+
18
+ export class SentenceStream {
19
+ private buf = ""
20
+ /** Everything before this offset was emitted. */
21
+ private pos = 0
22
+ private maxChars: number
23
+
24
+ constructor({ maxChars = 300 }: SentenceStreamOptions = {}) {
25
+ this.maxChars = maxChars
26
+ }
27
+
28
+ /** Appends streamed text; returns the sentences it completed. */
29
+ push(delta: string): string[] {
30
+ this.buf += String(delta ?? "")
31
+ return this.drain(false)
32
+ }
33
+
34
+ /**
35
+ * The text ended: returns the remaining sentences. `full` (the final text) is authoritative: missed deltas
36
+ * are recovered, and when only a later part was streamed, speech continues after what was already emitted.
37
+ */
38
+ end(full?: string): string[] {
39
+ if (typeof full === "string") this.adopt(full)
40
+ return this.drain(true)
41
+ }
42
+
43
+ private adopt(full: string): void {
44
+ const done = this.buf.slice(0, this.pos)
45
+ if (full.startsWith(done)) {
46
+ this.buf = full
47
+ return
48
+ }
49
+ const spoken = done.trim()
50
+ const at = full.indexOf(spoken)
51
+ if (at >= 0) {
52
+ this.buf = full
53
+ this.pos = at + spoken.length
54
+ }
55
+ // Otherwise the texts diverged: only what was streamed is spoken.
56
+ }
57
+
58
+ private drain(final: boolean): string[] {
59
+ const out: string[] = []
60
+ for (;;) {
61
+ while (this.pos < this.buf.length && /\s/.test(this.buf[this.pos]!)) this.pos++
62
+ if (this.pos >= this.buf.length) break
63
+ const end = this.next(this.pos, final)
64
+ if (end === undefined) break
65
+ const sentence = this.buf.slice(this.pos, end).trim()
66
+ this.pos = end
67
+ if (WORDY.test(sentence)) out.push(sentence)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** End offset of the next piece starting at `start`, or undefined to wait for more text. */
73
+ private next(start: number, final: boolean): number | undefined {
74
+ const buf = this.buf
75
+ if (buf.startsWith("```", start)) {
76
+ CLOSING_FENCE.lastIndex = start + 3
77
+ const close = CLOSING_FENCE.exec(buf)
78
+ if (close) return close.index + close[0].length
79
+ return final ? buf.length : undefined
80
+ }
81
+ BOUNDARY.lastIndex = start
82
+ for (let match = BOUNDARY.exec(buf); match; match = BOUNDARY.exec(buf)) {
83
+ const at = match.index
84
+ const after = at + match[0].length
85
+ let end: number | undefined
86
+ if (match[0] === "\n") end = at
87
+ else {
88
+ const verdict = this.sentenceEnd(start, at, after, final)
89
+ if (verdict === "wait") return this.cutLong(start, final)
90
+ if (verdict) end = after
91
+ }
92
+ if (end === undefined) continue
93
+ // Pieces without words ("---", "**") join the next sentence, unless a code block comes next (then
94
+ // they are dropped: drain skips pieces without words).
95
+ if (WORDY.test(buf.slice(start, end)) || /^\n[ \t]*```/.test(buf.slice(at))) return end
96
+ }
97
+ return this.cutLong(start, final)
98
+ }
99
+
100
+ /** Whether punctuation at `at`..`after` ends a sentence; "wait" when the text after it has not arrived. */
101
+ private sentenceEnd(start: number, at: number, after: number, final: boolean): boolean | "wait" {
102
+ const buf = this.buf
103
+ if (after >= buf.length) return final ? true : "wait"
104
+ if (!/\s/.test(buf[after]!)) return false // 3.5, config.ts, v1.2
105
+ const rest = /^[ \t]*([\s\S])?/.exec(buf.slice(after))!
106
+ const next = rest[1]
107
+ if (next === undefined) return final ? true : "wait"
108
+ if (next === "\n" || /\s/.test(next)) return true
109
+ if (/[?!]/.test(buf.slice(at, after))) return true
110
+ const lineStart = Math.max(start, buf.lastIndexOf("\n", at) + 1)
111
+ const tokenStart = Math.max(lineStart, buf.lastIndexOf(" ", at) + 1)
112
+ const token = buf.slice(tokenStart, after).toLowerCase()
113
+ if (ABBREVIATIONS.has(token.replace(/["'”’)\]]+$/, ""))) return false
114
+ if (tokenStart === lineStart && /^\d+[.)]$/.test(token)) return false // "1." list marker
115
+ return !/\p{Ll}/u.test(next)
116
+ }
117
+
118
+ /** No sentence end yet: a run longer than maxChars is cut at a comma or space; at the end, all of it. */
119
+ private cutLong(start: number, final: boolean): number | undefined {
120
+ const buf = this.buf
121
+ if (final) return buf.length
122
+ if (buf.length - start <= this.maxChars) return undefined
123
+ const window = buf.slice(start, start + this.maxChars)
124
+ let at = Math.max(window.lastIndexOf(", "), window.lastIndexOf("; "))
125
+ at = at > this.maxChars / 2 ? at + 1 : window.lastIndexOf(" ")
126
+ return start + (at > 0 ? at : this.maxChars)
127
+ }
128
+ }
@@ -0,0 +1,233 @@
1
+ // Delivers hub-minted session tokens (./agent/session-tokens.ts) to the spokes that report the sessions, with
2
+ // the sessionTokens op (feature "sessionTokens"; older spokes get nothing).
3
+ //
4
+ // On every sessions frame of a connection, the hub sends tokens for the sessions it reports for its own tenant
5
+ // (subagents included) that have none yet on that connection, whose token has less than a third of its lifetime
6
+ // left, or whose machine stamp changed (user or machine epoch, active key). Unchanged tokens are not resent. A
7
+ // reconnect is a new connection, so it gets every token again (the spoke drops its tokens when its connection
8
+ // changes). Tokens the spoke skipped, and tokens of a failed send, are retried on the next frame or sweep. A
9
+ // periodic sweep refreshes tokens nearing expiry and forgets connections that are gone.
10
+ //
11
+ // Act tokens (typ "act", decision #1 of phase 3) follow the same rules in a lane of their own: the actTokens op,
12
+ // sent only to connections announcing "actTokens" (never inside a sessionTokens frame, which older spokes hand to
13
+ // shells), without a hub URL, deduped and retried separately from the shell tokens. Launch-bound connections (a
14
+ // spoke in a sandbox, phase 5 decision #5) get session tokens but never act tokens.
15
+ // Tokens are never logged.
16
+ import {
17
+ batchActTokens,
18
+ batchSessionTokens,
19
+ type ActTokensRequest,
20
+ type ResultFor,
21
+ type SessionToken,
22
+ type SessionTokensRequest,
23
+ type SpokeFeature,
24
+ } from "../protocol"
25
+ import type { Timers } from "./registry"
26
+
27
+ /** How often the sweep looks for tokens to refresh (well under a third of their lifetime). */
28
+ export const SESSION_TOKEN_SWEEP_MS = 10 * 60 * 1000
29
+ /** A token is refreshed when less than this fraction of its lifetime remains. */
30
+ export const SESSION_TOKEN_REFRESH_FRACTION = 1 / 3
31
+
32
+ /** What the push reads from the registry. */
33
+ export interface PushRegistry {
34
+ supports(connID: string, feature: SpokeFeature): boolean
35
+ connMachine(connID: string): string | undefined
36
+ /** Sessions the connection reports for its own tenant. */
37
+ reportedSessions(connID: string): string[]
38
+ /** The launch a connection is bound to (a sandboxed spoke, phase 5): such connections get no act tokens. */
39
+ launchOf?(connID: string): string | undefined
40
+ }
41
+
42
+ /** Mints tokens (SessionTokens). */
43
+ export interface TokenMinter {
44
+ readonly lifetimeMs: number
45
+ stamp(machineID: string): string | undefined
46
+ mint(machineID: string, sessionID: string): { token: string; exp: number; stamp: string } | undefined
47
+ /** An act token (typ "act"); without it no act tokens are sent. */
48
+ mintAct?(machineID: string, sessionID: string): { token: string; exp: number; stamp: string } | undefined
49
+ }
50
+
51
+ export interface SessionTokenPushOptions {
52
+ readonly registry: PushRegistry
53
+ readonly tokens: TokenMinter
54
+ /** Sends a sessionTokens request to a connection (SpokeHub.request). */
55
+ send(connID: string, req: SessionTokensRequest | ActTokensRequest): Promise<ResultFor["sessionTokens"]>
56
+ /** The URL sessions' shells reach the hub at. */
57
+ readonly hubURL: string
58
+ readonly now?: () => number
59
+ readonly timers?: Timers
60
+ readonly sweepMs?: number
61
+ /** Largest request JSON (default: what fits in a frame). */
62
+ readonly maxChars?: number
63
+ log?(message: string): void
64
+ }
65
+
66
+ /** What a connection was sent for a session. */
67
+ interface Sent {
68
+ readonly exp: number
69
+ readonly stamp: string
70
+ }
71
+
72
+ /** One kind of token: the feature a connection must announce, how to mint one and how to batch them. */
73
+ interface Lane {
74
+ readonly feature: SpokeFeature
75
+ /** Launch-bound connections are left out of this lane. */
76
+ readonly skipLaunchBound?: boolean
77
+ mint(machineID: string, sessionID: string): { token: string; exp: number; stamp: string } | undefined
78
+ batch(tokens: readonly SessionToken[]): Array<SessionTokensRequest | ActTokensRequest>
79
+ /** connID → sessionID → the token last sent (or being sent). */
80
+ readonly sent: Map<string, Map<string, Sent>>
81
+ }
82
+
83
+ const realTimers: Timers = {
84
+ set: (fn, ms) => {
85
+ const handle = setTimeout(fn, ms)
86
+ ;(handle as { unref?: () => void }).unref?.()
87
+ return handle
88
+ },
89
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
90
+ }
91
+
92
+ const reason = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
93
+
94
+ export class SessionTokenPush {
95
+ readonly #lanes: readonly Lane[]
96
+ readonly #inflight = new Set<Promise<unknown>>()
97
+ readonly #now: () => number
98
+ readonly #timers: Timers
99
+ #timer: unknown
100
+ #stopped = false
101
+
102
+ constructor(private readonly options: SessionTokenPushOptions) {
103
+ this.#now = options.now ?? Date.now
104
+ this.#timers = options.timers ?? realTimers
105
+ const { tokens, hubURL, maxChars } = options
106
+ const max = maxChars !== undefined ? { maxChars } : {}
107
+ const session: Lane = {
108
+ feature: "sessionTokens",
109
+ mint: (machineID, sessionID) => tokens.mint(machineID, sessionID),
110
+ batch: (batch) => batchSessionTokens(batch, { hubURL, ...max }),
111
+ sent: new Map(),
112
+ }
113
+ const mintAct = tokens.mintAct?.bind(tokens)
114
+ const act: Lane | undefined = mintAct && { feature: "actTokens", skipLaunchBound: true, mint: mintAct, batch: (batch) => batchActTokens(batch, max), sent: new Map() }
115
+ this.#lanes = act ? [session, act] : [session]
116
+ }
117
+
118
+ /** Handles a sessions frame of a connection: sends what is new or due. Resolves when the sends settle. */
119
+ sessions(connID: string): Promise<void> {
120
+ return this.#track(this.#sync(connID))
121
+ }
122
+
123
+ /** Refreshes tokens nearing expiry on every known connection; forgets connections that are gone. */
124
+ sweep(): Promise<void> {
125
+ return this.#track(this.#sweepAll())
126
+ }
127
+
128
+ /** Resolves when every send started so far has settled (tests). */
129
+ async idle(): Promise<void> {
130
+ while (this.#inflight.size > 0) await Promise.allSettled([...this.#inflight])
131
+ }
132
+
133
+ /** Arms the periodic sweep. */
134
+ start(): void {
135
+ this.#stopped = false
136
+ this.#arm()
137
+ }
138
+
139
+ stop(): void {
140
+ this.#stopped = true
141
+ if (this.#timer !== undefined) this.#timers.clear(this.#timer)
142
+ this.#timer = undefined
143
+ }
144
+
145
+ #arm(): void {
146
+ if (this.#stopped) return
147
+ this.#timer = this.#timers.set(() => {
148
+ void this.#track(this.#sweepAll().finally(() => this.#arm()))
149
+ }, this.options.sweepMs ?? SESSION_TOKEN_SWEEP_MS)
150
+ }
151
+
152
+ async #sweepAll(): Promise<void> {
153
+ const conns = new Set(this.#lanes.flatMap((lane) => [...lane.sent.keys()]))
154
+ await Promise.all([...conns].map((connID) => this.#track(this.#sync(connID))))
155
+ }
156
+
157
+ /** Tracks work for idle(); failures (e.g. an unreadable key file) are logged, never thrown. */
158
+ #track(work: Promise<void>): Promise<void> {
159
+ const safe = work.catch((cause) => this.options.log?.(`[hub] session tokens: ${reason(cause)}`))
160
+ this.#inflight.add(safe)
161
+ return safe.finally(() => this.#inflight.delete(safe))
162
+ }
163
+
164
+ async #sync(connID: string): Promise<void> {
165
+ await Promise.all(this.#lanes.map((lane) => this.#syncLane(lane, connID)))
166
+ }
167
+
168
+ async #syncLane(lane: Lane, connID: string): Promise<void> {
169
+ const { registry, tokens } = this.options
170
+ const machineID = registry.connMachine(connID)
171
+ const excluded = lane.skipLaunchBound === true && registry.launchOf?.(connID) !== undefined
172
+ if (machineID === undefined || excluded || !registry.supports(connID, lane.feature)) {
173
+ lane.sent.delete(connID)
174
+ return
175
+ }
176
+ const stamp = tokens.stamp(machineID)
177
+ if (stamp === undefined) {
178
+ lane.sent.delete(connID)
179
+ return
180
+ }
181
+ let sent = lane.sent.get(connID)
182
+ if (!sent) lane.sent.set(connID, (sent = new Map()))
183
+ const reported = new Set(registry.reportedSessions(connID))
184
+ for (const sessionID of sent.keys()) if (!reported.has(sessionID)) sent.delete(sessionID)
185
+
186
+ const now = this.#now()
187
+ const refreshAt = tokens.lifetimeMs * SESSION_TOKEN_REFRESH_FRACTION
188
+ const due: SessionToken[] = []
189
+ const records = new Map<string, Sent>()
190
+ for (const sessionID of reported) {
191
+ const last = sent.get(sessionID)
192
+ if (last && last.stamp === stamp && last.exp - now >= refreshAt) continue
193
+ const minted = lane.mint(machineID, sessionID)
194
+ if (!minted) continue
195
+ const entry = { sessionID, token: minted.token, exp: minted.exp }
196
+ if (!this.#fits(lane, entry)) {
197
+ this.options.log?.(`[hub] ${lane.feature === "actTokens" ? "act" : "session"} token for ${sessionID} does not fit in a frame; not sent`)
198
+ continue
199
+ }
200
+ const record = { exp: minted.exp, stamp: minted.stamp }
201
+ sent.set(sessionID, record)
202
+ records.set(sessionID, record)
203
+ due.push(entry)
204
+ }
205
+ if (due.length === 0) return
206
+
207
+ const forget = (sessionID: string) => {
208
+ if (records.has(sessionID) && sent.get(sessionID) === records.get(sessionID)) sent.delete(sessionID)
209
+ }
210
+ const requests = lane.batch(due)
211
+ await Promise.all(
212
+ requests.map(async (req) => {
213
+ try {
214
+ const result = await this.options.send(connID, req)
215
+ for (const { sessionID } of result.skipped) forget(sessionID)
216
+ } catch (cause) {
217
+ for (const { sessionID } of req.tokens) forget(sessionID)
218
+ this.options.log?.(`[hub] sending ${lane.feature === "actTokens" ? "act" : "session"} tokens failed: ${reason(cause)}`)
219
+ }
220
+ }),
221
+ )
222
+ }
223
+
224
+ /** Whether a token fits in a request on its own. */
225
+ #fits(lane: Lane, entry: SessionToken): boolean {
226
+ try {
227
+ lane.batch([entry])
228
+ return true
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+ }
@@ -1,7 +1,7 @@
1
1
  // OpenAI-compatible speech endpoints (OpenAI, Groq, local whisper/kokoro servers, ...):
2
2
  // STT as multipart POST {baseURL}/audio/transcriptions (file, model, language?), TTS as
3
3
  // POST {baseURL}/audio/speech {model, input, voice, response_format: "mp3", speed?}.
4
- import { audioMime, SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
4
+ import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
5
5
 
6
6
  export interface OpenAICompatibleOptions {
7
7
  /** Base URL including the version path, e.g. https://api.openai.com/v1 (no trailing slash). */
@@ -17,28 +17,35 @@ export interface OpenAICompatibleOptions {
17
17
 
18
18
  export class OpenAICompatible implements SpeechProvider {
19
19
  private readonly fetch: typeof globalThis.fetch
20
+ /** Ask for verbose_json (a Whisper model whose server has not refused it). */
21
+ private verbose: boolean
20
22
 
21
23
  constructor(private readonly options: OpenAICompatibleOptions) {
22
24
  this.fetch = options.fetch ?? globalThis.fetch
25
+ this.verbose = wantsVerbose(options.sttModel)
23
26
  }
24
27
 
25
28
  async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
26
- const form = new FormData()
27
- form.append("file", new File([new Uint8Array(audio)], `audio.${format}`, { type: audioMime(format) }))
28
- form.append("model", this.options.sttModel)
29
- if (this.options.language) form.append("language", this.options.language)
30
- const response = await this.fetch(`${this.options.baseURL}/audio/transcriptions`, {
31
- method: "POST",
32
- headers: await this.headers(),
33
- body: form,
34
- signal,
35
- })
29
+ const headers = await this.headers()
30
+ const send = (verbose: boolean) => {
31
+ const form = new FormData()
32
+ form.append("file", new File([new Uint8Array(audio)], `audio.${format}`, { type: audioMime(format) }))
33
+ form.append("model", this.options.sttModel)
34
+ if (this.options.language) form.append("language", this.options.language)
35
+ // Whisper's verbose_json rates each segment's no_speech_prob: noise transcribed as "Thank you." is dropped.
36
+ if (verbose) form.append("response_format", "verbose_json")
37
+ return this.fetch(`${this.options.baseURL}/audio/transcriptions`, { method: "POST", headers, body: form, signal })
38
+ }
39
+ const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
36
40
  if (!response.ok) throw await failure("transcription", response)
37
- const body = (await response.json()) as { text?: unknown }
38
- return String(body.text ?? "").trim()
41
+ return transcriptText(await response.json())
39
42
  }
40
43
 
41
44
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
45
+ return collect(await this.speakStream(text, speed, signal))
46
+ }
47
+
48
+ async speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>> {
42
49
  const { ttsModel: model, voice } = this.options
43
50
  const headers = { ...(await this.headers()), "content-type": "application/json" }
44
51
  const response = await withSpeedRetry(
@@ -52,7 +59,7 @@ export class OpenAICompatible implements SpeechProvider {
52
59
  speed,
53
60
  )
54
61
  if (!response.ok) throw await failure("speech", response)
55
- return new Uint8Array(await response.arrayBuffer())
62
+ return audioBody(response)
56
63
  }
57
64
 
58
65
  private async headers(): Promise<Record<string, string>> {
@@ -1,6 +1,6 @@
1
- // OpenRouter speech-to-text (/audio/transcriptions, JSON with base64 input_audio) and text-to-speech
2
- // (/audio/speech).
3
- import { SpeechError, upstreamDetail, withSpeedRetry, type SpeechProvider } from "./provider"
1
+ // OpenRouter speech-to-text (/audio/transcriptions as OpenAI-style multipart: the audio goes up as binary,
2
+ // not as base64 JSON, a third smaller and no encoding) and text-to-speech (/audio/speech).
3
+ import { audioBody, audioMime, collect, SpeechError, transcriptText, upstreamDetail, wantsVerbose, withSpeedRetry, withVerboseRetry, type SpeechProvider } from "./provider"
4
4
 
5
5
  export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
6
6
 
@@ -24,22 +24,37 @@ export class OpenRouterError extends SpeechError {
24
24
  export class OpenRouter implements SpeechProvider {
25
25
  private readonly baseURL: string
26
26
  private readonly fetch: typeof globalThis.fetch
27
+ /** Ask for verbose_json (a Whisper model whose provider has not refused it). */
28
+ private verbose: boolean
27
29
 
28
30
  constructor(private readonly options: OpenRouterOptions) {
29
31
  this.baseURL = options.baseURL ?? OPENROUTER_BASE_URL
30
32
  this.fetch = options.fetch ?? globalThis.fetch
33
+ this.verbose = wantsVerbose(options.sttModel)
31
34
  }
32
35
 
33
36
  async transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string> {
34
37
  const { sttModel: model, language } = this.options
35
- const input_audio = { data: Buffer.from(audio).toString("base64"), format }
36
- const response = await this.post("/audio/transcriptions", { model, input_audio, ...(language ? { language } : {}) }, signal)
38
+ // OpenRouter documents multipart file/model/language (up to 25 MB; an utterance is at most ~2 MB of WAV), and
39
+ // response_format verbose_json (segments with Whisper's no_speech_prob) for OpenAI-compatible providers.
40
+ const send = (verbose: boolean) => {
41
+ const form = new FormData()
42
+ form.append("file", new File([new Uint8Array(audio)], `audio.${format}`, { type: audioMime(format) }))
43
+ form.append("model", model)
44
+ if (language) form.append("language", language)
45
+ if (verbose) form.append("response_format", "verbose_json")
46
+ return this.post("/audio/transcriptions", form, signal)
47
+ }
48
+ const response = await withVerboseRetry(send, this.verbose, () => (this.verbose = false))
37
49
  if (!response.ok) throw await failure("transcription", response)
38
- const body = (await response.json()) as { text?: unknown }
39
- return String(body.text ?? "").trim()
50
+ return transcriptText(await response.json())
40
51
  }
41
52
 
42
53
  async speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>> {
54
+ return collect(await this.speakStream(text, speed, signal))
55
+ }
56
+
57
+ async speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>> {
43
58
  const { ttsModel: model, voice } = this.options
44
59
  const response = await withSpeedRetry(
45
60
  (s) =>
@@ -47,9 +62,10 @@ export class OpenRouter implements SpeechProvider {
47
62
  speed,
48
63
  )
49
64
  if (!response.ok) throw await failure("speech", response)
50
- return new Uint8Array(await response.arrayBuffer())
65
+ return audioBody(response)
51
66
  }
52
67
 
68
+ /** POSTs JSON, or a form as multipart (fetch sets its content type with the boundary). */
53
69
  private async post(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
54
70
  const key = await this.options.apiKey()
55
71
  if (!key) {
@@ -57,10 +73,11 @@ export class OpenRouter implements SpeechProvider {
57
73
  "No OpenRouter API key: set one in the speech settings, set OPENROUTER_API_KEY, or connect OpenRouter in OpenCode",
58
74
  )
59
75
  }
76
+ const form = body instanceof FormData
60
77
  return this.fetch(`${this.baseURL}${path}`, {
61
78
  method: "POST",
62
- headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
63
- body: JSON.stringify(body),
79
+ headers: { authorization: `Bearer ${key}`, ...(form ? {} : { "content-type": "application/json" }) },
80
+ body: form ? body : JSON.stringify(body),
64
81
  signal,
65
82
  })
66
83
  }
@@ -6,6 +6,18 @@ export interface SpeechProvider {
6
6
  transcribe(audio: Uint8Array, format: string, signal?: AbortSignal): Promise<string>
7
7
  /** MP3 audio. `speed` is optional; providers that reject it get one retry without it. */
8
8
  speak(text: string, speed?: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer>>
9
+ /** The same MP3 as a stream, passed through as the provider sends it (errors are thrown before any byte). */
10
+ speakStream(text: string, speed?: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
11
+ }
12
+
13
+ /** A successful upstream response's body (an empty stream when it has none). */
14
+ export function audioBody(response: Response): ReadableStream<Uint8Array> {
15
+ return response.body ?? new ReadableStream({ start: (controller) => controller.close() })
16
+ }
17
+
18
+ /** Reads a whole audio stream into one buffer. */
19
+ export async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array<ArrayBuffer>> {
20
+ return new Uint8Array(await new Response(stream).arrayBuffer())
9
21
  }
10
22
 
11
23
  /** The speech provider refused or failed a request (or is not configured). Never carries the API key. */
@@ -72,3 +84,45 @@ export async function withSpeedRetry(send: (speed?: number) => Promise<Response>
72
84
  await response.body?.cancel().catch(() => {})
73
85
  return send(undefined)
74
86
  }
87
+
88
+ /** Whisper segments rated more likely than this to hold no speech are dropped (Whisper's own no-speech threshold). */
89
+ export const NO_SPEECH_MAX = 0.6
90
+
91
+ /** Whisper models report no_speech_prob per segment in verbose_json; other speech-to-text models do not take it. */
92
+ export function wantsVerbose(model: string): boolean {
93
+ return /whisper/i.test(model)
94
+ }
95
+
96
+ interface TranscriptBody {
97
+ text?: unknown
98
+ segments?: Array<{ text?: unknown; no_speech_prob?: unknown }>
99
+ }
100
+
101
+ /**
102
+ * The transcript of a json or verbose_json response. With segments that carry no_speech_prob, the ones above
103
+ * NO_SPEECH_MAX (noise Whisper turned into "Thank you." and the like) are left out.
104
+ */
105
+ export function transcriptText(body: unknown): string {
106
+ const { text, segments } = (body ?? {}) as TranscriptBody
107
+ if (Array.isArray(segments) && segments.some((s) => typeof s?.no_speech_prob === "number")) {
108
+ return segments
109
+ .filter((s) => !(typeof s?.no_speech_prob === "number" && s.no_speech_prob > NO_SPEECH_MAX))
110
+ .map((s) => String(s?.text ?? "").trim())
111
+ .filter(Boolean)
112
+ .join(" ")
113
+ }
114
+ return String(text ?? "").trim()
115
+ }
116
+
117
+ /**
118
+ * Sends a transcription, verbose (verbose_json) when asked; a 400 to the verbose request is retried once plain,
119
+ * and when the plain one works the provider is told (`refused`) not to ask for verbose_json again.
120
+ */
121
+ export async function withVerboseRetry(send: (verbose: boolean) => Promise<Response>, verbose: boolean, refused: () => void): Promise<Response> {
122
+ const response = await send(verbose)
123
+ if (!verbose || response.status !== 400) return response
124
+ await response.body?.cancel().catch(() => {})
125
+ const plain = await send(false)
126
+ if (plain.ok) refused()
127
+ return plain
128
+ }
@@ -24,16 +24,18 @@ const MODELS_TIMEOUT_MS = 10_000
24
24
  export interface SpeechSettingsStore {
25
25
  /** The server default. */
26
26
  speechSettings(): StoredSpeech
27
- setSpeechSettings(settings: StoredSpeech): void
27
+ setSpeechSettings(settings: StoredSpeech): void | Promise<void>
28
28
  /** A user's own settings (multi-user hubs). */
29
29
  userSpeech?(userID: string): StoredSpeech
30
- setUserSpeech?(userID: string, settings: StoredSpeech): void
30
+ setUserSpeech?(userID: string, settings: StoredSpeech): void | Promise<void>
31
31
  }
32
32
 
33
33
  /** Speech for one scope: a user (their settings over the default) or, without a user, the server default. */
34
34
  export interface ScopedSpeech {
35
35
  transcribe(audio: Uint8Array, format: string): Promise<string>
36
36
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
37
+ /** The audio as the provider streams it; `signal` aborts the upstream request (the phone moved on). */
38
+ speakStream(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
37
39
  }
38
40
 
39
41
  export interface SpeechServiceOptions {
@@ -67,17 +69,24 @@ export class SpeechService {
67
69
  return userSpeechView(stored, this.options.defaults, this.userSettings(userID) ?? {})
68
70
  }
69
71
 
70
- /** Partial update of the server default, or of a user's own settings; throws ValidationError without saving anything. */
71
- update(input: unknown, userID?: string): SpeechView | UserSpeechView {
72
+ /**
73
+ * Partial update of the server default, or of a user's own settings; throws ValidationError (synchronously)
74
+ * without saving anything. Resolves with the new view once saved.
75
+ */
76
+ update(input: unknown, userID?: string): Promise<SpeechView | UserSpeechView> {
72
77
  const { store, defaults } = this.options
73
- if (userID === undefined) store.setSpeechSettings(updateSpeech(store.speechSettings(), input, defaults.file))
78
+ let saved: void | Promise<void>
79
+ if (userID === undefined) saved = store.setSpeechSettings(updateSpeech(store.speechSettings(), input, defaults.file))
74
80
  else {
75
81
  if (!store.setUserSpeech) throw new Error("per-user speech settings are not supported by this store")
76
82
  // Validated against (and keys dropped relative to) the layers below: server default and hub.json.
77
- store.setUserSpeech(userID, updateSpeech(this.userSettings(userID) ?? {}, input, lowerLayers(store.speechSettings(), defaults)))
83
+ saved = store.setUserSpeech(userID, updateSpeech(this.userSettings(userID) ?? {}, input, lowerLayers(store.speechSettings(), defaults)))
78
84
  }
79
85
  this.cache.clear()
80
- return this.view(userID)
86
+ return Promise.resolve(saved).then(() => {
87
+ this.cache.clear()
88
+ return this.view(userID)
89
+ })
81
90
  }
82
91
 
83
92
  /** Speech with a user's effective settings (undefined: the server default). */
@@ -85,6 +94,7 @@ export class SpeechService {
85
94
  return {
86
95
  transcribe: (audio, format) => this.transcribe(audio, format, undefined, userID),
87
96
  speak: (text) => this.speak(text, undefined, userID),
97
+ speakStream: (text, signal) => this.speakStream(text, signal, userID),
88
98
  }
89
99
  }
90
100
 
@@ -97,6 +107,11 @@ export class SpeechService {
97
107
  return this.provider("tts", userID).speak(text, tts.speed, signal)
98
108
  }
99
109
 
110
+ speakStream(text: string, signal?: AbortSignal, userID?: string): Promise<ReadableStream<Uint8Array>> {
111
+ const tts = resolveTts(this.options.store.speechSettings(), this.options.defaults, this.userSettings(userID))
112
+ return this.provider("tts", userID).speakStream(text, tts.speed, signal)
113
+ }
114
+
100
115
  /**
101
116
  * Models offered by the configured provider for `kind` (a user's effective provider when given), the
102
117
  * configured model first and marked `current`. OpenRouter filters by output modality; other providers list