@bivy/bivy 0.0.0 → 0.1.0-staging.11

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 (147) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +335 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4215 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/port-picker.mjs +40 -0
  9. package/bin/prune-sessions.mjs +52 -0
  10. package/bin/sessions-list.mjs +27 -0
  11. package/bin/shim-path.mjs +126 -0
  12. package/bin/uninstall-paths.mjs +48 -0
  13. package/dist/approval.js +87 -0
  14. package/dist/attach.js +248 -0
  15. package/dist/auth.js +258 -0
  16. package/dist/bivy-login.js +180 -0
  17. package/dist/browser-open.js +50 -0
  18. package/dist/control-plane-tasks.js +236 -0
  19. package/dist/data-dir.js +25 -0
  20. package/dist/device-registry.js +201 -0
  21. package/dist/e2e.js +70 -0
  22. package/dist/ephemeral-exec.js +109 -0
  23. package/dist/exec.js +209 -0
  24. package/dist/git-auth.js +155 -0
  25. package/dist/github-app-auth.js +107 -0
  26. package/dist/github-app-connect.js +235 -0
  27. package/dist/github-app-manifest.js +82 -0
  28. package/dist/github-app-sync-cli.js +93 -0
  29. package/dist/github-app-vault.js +106 -0
  30. package/dist/github-apps.js +121 -0
  31. package/dist/github-connect-repo.js +74 -0
  32. package/dist/github-device-auth.js +109 -0
  33. package/dist/github-tasks.js +650 -0
  34. package/dist/guard.js +109 -0
  35. package/dist/harness/cache-evict.js +88 -0
  36. package/dist/harness/checkpoint.js +0 -0
  37. package/dist/harness/cow-clone.js +84 -0
  38. package/dist/harness/dep-cache.js +78 -0
  39. package/dist/harness/disk-admission.js +46 -0
  40. package/dist/harness/egress.js +30 -0
  41. package/dist/harness/manager.js +97 -0
  42. package/dist/harness/mcp-config-formats.js +164 -0
  43. package/dist/harness/mcp-config.js +111 -0
  44. package/dist/harness/mcp-inject.js +134 -0
  45. package/dist/harness/mcp-proxy-cli.js +88 -0
  46. package/dist/harness/mcp-proxy.js +150 -0
  47. package/dist/harness/net-proxy.js +120 -0
  48. package/dist/harness/sandbox.js +96 -0
  49. package/dist/history-sync.js +26 -0
  50. package/dist/hosted-endpoints.d.mts +14 -0
  51. package/dist/hosted-endpoints.mjs +35 -0
  52. package/dist/identity.js +153 -0
  53. package/dist/integrations/index.js +4 -0
  54. package/dist/integrations/manager.js +279 -0
  55. package/dist/integrations/oauth.js +78 -0
  56. package/dist/integrations/registry.js +239 -0
  57. package/dist/integrations/store.js +54 -0
  58. package/dist/integrations/types.js +1 -0
  59. package/dist/linear-tasks.js +49 -0
  60. package/dist/metadata.js +226 -0
  61. package/dist/multiplexer.js +79 -0
  62. package/dist/native-pi.js +38 -0
  63. package/dist/node-stats.js +237 -0
  64. package/dist/pairing-crypto.js +105 -0
  65. package/dist/policy/conditions.js +103 -0
  66. package/dist/policy/policy-engine.js +20 -0
  67. package/dist/policy/risk.js +18 -0
  68. package/dist/policy/ruleset.js +113 -0
  69. package/dist/policy/run-policy.js +108 -0
  70. package/dist/policy/session-reroute.js +96 -0
  71. package/dist/pty-runner.py +95 -0
  72. package/dist/question.js +146 -0
  73. package/dist/redact.js +97 -0
  74. package/dist/relay-attach.js +345 -0
  75. package/dist/relay-chunk.js +73 -0
  76. package/dist/relay-cli-crypto.js +70 -0
  77. package/dist/relay-client.js +372 -0
  78. package/dist/relay-setup.js +262 -0
  79. package/dist/repo-workspace.js +208 -0
  80. package/dist/runtime/adoption.js +45 -0
  81. package/dist/runtime/agent-service-bin.js +149 -0
  82. package/dist/runtime/agent-service.js +439 -0
  83. package/dist/runtime/ansi.js +27 -0
  84. package/dist/runtime/anthropic-preflight.js +80 -0
  85. package/dist/runtime/claude-code.js +1408 -0
  86. package/dist/runtime/cli-parsers.js +647 -0
  87. package/dist/runtime/codex-auth.js +168 -0
  88. package/dist/runtime/codex-preflight.js +60 -0
  89. package/dist/runtime/codex-sessions.js +279 -0
  90. package/dist/runtime/control-plane-location.js +74 -0
  91. package/dist/runtime/credential-ingest.js +122 -0
  92. package/dist/runtime/credential-provisioning.js +79 -0
  93. package/dist/runtime/credential-store.js +435 -0
  94. package/dist/runtime/credentials.js +153 -0
  95. package/dist/runtime/host.js +153 -0
  96. package/dist/runtime/index.js +1552 -0
  97. package/dist/runtime/local-model-store.js +194 -0
  98. package/dist/runtime/location-registry.js +28 -0
  99. package/dist/runtime/model-catalog.js +97 -0
  100. package/dist/runtime/model-namer.js +85 -0
  101. package/dist/runtime/native-process-scan.js +102 -0
  102. package/dist/runtime/native-session-discovery.js +103 -0
  103. package/dist/runtime/normalize.js +75 -0
  104. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  105. package/dist/runtime/oauth/model-oauth.js +324 -0
  106. package/dist/runtime/opencode-preflight.js +55 -0
  107. package/dist/runtime/pi-auth.js +82 -0
  108. package/dist/runtime/pi-oauth.js +52 -0
  109. package/dist/runtime/pi-session-discovery.js +42 -0
  110. package/dist/runtime/pi.js +539 -0
  111. package/dist/runtime/process.js +499 -0
  112. package/dist/runtime/protocol.js +646 -0
  113. package/dist/runtime/remote.js +541 -0
  114. package/dist/runtime/rpc-protocol.js +56 -0
  115. package/dist/runtime/ruleset-store.js +117 -0
  116. package/dist/runtime/session-location.js +50 -0
  117. package/dist/runtime/types.js +17 -0
  118. package/dist/secrets-cli.js +134 -0
  119. package/dist/secrets.js +264 -0
  120. package/dist/server.js +9435 -0
  121. package/dist/session/bivy-session.js +1 -0
  122. package/dist/session/checkpoint-pack.js +133 -0
  123. package/dist/session/event-log.js +340 -0
  124. package/dist/session/fork-dirty.js +73 -0
  125. package/dist/session/fork-prereqs.js +61 -0
  126. package/dist/session/fork.js +90 -0
  127. package/dist/session/native-import.js +56 -0
  128. package/dist/session/reconnect.js +168 -0
  129. package/dist/session/replication-service.js +236 -0
  130. package/dist/session/replication.js +106 -0
  131. package/dist/session/replicator.js +140 -0
  132. package/dist/session/session-new-dedupe.js +42 -0
  133. package/dist/session/sibling-client.js +201 -0
  134. package/dist/session/transcript-merge.js +131 -0
  135. package/dist/session/transcript-normal.js +195 -0
  136. package/dist/session/workspace-context.js +1 -0
  137. package/dist/session-event-coalescer.js +50 -0
  138. package/dist/session-identity.js +34 -0
  139. package/dist/session-ref.js +65 -0
  140. package/dist/stt-cli.js +131 -0
  141. package/dist/stt.js +168 -0
  142. package/dist/terminal.js +409 -0
  143. package/dist/wire-format.js +67 -0
  144. package/dist/worktree-provision.js +118 -0
  145. package/dist/worktree.js +117 -0
  146. package/package.json +40 -6
  147. package/public/qr.js +464 -0
