@bivy/bivy 0.1.0-staging.13 → 0.1.0-staging.14

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/dist/server.js CHANGED
@@ -66,6 +66,7 @@ import { thinkingTextFromContent } from "./session/transcript-merge.js";
66
66
  import { normalizeMessages } from "./session/transcript-normal.js";
67
67
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
68
68
  import { EventLog } from "./session/event-log.js";
69
+ import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
69
70
  import { ReplicationService } from "./session/replication-service.js";
70
71
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
71
72
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
@@ -855,9 +856,12 @@ function safeAttachmentName(value) {
855
856
  return String(value || "attachment").replace(/[\r\n]/g, " ").slice(0, 180);
856
857
  }
857
858
  /**
858
- * Split composer attachments into three channels:
859
+ * Split composer attachments into channels:
859
860
  * - `images` — base64 blobs passed to the model as vision.
860
861
  * - `imageNotes` — one prose line per image for the persisted transcript.
862
+ * - `imageRefs` — durable AttachmentStore references for the images, persisted
863
+ * in the event log so they rehydrate after a reload / on
864
+ * another device (images used to be vision-only, then lost).
861
865
  * - `files` — decoded file attachments (bytes or text) to be written to
862
866
  * disk by materializeAttachments so the agent can open them
863
867
  * with its normal file tools. Any file type is supported;
@@ -865,9 +869,10 @@ function safeAttachmentName(value) {
865
869
  */
