@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.
- package/LICENSE +105 -0
- package/README.md +335 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4215 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/port-picker.mjs +40 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +372 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1408 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +279 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1552 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +539 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +646 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9435 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +90 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +195 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- 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
|
+
}
|
package/dist/stt-cli.js
ADDED
|
@@ -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
|
+
}
|