@@ -0,0 +1,195 @@
1
+ /** Cap a tool payload down to a short, log-safe one-liner. */
2
+ function compactValue(value, max = 200) {
3
+ let s;
4
+ try {
5
+ s = typeof value === "string" ? value : JSON.stringify(value);
6
+ }
7
+ catch {
8
+ s = String(value);
9
+ }
10
+ s = String(s ?? "").replace(/\s+/g, " ").trim();
11
+ return s.length > max ? `${s.slice(0, Math.max(0, max - 1))}…` : s;
12
+ }
13
+ /** Flatten one runtime message's content into text + tool annotations. */
14
+ function readContent(content) {
15
+ if (typeof content === "string") {
16
+ return { text: content, tools: [], toolResultOnly: false };
17
+ }
18
+ if (!Array.isArray(content)) {
19
+ return { text: "", tools: [], toolResultOnly: false };
20
+ }
21
+ const texts = [];
22
+ const tools = [];
23
+ let sawText = false;
24
+ let sawToolResult = false;
25
+ for (const raw of content) {
26
+ const type = raw?.type;
27
+ if (type === "text" && typeof raw.text === "string") {
28
+ texts.push(raw.text);
29
+ sawText = true;
30
+ }
31
+ else if (type === "tool_use") {
32
+ tools.push({ name: raw.name, summary: `${raw.name ?? "tool"}(${compactValue(raw.input)})` });
33
+ }
34
+ else if (type === "tool_result") {
35
+ sawToolResult = true;
36
+ tools.push({ name: undefined, summary: `→ ${compactValue(raw.content)}` });
37
+ }
38
+ // "thinking" and unknown block types are intentionally dropped: internal
39
+ // reasoning is neither portable across runtimes nor needed for continuity.
40
+ }
41
+ return { text: texts.join("\n").trim(), tools, toolResultOnly: sawToolResult && !sawText };
42
+ }
43
+ function normalizeRole(role, toolResultOnly) {
44
+ if (role === "assistant")
45
+ return "assistant";
46
+ if (role === "system" || role === "error")
47
+ return "error";
48
+ // A "user" message that is purely tool_result is really tool output.
49
+ if (role === "user")
50
+ return toolResultOnly ? "tool" : "user";
51
+ return "user";
52
+ }
53
+ function toTs(value) {
54
+ if (typeof value === "number" && Number.isFinite(value))
55
+ return value;
56
+ if (typeof value === "string") {
57
+ const t = Date.parse(value);
58
+ if (!Number.isNaN(t))
59
+ return t;
60
+ }
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Flatten a runtime transcript (`readMessages()` output) into portable turns.
65
+ * Pure and defensive: unknown shapes degrade to empty text rather than throwing.
66
+ */
67
+ export function normalizeMessages(messages, header) {
68
+ const turns = [];
69
+ for (const message of messages ?? []) {
70
+ const m = message;
71
+ const { text, tools, toolResultOnly } = readContent(m.content);
72
+ const role = normalizeRole(m.role, toolResultOnly);
73
+ const namedTool = tools.find((t) => t.name)?.name;
74
+ const toolSummary = tools.length ? tools.map((t) => t.summary).join("; ") : undefined;
75
+ // Skip turns that carry nothing a destination could use.
76
+ if (!text && !toolSummary)
77
+ continue;
78
+ turns.push({
79
+ role,
80
+ text,
81
+ ...(namedTool ? { toolName: namedTool } : {}),
82
+ ...(toolSummary ? { toolSummary } : {}),
83
+ ...(toTs(m.timestamp ?? m.ts) !== undefined ? { ts: toTs(m.timestamp ?? m.ts) } : {}),
84
+ });
85
+ }
86
+ return { header, turns };
87
+ }
88
+ function truncate(text, max) {
89
+ const compact = String(text || "").replace(/\s+/g, " ").trim();
90
+ return compact.length > max ? `${compact.slice(0, Math.max(0, max - 1))}…` : compact;
91
+ }
92
+ /**
93
+ * Render a continuation prompt for a **seeded** (cross-runtime) fork — the
94
+ * fallback when the target runtime can't replay history into its own store.
95
+ *
96
+ * The recent-conversation block is **budget-adaptive**, not a fixed tail: it
97
+ * walks backward from the latest turn packing verbatim turns until `charBudget`
98
+ * is reached (or the optional `recentTurns` count cap is hit), so a long run of
99
+ * short turns carries far more context than the old fixed 12, while a few
100
+ * verbose turns still stay bounded for the target's context window and cost. Any
101
+ * turns that don't fit are summarised as an omission count that points at the
102
+ * full transcript — the complete history is one link away. A structured superset
103
+ * of the old client-side `sessionHandoffSummary`.
104
+ */
105
+ export function buildSeedPrompt(transcript, opts = {}) {
106
+ const perTurnChars = opts.perTurnChars ?? 700;
107
+ const charBudget = opts.charBudget ?? 12000;
108
+ const maxCount = opts.recentTurns ?? Number.POSITIVE_INFINITY;
109
+ const title = transcript.header.title || "Untitled session";
110
+ const targetAgent = opts.targetAgent || "a new agent";
111
+ const formatted = transcript.turns
112
+ .filter((t) => t.text || t.toolSummary)
113
+ .map((t) => {
114
+ const body = t.text || (t.toolSummary ? `[${t.toolName ?? "tool"}] ${t.toolSummary}` : "");
115
+ return `- ${t.role}: ${truncate(body, perTurnChars)}`;
116
+ });
117
+ // Pack the newest turns first, within both the char budget and the count cap.
118
+ // The most recent turn is always kept, even if it alone exceeds the budget, so
119
+ // the seed is never empty.
120
+ const picked = [];
121
+ let used = 0;
122
+ for (let i = formatted.length - 1; i >= 0 && picked.length < maxCount; i -= 1) {
123
+ const cost = formatted[i].length + 1;
124
+ if (picked.length > 0 && used + cost > charBudget)
125
+ break;
126
+ picked.push(formatted[i]);
127
+ used += cost;
128
+ }
129
+ picked.reverse();
130
+ const omitted = formatted.length - picked.length;
131
+ const recent = picked.length ? picked.join("\n") : "- (no prior turns were available)";
132
+ const lines = [
133
+ `I am continuing an existing Bivy session (forked from ${transcript.header.sourceRuntimeId} to ${targetAgent}).`,
134
+ `Session: ${title}`,
135
+ opts.transcriptUrl ? `Full original transcript: ${opts.transcriptUrl}` : null,
136
+ transcript.header.model ? `Model before fork: ${transcript.header.model}` : null,
137
+ opts.context?.repoSlug ? `Repository: ${opts.context.repoSlug}` : null,
138
+ opts.context?.branch ? `Branch: ${opts.context.branch}` : null,
139
+ opts.context?.prUrl ? `PR: ${opts.context.prUrl}` : null,
140
+ "",
141
+ omitted > 0
142
+ ? `Recent conversation (most recent last; ${omitted} earlier turn${omitted === 1 ? "" : "s"} omitted — see the full transcript${opts.transcriptUrl ? " linked above" : ""}):`
143
+ : "Recent conversation (most recent last):",
144
+ recent,
145
+ "",
146
+ opts.transcriptUrl
147
+ ? "Open the full transcript link above if this summary is missing anything, then continue from here."
148
+ : "Continue from here.",
149
+ ];
150
+ return lines.filter((line) => line != null).join("\n");
151
+ }
152
+ /**
153
+ * Render a normalized transcript as portable `{role, text}` turns for a
154
+ * **replayed** ("true fork") cross-runtime fork — the target runtime writes
155
+ * these as real prior conversation into its own store and resumes, so the new
156
+ * agent opens on a copy of the whole history instead of a seeded summary.
157
+ *
158
+ * Unlike `buildSeedPrompt` this keeps EVERY turn, not just the tail, and keeps
159
+ * each turn's own role instead of flattening the conversation into one user
160
+ * prompt. Two deliberate shaping rules keep the result valid on any target model:
161
+ * - Tool activity is inlined as plain text (`[ran X] …`, `[tool result] …`),
162
+ * never as provider-specific `tool_use`/`tool_result` blocks whose ids/schemas
163
+ * would dangle or mismatch in a different runtime.
164
+ * - Non-conversational roles fold into the model's voice: a pure tool-result
165
+ * turn and a system/error notice both attach as `assistant` text (the agent's
166
+ * own work), so only the human's turns ever carry the `user` role.
167
+ * Consecutive same-role turns are merged so the resumed history reads as clean
168
+ * alternating turns. Tool payloads inherit `normalizeMessages`' compaction, so a
169
+ * replayed fork is faithful in its prose but summarised in raw tool I/O — the
170
+ * working tree (carried separately as a dirty patch) holds the real file state.
171
+ */
172
+ export function buildForkHistory(transcript) {
173
+ const history = [];
174
+ for (const turn of transcript.turns) {
175
+ const role = turn.role === "user" ? "user" : "assistant";
176
+ const parts = [];
177
+ if (turn.role === "error" && turn.text)
178
+ parts.push(`[system] ${turn.text}`);
179
+ else if (turn.text)
180
+ parts.push(turn.text);
181
+ if (turn.toolSummary) {
182
+ const label = turn.role === "tool" ? "tool result" : turn.toolName ? `ran ${turn.toolName}` : "tool";
183
+ parts.push(`[${label}] ${turn.toolSummary}`);
184
+ }
185
+ const text = parts.join("\n\n").trim();
186
+ if (!text)
187
+ continue;
188
+ const last = history[history.length - 1];
189
+ if (last && last.role === role)
190
+ last.text = `${last.text}\n\n${text}`;
191
+ else
192
+ history.push({ role, text });
193
+ }
194
+ return history;
195
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }