@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 +96 -12
- package/dist/session/attachment-store.js +110 -0
- package/dist/session/event-log.js +44 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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