@huynle/hub 0.6.1 → 0.7.2
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 +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|