@bivy/bivy 0.0.0 → 0.1.0-staging.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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. package/public/qr.js +464 -0
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ const defaultTimers = {
4
+ schedule: (fn, ms) => setTimeout(fn, ms),
5
+ cancel: (h) => clearTimeout(h),
6
+ };
7
+ export class SessionEventCoalescer {
8
+ pending = new Map();
9
+ timers = new Map();
10
+ coalesceMs;
11
+ emit;
12
+ clock;
13
+ constructor(opts) {
14
+ this.coalesceMs = opts.coalesceMs;
15
+ this.emit = opts.emit;
16
+ this.clock = opts.timers ?? defaultTimers;
17
+ }
18
+ /** Queue a self-superseding update for `sessionId`, replacing any pending one. */
19
+ push(sessionId, payload) {
20
+ this.pending.set(sessionId, payload);
21
+ if (!this.timers.has(sessionId)) {
22
+ this.timers.set(sessionId, this.clock.schedule(() => this.flush(sessionId), this.coalesceMs));
23
+ }
24
+ }
25
+ /** Emit the pending update for `sessionId` immediately, if any. */
26
+ flush(sessionId) {
27
+ this.cancelTimer(sessionId);
28
+ if (this.pending.has(sessionId)) {
29
+ const payload = this.pending.get(sessionId);
30
+ this.pending.delete(sessionId);
31
+ this.emit(payload);
32
+ }
33
+ }
34
+ /** Drop any pending update and timer for `sessionId` without emitting (teardown). */
35
+ clear(sessionId) {
36
+ this.cancelTimer(sessionId);
37
+ this.pending.delete(sessionId);
38
+ }
39
+ /** Number of sessions with a pending update (test/introspection aid). */
40
+ get size() {
41
+ return this.pending.size;
42
+ }
43
+ cancelTimer(sessionId) {
44
+ const handle = this.timers.get(sessionId);
45
+ if (handle !== undefined) {
46
+ this.clock.cancel(handle);
47
+ this.timers.delete(sessionId);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import path from "node:path";
4
+ /**
5
+ * The identity key two rows share iff they're the same durable conversation: a
6
+ * resolved on-disk path when known (so relative/absolute spellings of the same
7
+ * file collapse together), else the bare id. Shared by dedupeSessionSummaries
8
+ * below and by native-session-discovery.ts's cross-check against Bivy-managed
9
+ * sessions (issue #156) — one identity scheme, not two copies of it.
10
+ */
11
+ export function sessionIdentityKey(ref) {
12
+ return ref.path ? `ref:${path.resolve(ref.path)}` : `id:${ref.id}`;
13
+ }
14
+ /** Collapse adapter-local rows that point at the same durable conversation. */
15
+ export function dedupeSessionSummaries(sessions, ownerFor) {
16
+ const byIdentity = new Map();
17
+ for (const session of sessions) {
18
+ const owner = ownerFor(session);
19
+ const ref = owner?.path || session.path;
20
+ const identity = sessionIdentityKey(ref ? { path: ref } : { id: session.id });
21
+ const current = byIdentity.get(identity);
22
+ if (!current) {
23
+ byIdentity.set(identity, session);
24
+ continue;
25
+ }
26
+ const score = (candidate) => {
27
+ const candidateOwner = ownerFor(candidate);
28
+ return Number(candidateOwner?.id === candidate.id) * 2 + Number(candidateOwner?.runtimeId === candidate.agent);
29
+ };
30
+ if (score(session) > score(current))
31
+ byIdentity.set(identity, session);
32
+ }
33
+ return [...byIdentity.values()];
34
+ }
@@ -0,0 +1,65 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import path from "node:path";
4
+ /**
5
+ * Reduce an id-based runtime's resume ref to the bare session id it expects.
6
+ *
7
+ * A ref may arrive as the id itself (the common case — e.g. straight from Bivy's
8
+ * metadata store) or, for adapters that surface an on-disk transcript path, as a
9
+ * ".../<id>.jsonl" file (Claude Code lists sessions from ~/.claude that way). Both
10
+ * normalize to "<id>".
11
+ */
12
+ export function sessionIdFromRef(ref) {
13
+ return /[/\\]|\.jsonl$/i.test(ref) ? path.basename(ref).replace(/\.jsonl$/i, "") : ref;
14
+ }
15
+ /**
16
+ * Normalize a resume ref into the token the owning runtime actually expects,
17
+ * enforcing the sessions-dir guard only where it applies.
18
+ *
19
+ * Bivy is the unifying store for sessions started anywhere, so resume must work
20
+ * for every runtime — but runtimes differ in what a "session ref" is:
21
+ *
22
+ * - Path-based runtimes (pi) resume by reading a transcript file. The daemon
23
+ * can be handed an arbitrary path by a remote client, so these refs are
24
+ * confined to the node's sessions directory (path-traversal guard).
25
+ * - Id-based runtimes (Claude Code, …) resume by an opaque session id they
26
+ * validate themselves and store their transcripts elsewhere (~/.claude).
27
+ * There is no local file to confine, so the guard must NOT apply — doing so
28
+ * is what made those sessions fail with "Session file is outside the
29
+ * sessions directory".
30
+ *
31
+ * Throws for a path-based ref that escapes `sessionsDir`.
32
+ */
33
+ export function resolveResumeRef(opts) {
34
+ const { ref, resumesByPath, sessionsDir } = opts;
35
+ if (!resumesByPath)
36
+ return sessionIdFromRef(ref);
37
+ const resolved = path.resolve(ref);
38
+ if (!resolved.startsWith(path.resolve(sessionsDir) + path.sep)) {
39
+ throw new Error("Session file is outside the sessions directory");
40
+ }
41
+ return resolved;
42
+ }
43
+ /**
44
+ * Decide what to resume a client-named session from when the node isn't holding
45
+ * it in memory (a restart, an idle close, or a session this process never
46
+ * opened — the PWA lists sessions straight from durable metadata). Prefer an
47
+ * explicit transcript path, then the path metadata recorded, and finally — for
48
+ * id-based runtimes that keep no confinable local file — the session id itself.
49
+ *
50
+ * Returns undefined when nothing durable is known: a session this node never
51
+ * started, or one that was deleted. The caller turns that into "Session not
52
+ * found" rather than resurrecting a bogus session from an arbitrary id. This is
53
+ * the decision that lets a real-but-closed session resume instead of failing —
54
+ * the root of the PWA's "can't resume sessions / sessions not found".
55
+ */
56
+ export function resumeRefFor(opts) {
57
+ const explicit = opts.path?.trim();
58
+ if (explicit)
59
+ return explicit;
60
+ if (opts.metaPath)
61
+ return opts.metaPath;
62
+ if (opts.metaKnown && opts.id)
63
+ return opts.id;
64
+ return undefined;
65
+ }
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy voice` — configure speech-to-text (voice input) from the terminal.
4
+ //
5
+ // Mirrors the web app's Settings → Voice input panel: pick a preferred provider
6
+ // and store a per-provider API key (encrypted in the SecretVault). Keeps CLI and
7
+ // React in parity via the shared helpers in stt.ts.
8
+ import { createInterface } from "node:readline/promises";
9
+ import { stdin as input, stdout as output } from "node:process";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { STT_PROVIDERS, getSttConfig, isSttProvider, removeSttKey, resolveSttKey, setSttKey, setSttProvider, sttProviderList, } from "./stt.js";
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const repoRoot = path.resolve(__dirname, "..");
15
+ const appDir = process.env.BIVY_DATA_DIR ?? path.join(repoRoot, ".bivy");
16
+ async function askHidden(question) {
17
+ if (!input.isTTY) {
18
+ const rl = createInterface({ input, output });
19
+ try {
20
+ return await rl.question(question);
21
+ }
22
+ finally {
23
+ rl.close();
24
+ }
25
+ }
26
+ return new Promise((resolve, reject) => {
27
+ let value = "";
28
+ const onData = (chunk) => {
29
+ for (const char of chunk.toString("utf8")) {
30
+ if (char === "\u0003") {
31
+ cleanup();
32
+ output.write("\n");
33
+ reject(new Error("Cancelled"));
34
+ return;
35
+ }
36
+ if (char === "\r" || char === "\n") {
37
+ cleanup();
38
+ output.write("\n");
39
+ resolve(value);
40
+ return;
41
+ }
42
+ if (char === "\u007f" || char === "\b")
43
+ value = value.slice(0, -1);
44
+ else
45
+ value += char;
46
+ }
47
+ };
48
+ const cleanup = () => {
49
+ input.off("data", onData);
50
+ input.setRawMode(false);
51
+ input.pause();
52
+ };
53
+ output.write(question);
54
+ input.setRawMode(true);
55
+ input.resume();
56
+ input.on("data", onData);
57
+ });
58
+ }
59
+ function usage() {
60
+ console.log(`Usage: bivy voice <status|provider|key|remove>
61
+
62
+ Commands:
63
+ bivy voice Show the current provider and which keys are set
64
+ bivy voice status (same as above)
65
+ bivy voice provider <groq|openai> Choose the preferred transcription provider
66
+ bivy voice key <groq|openai> [key] Store an API key (prompts when the key is omitted)
67
+ bivy voice remove <groq|openai> Forget a stored API key
68
+
69
+ Providers:
70
+ groq ${STT_PROVIDERS.groq.model} — fast, low cost, strong multilingual
71
+ openai ${STT_PROVIDERS.openai.model} — top accuracy
72
+ `);
73
+ }
74
+ function parseProvider(value) {
75
+ if (!isSttProvider(value)) {
76
+ throw new Error(`Provider must be one of: ${sttProviderList().join(", ")}`);
77
+ }
78
+ return value;
79
+ }
80
+ async function printStatus() {
81
+ const config = await getSttConfig(appDir);
82
+ console.log(`Preferred provider: ${config.provider}`);
83
+ for (const p of config.providers) {
84
+ const active = p.id === config.provider ? " (preferred)" : "";
85
+ console.log(` ${p.configured ? "✓" : "·"} ${p.id.padEnd(7)} ${p.label} — ${p.model}${active}`);
86
+ }
87
+ console.log(config.providers.some((p) => p.configured)
88
+ ? ""
89
+ : "\nNo keys set yet. Run 'bivy voice key groq' or 'bivy voice key openai'.");
90
+ }
91
+ async function main() {
92
+ const [cmd, arg, ...rest] = process.argv.slice(2);
93
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
94
+ usage();
95
+ return;
96
+ }
97
+ switch (cmd) {
98
+ case "status":
99
+ await printStatus();
100
+ return;
101
+ case "provider": {
102
+ const provider = parseProvider(arg);
103
+ setSttProvider(appDir, provider);
104
+ console.log(`Preferred voice provider set to ${provider}.`);
105
+ if (!(await resolveSttKey(appDir, provider))) {
106
+ console.log(`No key stored for ${provider} yet — run 'bivy voice key ${provider}'.`);
107
+ }
108
+ return;
109
+ }
110
+ case "key": {
111
+ const provider = parseProvider(arg);
112
+ const value = rest.join(" ").trim() || (await askHidden(`${STT_PROVIDERS[provider].label} API key: `));
113
+ setSttKey(appDir, provider, value);
114
+ console.log(`Saved ${provider} key.`);
115
+ return;
116
+ }
117
+ case "remove": {
118
+ const provider = parseProvider(arg);
119
+ console.log(removeSttKey(appDir, provider) ? `Removed ${provider} key.` : `No ${provider} key was stored.`);
120
+ return;
121
+ }
122
+ default:
123
+ console.error(`Unknown command: ${cmd}\n`);
124
+ usage();
125
+ process.exitCode = 1;
126
+ }
127
+ }
128
+ main().catch((error) => {
129
+ console.error(error instanceof Error ? error.message : String(error));
130
+ process.exit(1);
131
+ });
package/dist/stt.js ADDED
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Speech-to-text (voice input) — shared logic for the node server and the CLI.
4
+ //
5
+ // A voice recording captured in the web app (or any client) is forwarded to the
6
+ // node, which holds the provider API key and makes the transcription call on the
7
+ // user's behalf. Two providers are supported; the user picks a preferred one and
8
+ // stores a key per provider. Keys live in the encrypted SecretVault (never in
9
+ // settings.json); the preferred provider is a plain setting.
10
+ //
11
+ // Both providers expose the same OpenAI-compatible /audio/transcriptions
12
+ // multipart endpoint that returns `{ text }`, which keeps the call site uniform.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { SecretVault } from "./secrets.js";
16
+ export const STT_PROVIDERS = {
17
+ groq: {
18
+ id: "groq",
19
+ label: "Groq (Whisper)",
20
+ url: "https://api.groq.com/openai/v1/audio/transcriptions",
21
+ model: "whisper-large-v3-turbo",
22
+ keyEnv: "GROQ_API_KEY",
23
+ },
24
+ openai: {
25
+ id: "openai",
26
+ label: "OpenAI",
27
+ url: "https://api.openai.com/v1/audio/transcriptions",
28
+ model: "gpt-4o-mini-transcribe",
29
+ keyEnv: "OPENAI_API_KEY",
30
+ },
31
+ };
32
+ export const DEFAULT_STT_PROVIDER = "groq";
33
+ /** Max audio payload we forward to a provider (their own caps are ~25 MB). */
34
+ export const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
35
+ export function isSttProvider(value) {
36
+ return value === "groq" || value === "openai";
37
+ }
38
+ export function sttProviderList() {
39
+ return Object.keys(STT_PROVIDERS);
40
+ }
41
+ /** Vault secret id holding a provider's API key. */
42
+ export function sttKeyId(provider) {
43
+ return `stt.${provider}`;
44
+ }
45
+ function settingsPath(appDir) {
46
+ return path.join(appDir, "settings.json");
47
+ }
48
+ function readSettings(appDir) {
49
+ try {
50
+ const file = settingsPath(appDir);
51
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ // Read-modify-write so we never clobber unrelated settings (approvalMode, etc.).
58
+ function writeSettings(appDir, settings) {
59
+ fs.mkdirSync(appDir, { recursive: true });
60
+ fs.writeFileSync(settingsPath(appDir), `${JSON.stringify(settings, null, 2)}\n`);
61
+ }
62
+ export function getSttProvider(appDir) {
63
+ const raw = readSettings(appDir).sttProvider;
64
+ return isSttProvider(raw) ? raw : DEFAULT_STT_PROVIDER;
65
+ }
66
+ export function setSttProvider(appDir, provider) {
67
+ if (!isSttProvider(provider))
68
+ throw new Error(`Unknown speech provider: ${provider}`);
69
+ const settings = readSettings(appDir);
70
+ settings.sttProvider = provider;
71
+ writeSettings(appDir, settings);
72
+ return provider;
73
+ }
74
+ /** Resolve a provider key: stored vault secret first, then env-var fallback. */
75
+ export async function resolveSttKey(appDir, provider) {
76
+ const fromVault = await new SecretVault(appDir).resolve(sttKeyId(provider)).catch(() => undefined);
77
+ if (fromVault)
78
+ return fromVault;
79
+ const env = process.env[STT_PROVIDERS[provider].keyEnv];
80
+ return env && env.trim() ? env.trim() : undefined;
81
+ }
82
+ export function setSttKey(appDir, provider, key) {
83
+ if (!isSttProvider(provider))
84
+ throw new Error(`Unknown speech provider: ${provider}`);
85
+ const value = String(key || "").trim();
86
+ if (!value)
87
+ throw new Error("API key cannot be empty.");
88
+ new SecretVault(appDir).setLocal(sttKeyId(provider), value, `${STT_PROVIDERS[provider].label} speech-to-text key`);
89
+ }
90
+ export function removeSttKey(appDir, provider) {
91
+ if (!isSttProvider(provider))
92
+ throw new Error(`Unknown speech provider: ${provider}`);
93
+ return new SecretVault(appDir).delete(sttKeyId(provider));
94
+ }
95
+ /** The voice-input status the settings UIs render: chosen provider + which keys exist. */
96
+ export async function getSttConfig(appDir) {
97
+ const provider = getSttProvider(appDir);
98
+ const providers = await Promise.all(sttProviderList().map(async (id) => ({
99
+ id,
100
+ label: STT_PROVIDERS[id].label,
101
+ model: STT_PROVIDERS[id].model,
102
+ configured: Boolean(await resolveSttKey(appDir, id)),
103
+ })));
104
+ return { provider, providers };
105
+ }
106
+ /** Send audio to the chosen provider and return the transcript text. */
107
+ export async function transcribeAudio(input) {
108
+ const provider = isSttProvider(input.provider) ? input.provider : getSttProvider(input.appDir);
109
+ const spec = STT_PROVIDERS[provider];
110
+ if (!input.audio || input.audio.length === 0)
111
+ throw new Error("No audio was recorded.");
112
+ if (input.audio.length > MAX_AUDIO_BYTES)
113
+ throw new Error("Recording is too large to transcribe.");
114
+ const key = await resolveSttKey(input.appDir, provider);
115
+ if (!key) {
116
+ throw new Error(`No API key set for ${spec.label}. Add one in Settings → Voice input, or run 'bivy voice key ${provider}'.`);
117
+ }
118
+ const mime = input.mimeType || "audio/webm";
119
+ const filename = input.filename || `audio.${extensionForMime(mime)}`;
120
+ const form = new FormData();
121
+ // Copy into a plain Uint8Array — a Node Buffer's backing store is typed as
122
+ // possibly-SharedArrayBuffer, which isn't a valid BlobPart under DOM lib types.
123
+ form.append("file", new Blob([new Uint8Array(input.audio)], { type: mime }), filename);
124
+ form.append("model", spec.model);
125
+ form.append("response_format", "json");
126
+ if (input.language)
127
+ form.append("language", input.language);
128
+ let res;
129
+ try {
130
+ res = await fetch(spec.url, { method: "POST", headers: { authorization: `Bearer ${key}` }, body: form });
131
+ }
132
+ catch (error) {
133
+ throw new Error(`Could not reach ${spec.label}: ${error instanceof Error ? error.message : String(error)}`);
134
+ }
135
+ const body = await res.text();
136
+ if (!res.ok) {
137
+ let detail = body.slice(0, 300);
138
+ try {
139
+ detail = JSON.parse(body)?.error?.message || detail;
140
+ }
141
+ catch {
142
+ /* keep raw body */
143
+ }
144
+ throw new Error(`${spec.label} transcription failed (${res.status}): ${detail}`);
145
+ }
146
+ let data;
147
+ try {
148
+ data = JSON.parse(body);
149
+ }
150
+ catch {
151
+ throw new Error(`${spec.label} returned an unreadable response.`);
152
+ }
153
+ return String(data.text ?? "").trim();
154
+ }
155
+ function extensionForMime(mime) {
156
+ const m = mime.toLowerCase();
157
+ if (m.includes("webm"))
158
+ return "webm";
159
+ if (m.includes("ogg"))
160
+ return "ogg";
161
+ if (m.includes("mp4") || m.includes("m4a") || m.includes("aac"))
162
+ return "m4a";
163
+ if (m.includes("mpeg") || m.includes("mp3"))
164
+ return "mp3";
165
+ if (m.includes("wav"))
166
+ return "wav";
167
+ return "webm";
168
+ }