866
870
  function attachmentsFrom(value) {
867
871
  if (!Array.isArray(value))
868
- return { images: [], imageNotes: [], files: [] };
872
+ return { images: [], imageNotes: [], imageRefs: [], files: [] };
869
873
  const images = [];
870
874
  const imageNotes = [];
875
+ const imageRefs = [];
871
876
  const files = [];
872
877
  for (const raw of value.slice(0, 12)) {
873
878
  if (!raw || typeof raw !== "object")
@@ -877,8 +882,17 @@ function attachmentsFrom(value) {
877
882
  const size = Number(attachment.size || 0);
878
883
  const mimeType = typeof attachment.mimeType === "string" && attachment.mimeType ? attachment.mimeType : undefined;
879
884
  if (attachment.kind === "image" && typeof attachment.data === "string") {
880
- images.push({ type: "image", data: attachment.data, mimeType: mimeType ?? "image/png" });
885
+ const imgMime = mimeType ?? "image/png";
886
+ images.push({ type: "image", data: attachment.data, mimeType: imgMime });
881
887
  imageNotes.push(`[Image attachment: ${name}${size ? ` (${size} bytes)` : ""}]`);
888
+ // Persist the image bytes durably (dedup by hash). Best-effort: a store
889
+ // failure must not break vision for the turn, so it only costs the ref.
890
+ try {
891
+ imageRefs.push(attachmentStore.put(Buffer.from(attachment.data, "base64"), { name, mimeType: imgMime, kind: "image" }));
892
+ }
893
+ catch (error) {
894
+ console.warn("[attachments] failed to store image:", error instanceof Error ? error.message : String(error));
895
+ }
882
896
  }
883
897
  else if (attachment.kind === "file") {
884
898
  if (typeof attachment.data === "string" && attachment.data) {
@@ -891,7 +905,7 @@ function attachmentsFrom(value) {
891
905
  // nothing to write, so there is nothing to hand the agent — skip it.
892
906
  }
893
907
  }
894
- return { images, imageNotes, files };
908
+ return { images, imageNotes, imageRefs, files };
895
909
  }
896
910
  /** Strip a user-supplied filename to a safe basename — no path traversal, no
897
911
  * characters that would break the placeholder note or the filesystem. */
@@ -914,7 +928,22 @@ function sanitizeAttachmentFilename(name) {
914
928
  */
915
929
  function materializeAttachments(record, files) {
916
930
  if (!files.length)
917
- return "";
931
+ return { note: "", refs: [] };
932
+ const refs = [];
933
+ // Store every file durably in the global content-addressed store first (for
934
+ // re-findability), independent of the per-workdir copy below. Best-effort per
935
+ // file so one bad blob doesn't lose the others.
936
+ for (const file of files) {
937
+ const bytes = file.bytes ?? (typeof file.text === "string" ? Buffer.from(file.text, "utf8") : undefined);
938
+ if (!bytes)
939
+ continue;
940
+ try {
941
+ refs.push(attachmentStore.put(bytes, { name: sanitizeAttachmentFilename(file.name), mimeType: file.mimeType, kind: "file" }));
942
+ }
943
+ catch (error) {
944
+ console.warn("[attachments] failed to store file:", error instanceof Error ? error.message : String(error));
945
+ }
946
+ }
918
947
  const workdir = harnessDirFor(record);
919
948
  const dir = path.join(workdir, ".bivy-attachments");
920
949
  try {
@@ -922,7 +951,7 @@ function materializeAttachments(record, files) {
922
951
  }
923
952
  catch (error) {
924
953
  const why = error instanceof Error ? error.message : String(error);
925
- return files.map((f) => `[File attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n");
954
+ return { note: files.map((f) => `[File attachment: ${sanitizeAttachmentFilename(f.name)} could not be saved: ${why}]`).join("\n"), refs };
926
955
  }
927
956
  const notes = [];
928
957
  const used = new Set();
@@ -953,7 +982,7 @@ function materializeAttachments(record, files) {
953
982
  notes.push(`[File attachment: ${label} could not be saved: ${error instanceof Error ? error.message : String(error)}]`);
954
983
  }
955
984
  }
956
- return notes.join("\n");
985
+ return { note: notes.join("\n"), refs };
957
986
  }
958
987
  function approvalModeFrom(value) {
959
988
  return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
@@ -1971,6 +2000,12 @@ function eventLogPath(sessionId) {
1971
2000
  // eventLog.deriveHistory. `redactSecrets` scrubs credentials at the single flush
1972
2001
  // choke point before anything lands on the synced-to-PWA disk.
1973
2002
  const eventLog = new EventLog(eventLogDir, eventLogPath, redactSecrets);
2003
+ // Global content-addressed store for message attachments (images + files). Unlike
2004
+ // the per-session `.bivy-attachments/` worktree copy (kept so the agent can open
2005
+ // files with its tools), this is durable, session-independent, and re-findable:
2006
+ // the transcript references blobs by hash, and clients rehydrate thumbnails by
2007
+ // hash after a reload or on another device. See src/session/attachment-store.ts.
2008
+ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"));
1974
2009
  // --- Warm session replication (docs/session-replication.md) -----------------
1975
2010
  // A standby's replica repo lives under appDir/replicas/<id>: a self-contained git
1976
2011
  // repo that receives checkpoint bundles and is checked out on promotion. Created
@@ -2228,6 +2263,9 @@ function buildHistoryEvent(opts) {
2228
2263
  prUrl: record?.prUrl ?? opts.prUrl,
2229
2264
  prs: record?.prs ?? opts.prs,
2230
2265
  bivySession: bSess,
2266
+ // Durable attachment references (text→refs), so a client that never sent the
2267
+ // attachment (a reload, or a different device) rehydrates thumbnails by hash.
2268
+ attachmentRefs: opts.sessionId ? eventLog.readAttachments(opts.sessionId) : [],
2231
2269
  };
2232
2270
  }
2233
2271
  // Idempotency for `session.new` keyed by requestId: a client's post-reconnect
@@ -2248,6 +2286,27 @@ const RELAY_COMMANDS = {
2248
2286
  ping(msg, ctx) {
2249
2287
  ctx.reply({ type: "pong", requestId: typeof msg.requestId === "string" ? msg.requestId : undefined });
2250
2288
  },
2289
+ // Fetch a stored attachment's bytes by content hash. The relay client (a phone
2290
+ // not on the LAN) can't reach the GET /api/attachment endpoint, so it fetches
2291
+ // over the encrypted tunnel instead; the relay framing chunks the base64 payload
2292
+ // (the same mechanism that carries large image uploads). Direct/LAN clients use
2293
+ // the HTTP endpoint. Both are authenticated — the relay tunnel by enrollment,
2294
+ // the HTTP route by /api's authMiddleware.
2295
+ "attachment.fetch"(msg, ctx) {
2296
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : undefined;
2297
+ const hash = typeof msg.hash === "string" ? String(msg.hash) : "";
2298
+ if (!isValidAttachmentHash(hash)) {
2299
+ ctx.reply({ type: "attachment.error", requestId, hash, error: "Invalid attachment id" });
2300
+ return;
2301
+ }
2302
+ const bytes = attachmentStore.read(hash);
2303
+ if (!bytes) {
2304
+ ctx.reply({ type: "attachment.error", requestId, hash, error: "Attachment not found" });
2305
+ return;
2306
+ }
2307
+ const meta = attachmentStore.readMeta(hash);
2308
+ ctx.reply({ type: "attachment.data", requestId, hash, mimeType: meta?.mimeType ?? "application/octet-stream", name: meta?.name, data: bytes.toString("base64") });
2309
+ },
2251
2310
  "session.pause"(msg) {
2252
2311
  const record = resolveSession(msg.sessionId);
2253
2312
  if (record)
@@ -3039,7 +3098,7 @@ const RELAY_COMMANDS = {
3039
3098
  },
3040
3099
  async prompt(msg) {
3041
3100
  const text = String(msg.text ?? "").trim();
3042
- const { images, imageNotes, files } = attachmentsFrom(msg.attachments);
3101
+ const { images, imageNotes, imageRefs, files } = attachmentsFrom(msg.attachments);
3043
3102
  if (!text && !images.length && !files.length)
3044
3103
  return;
3045
3104
  // Title/naming can only see what we have before the session exists; the file
@@ -3077,9 +3136,12 @@ const RELAY_COMMANDS = {
3077
3136
  touchSession(record);
3078
3137
  // Now that the session (and its workdir) exists, write file attachments to
3079
3138
  // disk and fold their path notes into the prompt the agent actually sees.
3080
- const fileNote = materializeAttachments(record, files);
3139
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
3081
3140
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
3082
3141
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
3142
+ // Persist durable attachment refs keyed by the exact text the transcript
3143
+ // stores for this user message, so history rehydrates thumbnails by hash.
3144
+ eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
3083
3145
  const agentPrompt = promptForAgent(record, promptText);
3084
3146
  const cmid = typeof msg.clientMessageId === "string" && msg.clientMessageId ? msg.clientMessageId : undefined;
3085
3147
  void dedupePrompt(cmid, async () => {
@@ -6340,7 +6402,10 @@ function maybeRenameWorktreeBranch(record, name) {
6340
6402
  const result = spawnSync("git", ["-C", wt.path, "branch", "-m", wt.branch, next], { encoding: "utf8", timeout: 10_000 });
6341
6403
  if (result.error || result.status !== 0) {
6342
6404
  const detail = String(result.stderr || result.error || "git branch rename failed").trim();
6343
- console.warn(`[branch-rename] could not rename ${wt.branch} to ${next}:`, detail);
6405
+ // Pass the branch names as args, not spliced into the format string: a branch
6406
+ // name containing a %-specifier would otherwise be interpreted by console.warn
6407
+ // (CodeQL js/tainted-format-string).
6408
+ console.warn("[branch-rename] could not rename %s to %s:", wt.branch, next, detail);
6344
6409
  return;
6345
6410
  }
6346
6411
  wt.branch = next;
@@ -9023,13 +9088,31 @@ app.get("/api/repos", async (_req, res) => {
9023
9088
  app.get("/api/repos/branches", async (req, res) => {
9024
9089
  res.json(await listRepoBranches(String(req.query.repo || "").trim()));
9025
9090
  });
9091
+ // Serve a stored attachment's bytes by content hash (direct/LAN clients). Behind
9092
+ // /api's authMiddleware. Content-addressed, so responses are immutably cacheable.
9093
+ // The hash is validated to a 64-char hex before it ever touches a path.
9094
+ app.get("/api/attachment/:hash", (req, res) => {
9095
+ const hash = String(req.params.hash || "");
9096
+ if (!isValidAttachmentHash(hash))
9097
+ return res.status(400).json({ error: "Invalid attachment id" });
9098
+ const bytes = attachmentStore.read(hash);
9099
+ if (!bytes)
9100
+ return res.status(404).json({ error: "Attachment not found" });
9101
+ const meta = attachmentStore.readMeta(hash);
9102
+ res.setHeader("Content-Type", meta?.mimeType || "application/octet-stream");
9103
+ res.setHeader("Content-Length", String(bytes.length));
9104
+ res.setHeader("X-Content-Type-Options", "nosniff");
9105
+ // Content-addressed: the bytes for a hash never change, so cache aggressively.
9106
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
9107
+ res.end(bytes);
9108
+ });
9026
9109
  app.post("/api/session/prompt", async (req, res, next) => {
9027
9110
  try {
9028
9111
  const text = String(req.body?.text ?? "").trim();
9029
9112
  if (text === "/login" || text.startsWith("/login ")) {
9030
9113
  return res.status(400).json({ error: "Use the Login / API tokens dialog from the phone UI. If it is not visible, refresh this page after updating Bivy." });
9031
9114
  }
9032
- const { images, imageNotes, files } = attachmentsFrom(req.body?.attachments);
9115
+ const { images, imageNotes, imageRefs, files } = attachmentsFrom(req.body?.attachments);
9033
9116
  if (!text && !images.length && !files.length)
9034
9117
  return res.status(400).json({ error: "Missing text" });
9035
9118
  // File notes (with on-disk paths) are folded in once the workdir exists; the
@@ -9052,9 +9135,10 @@ app.post("/api/session/prompt", async (req, res, next) => {
9052
9135
  return res.status(409).json({ error: record.tuiRefreshing ? "This session is returning from the terminal. Try again in a moment." : "This session is open in the terminal (TUI). Close the TUI to chat here." });
9053
9136
  }
9054
9137
  const session = record.session;
9055
- const fileNote = materializeAttachments(record, files);
9138
+ const { note: fileNote, refs: fileRefs } = materializeAttachments(record, files);
9056
9139
  const promptText = [text, imageNotes.join("\n"), fileNote].filter(Boolean).join("\n\n") ||
9057
9140
  (images.length ? "Please review the attached image(s)." : files.length ? "Please review the attached file(s)." : "");
9141
+ eventLog.appendAttachments(record.id, promptText, [...imageRefs, ...fileRefs]);
9058
9142
  const agentPrompt = promptForAgent(record, promptText);
9059
9143
  const cmid = typeof req.body?.clientMessageId === "string" && req.body.clientMessageId ? req.body.clientMessageId : undefined;
9060
9144
  markSessionWorking(record, { type: "agent_start" });
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Content-addressed blob store for message attachments (images + files).
5
+ //
6
+ // Attachments used to be fragile and un-refindable: image bytes were passed to
7
+ // the model as vision for a single turn and then dropped entirely (never written
8
+ // anywhere), while file attachments were written only into the session's
9
+ // ephemeral worktree at `<workdir>/.bivy-attachments/`. Nothing survived in a
10
+ // stable, session-independent location, so a reload on another device — or after
11
+ // the client's in-memory attachment cache aged out — showed only a bare
12
+ // "[Image attachment: …]" placeholder.
13
+ //
14
+ // This store fixes that: every attachment is written ONCE to a global folder
15
+ // under `<appDir>/attachments/`, addressed by the SHA-256 of its bytes. Identical
16
+ // bytes dedupe for free (same hash → same path), the location is stable and
17
+ // re-findable, and the transcript references a blob by hash instead of carrying
18
+ // (or losing) the bytes. Paths are two-level sharded (`ab/cd/<hash>`) so a busy
19
+ // node never piles millions of entries into one directory. A small sidecar
20
+ // `<hash>.json` remembers a human name / mime / size for the blob.
21
+ //
22
+ // Out of scope for now (tracked as TODOs): garbage-collecting blobs no transcript
23
+ // references any more, and a global size cap / LRU eviction on the store.
24
+ import crypto from "node:crypto";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ /** A 64-char lowercase-hex SHA-256, the only shape a valid hash can take. */
28
+ const HASH_RE = /^[0-9a-f]{64}$/;
29
+ /** Whether `value` is a well-formed content hash (guards path building). */
30
+ export function isValidAttachmentHash(value) {
31
+ return typeof value === "string" && HASH_RE.test(value);
32
+ }
33
+ /**
34
+ * A global, content-addressed attachment store rooted at a single directory.
35
+ * All methods are best-effort and synchronous, matching the surrounding
36
+ * server code (EventLog, SidecarStore) — attachment persistence must never sink
37
+ * a turn, so a write failure surfaces as a thrown error the caller degrades to a
38
+ * text note rather than a crash.
39
+ */
40
+ export class AttachmentStore {
41
+ dir;
42
+ constructor(dir) {
43
+ this.dir = dir;
44
+ }
45
+ /** `<dir>/ab/cd` for a hash beginning `abcd…`. */
46
+ shardDir(hash) {
47
+ return path.join(this.dir, hash.slice(0, 2), hash.slice(2, 4));
48
+ }
49
+ blobPath(hash) {
50
+ return path.join(this.shardDir(hash), hash);
51
+ }
52
+ metaPath(hash) {
53
+ return path.join(this.shardDir(hash), `${hash}.json`);
54
+ }
55
+ /**
56
+ * Store `bytes` and return a durable reference. Content-addressed, so calling
57
+ * this twice with identical bytes writes the blob once and returns the same
58
+ * hash. The blob write is skipped when the file already exists (dedupe); the
59
+ * sidecar is written only the first time so the earliest name/mime wins.
60
+ */
61
+ put(bytes, opts) {
62
+ const hash = crypto.createHash("sha256").update(bytes).digest("hex");
63
+ const ref = { hash, name: opts.name, mimeType: opts.mimeType, size: bytes.length, kind: opts.kind };
64
+ fs.mkdirSync(this.shardDir(hash), { recursive: true });
65
+ const blob = this.blobPath(hash);
66
+ if (!fs.existsSync(blob))
67
+ fs.writeFileSync(blob, bytes);
68
+ if (!fs.existsSync(this.metaPath(hash))) {
69
+ const meta = { ...ref, createdAt: Date.now() };
70
+ try {
71
+ fs.writeFileSync(this.metaPath(hash), JSON.stringify(meta));
72
+ }
73
+ catch {
74
+ // A missing sidecar only costs us the remembered name/mime — the blob is
75
+ // what matters, so never let a sidecar write failure fail the store.
76
+ }
77
+ }
78
+ return ref;
79
+ }
80
+ /** The blob's metadata, or null if the hash is unknown/malformed. */
81
+ readMeta(hash) {
82
+ if (!isValidAttachmentHash(hash))
83
+ return null;
84
+ try {
85
+ return JSON.parse(fs.readFileSync(this.metaPath(hash), "utf8"));
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /** The on-disk path of a stored blob, or null if absent/malformed. */
92
+ getPath(hash) {
93
+ if (!isValidAttachmentHash(hash))
94
+ return null;
95
+ const blob = this.blobPath(hash);
96
+ return fs.existsSync(blob) ? blob : null;
97
+ }
98
+ /** Read a stored blob's raw bytes, or null if absent/malformed. */
99
+ read(hash) {
100
+ const p = this.getPath(hash);
101
+ if (!p)
102
+ return null;
103
+ try {
104
+ return fs.readFileSync(p);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ }
@@ -40,8 +40,35 @@ function isBase(value) {
40
40
  const record = value;
41
41
  return record.bivyKind === "base" && Array.isArray(record.messages) && typeof record.reset === "boolean";
42
42
  }
43
+ function isAttachment(value) {
44
+ if (!value || typeof value !== "object")
45
+ return false;
46
+ const record = value;
47
+ return record.bivyKind === "attachment" && typeof record.text === "string" && Array.isArray(record.refs);
48
+ }
43
49
  function isRecord(value) {
44
- return isOverlay(value) || isBase(value);
50
+ return isOverlay(value) || isBase(value) || isAttachment(value);
51
+ }
52
+ /**
53
+ * Fold attachment records into a text→refs list: last write wins per text (a
54
+ * resent identical prompt re-keys onto the newest refs), preserving first-seen
55
+ * order. `mergeTranscript` never sees these — the client matches them onto the
56
+ * user messages by their persisted text, exactly as its in-memory attachment
57
+ * cache did, but now sourced durably from the log.
58
+ */
59
+ export function replayAttachments(entries) {
60
+ const byText = new Map();
61
+ for (const entry of entries) {
62
+ if (entry.bivyKind !== "attachment")
63
+ continue;
64
+ if (!entry.text || !entry.refs.length)
65
+ continue;
66
+ // delete+set so a re-keyed text moves to the end (newest), matching the
67
+ // client's rememberAttachments last-wins semantics.
68
+ byText.delete(entry.text);
69
+ byText.set(entry.text, entry.refs);
70
+ }
71
+ return [...byText.entries()];
45
72
  }
46
73
  /**
47
74
  * Fold the intermediate-reasoning entries exactly as the legacy incremental
@@ -243,6 +270,22 @@ export class EventLog {
243
270
  this.baseKeys.set(id, nextKeys);
244
271
  this.enqueue(id, this.syntheticKey(id), record);
245
272
  }
273
+ /**
274
+ * Record the attachment references a user sent with a prompt, keyed by the
275
+ * prompt's persisted text. Id-less (synthetic key) so successive prompts never
276
+ * coalesce. A no-op when there are no refs.
277
+ */
278
+ appendAttachments(id, text, refs) {
279
+ if (!text || !refs.length)
280
+ return;
281
+ this.load(id);
282
+ const record = { bivyKind: "attachment", createdAt: Date.now(), text, refs: refs.map((r) => ({ ...r })) };
283
+ this.enqueue(id, this.syntheticKey(id), record);
284
+ }
285
+ /** Replay the attachment records (disk + pending) into a text→refs list. */
286
+ readAttachments(id) {
287
+ return replayAttachments(this.entries(id));
288
+ }
246
289
  /** Replay the overlay entries (disk + pending) into the flat `extras` list. */
247
290
  read(id) {
248
291
  return replayExtras(this.entries(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.1.0-staging.13",
3
+ "version": "0.1.0-staging.14",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",