teapot-coding-agent 0.37.72 → 0.37.73

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.
@@ -1,6 +1,7 @@
1
1
  import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { EventLog, readEvents } from "../log/events.js";
4
+ import { storeImage, resolveImages } from "../log/image-store.js";
4
5
  import { chat, chatStream } from "./llm.js";
5
6
  import { auditPrompt, parseAuditVerdict } from "./goal-audit.js";
6
7
  import { executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, isRequestNodeLimit, hasRunningBgShells, hasRunningBgShellsFor, killBgShells } from "./tools.js";
@@ -569,6 +570,15 @@ export class Agent {
569
570
  get goalFile() {
570
571
  return path.join(this.opts.sessionDir, "goal.md");
571
572
  }
573
+ /**
574
+ * Image bytes live in `blobs/` INSIDE this session, beside `chat.jsonl`.
575
+ *
576
+ * So `rm -r` on a session reclaims everything it ever read — which is the whole point when one
577
+ * session's log is what grew to hundreds of megabytes.
578
+ */
579
+ get imagesDir() {
580
+ return path.join(this.opts.sessionDir, "blobs");
581
+ }
572
582
  get memoryFile() {
573
583
  const perSession = path.join(this.opts.sessionDir, "memory.md");
574
584
  const parent = path.dirname(this.opts.sessionDir);
@@ -681,7 +691,7 @@ export class Agent {
681
691
  if (!lineage.length)
682
692
  return;
683
693
  const last = lineage[lineage.length - 1];
684
- const msgs = rebuildMessagesFrom(lineage);
694
+ const msgs = await rebuildMessagesFrom(lineage, this.imagesDir);
685
695
  for (const e of own) {
686
696
  if (this.sessionTitle === undefined && e.type === "prompt" && e.data?.source === "user") {
687
697
  this.sessionTitle = sessionTitleFrom(e.data?.text) ?? undefined;
@@ -795,7 +805,7 @@ export class Agent {
795
805
  throw new Error("prompt is not on this agent's current lineage");
796
806
  const kept = lineage.slice(0, tIdx);
797
807
  const dropped = lineage.slice(tIdx);
798
- const msgs = rebuildMessagesFrom(kept);
808
+ const msgs = await rebuildMessagesFrom(kept, this.imagesDir);
799
809
  msgs.push({ role: "user", content: text });
800
810
  const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
801
811
  await this.quiesceForHistoryEdit();
@@ -1070,12 +1080,25 @@ export class Agent {
1070
1080
  const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
1071
1081
  this.pendingPrompts.push(entry);
1072
1082
  void this.ensureReady()
1073
- .then(() => this.log.append("prompt", this.currentSession, this.currentBranch, {
1074
- source,
1075
- text,
1076
- ...(images?.length ? { images: images.map((i) => ({ url: i.url, name: i.name })) } : {}),
1077
- ...(promptId ? { promptId } : {}),
1078
- }))
1083
+ .then(async () => {
1084
+ /**
1085
+ * A user's attachment is stored the same way a tool's image is: bytes in `blobs/`, a reference
1086
+ * in the log. It used to keep the raw `data:` URL, which put up to 12MB of base64 into
1087
+ * `chat.jsonl` — the same inflation the tool path just stopped doing.
1088
+ */
1089
+ const stored = images?.length
1090
+ ? await Promise.all(images.map(async (i) => ({
1091
+ url: await (async () => (await storeImage(this.imagesDir, i.url)).ref)(),
1092
+ name: i.name,
1093
+ })))
1094
+ : undefined;
1095
+ return this.log.append("prompt", this.currentSession, this.currentBranch, {
1096
+ source,
1097
+ text,
1098
+ ...(stored?.length ? { images: stored } : {}),
1099
+ ...(promptId ? { promptId } : {}),
1100
+ });
1101
+ })
1079
1102
  .then(() => {
1080
1103
  if (source === "user")
1081
1104
  this.opts.onFirstUserPrompt?.(this.currentSession);
@@ -1843,12 +1866,25 @@ export class Agent {
1843
1866
  this.stats.toolCalls++;
1844
1867
  this.consecutiveIdleRounds = 0;
1845
1868
  const capped = result.result.slice(0, 8000);
1869
+ // data URL → `blobs/<hash>` (idempotent, so re-reading the same picture costs one file)
1870
+ const imageRef = async (u) => (await storeImage(this.imagesDir, u)).ref;
1846
1871
  await this.log.append("tool_result", this.currentSession, this.currentBranch, {
1847
1872
  callId: call.id,
1848
1873
  name: call.function.name,
1849
1874
  ok: result.ok,
1850
1875
  durationMs: Date.now() - t0,
1851
1876
  result: capped,
1877
+ /**
1878
+ * The IMAGE itself belongs in the log, or a restored session cannot rebuild the request.
1879
+ *
1880
+ * MEASURED (v0.37.69, `github-bug-research-1f48715f` seq 11280): the `tool_result` row held
1881
+ * only the TEXT — "the image itself is attached to this message, so you are looking at it" —
1882
+ * and the picture existed nowhere. On restore the model reads that sentence, finds no image,
1883
+ * and has to invent what it saw.
1884
+ *
1885
+ * It is stored as a `blobs/<sha256>` reference, not inline: see `src/log/blobs.ts`.
1886
+ */
1887
+ ...(result.image ? { images: [{ url: await imageRef(result.image.url), name: result.image.name }] } : {}),
1852
1888
  });
1853
1889
  /**
1854
1890
  * A tool may hand the model an IMAGE (read_file on a picture).
@@ -2837,7 +2873,7 @@ function lineageOf(events) {
2837
2873
  lineage.shift();
2838
2874
  return lineage;
2839
2875
  }
2840
- function rebuildMessagesFrom(list) {
2876
+ async function rebuildMessagesFrom(list, imagesDir) {
2841
2877
  const msgs = [];
2842
2878
  const META_TOOLS = new Set([
2843
2879
  "finish", "report_progress", "set_goal", "get_goal",
@@ -2877,7 +2913,10 @@ function rebuildMessagesFrom(list) {
2877
2913
  const d = e.data;
2878
2914
  if (e.type === "prompt" && typeof d.text === "string") {
2879
2915
  const text = d.text;
2880
- const imgs = Array.isArray(d.images) ? d.images : [];
2916
+ const imgsRaw = Array.isArray(d.images) ? d.images : [];
2917
+ const imgs = imgsRaw.length
2918
+ ? (await resolveImages(imagesDir, imgsRaw.map((i) => i.url))).map((url) => ({ url }))
2919
+ : [];
2881
2920
  const mk = () => imgs.length
2882
2921
  ? {
2883
2922
  role: "user",
@@ -2929,11 +2968,36 @@ function rebuildMessagesFrom(list) {
2929
2968
  }
2930
2969
  }
2931
2970
  else if (e.type === "tool_result") {
2932
- msgs.push({
2933
- role: "tool",
2934
- tool_call_id: String(d.callId ?? ""),
2935
- content: `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`,
2936
- });
2971
+ {
2972
+ const text = `${d.ok === false ? "(failed) " : ""}${typeof d.result === "string" ? d.result : ""}`;
2973
+ const toolImgsRaw = Array.isArray(d.images) ? d.images : [];
2974
+ // `blobs/<hash>` → a data URL again, because only the wire needs the bytes inline
2975
+ const toolImgs = toolImgsRaw.length
2976
+ ? (await resolveImages(imagesDir, toolImgsRaw.map((i) => i.url))).map((url) => ({ url }))
2977
+ : [];
2978
+ /**
2979
+ * Rebuild the image part on restore, exactly as it was sent live.
2980
+ *
2981
+ * Without this a restored session silently degrades: the text still claims the image is
2982
+ * attached, so the model is not merely missing data, it is told something false about what it
2983
+ * can see.
2984
+ */
2985
+ msgs.push(toolImgs.length
2986
+ ? {
2987
+ role: "tool",
2988
+ tool_call_id: String(d.callId ?? ""),
2989
+ content: text,
2990
+ content_parts: [
2991
+ { type: "text", text },
2992
+ ...toolImgs.map((i) => ({ type: "image_url", image_url: { url: i.url } })),
2993
+ ],
2994
+ }
2995
+ : {
2996
+ role: "tool",
2997
+ tool_call_id: String(d.callId ?? ""),
2998
+ content: text,
2999
+ });
3000
+ }
2937
3001
  openCalls.delete(String(d.callId ?? ""));
2938
3002
  flushUsers();
2939
3003
  }
@@ -0,0 +1,118 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * Image bytes live in a `blobs/` directory INSIDE the session, beside `chat.jsonl`.
6
+ *
7
+ * Three reasons, in order of how much they matter:
8
+ *
9
+ * 1. **Deleting a session must delete its images.** The operator's data dir reached 533MB of logs with
10
+ * a 578KB JPEG per read; being able to `rm -r` one session and reclaim every byte is the point. A
11
+ * shared store would leave orphans that no session owns and nobody dares delete.
12
+ * 2. base64 is 4/3 the size and inflates `chat.jsonl`, which is re-read on every replay. MEASURED: the
13
+ * median tool result is 229 bytes, so one picture would dwarf a thousand ordinary rows.
14
+ * 3. JSON is a text format. Megabytes of encoded binary inside it make every reader of the log — replay,
15
+ * grep, the timeline, a person with `less` — pay for data almost none of them want.
16
+ *
17
+ * NOT `assets/`: that is the WALLPAPER store, with its own resize cache, URL shape and lifetime (a
18
+ * wallpaper outlives every session). Two lifecycles in one directory is how "safe to delete" stops
19
+ * being true.
20
+ *
21
+ * Names are `sha256` of the content, so a write is idempotent and two different images cannot share a
22
+ * filename.
23
+ */
24
+ const BLOBS_DIR = "blobs";
25
+ /**
26
+ * Write a data URL to `images/` and return the reference to store in the log — `assets/<sha256>.<ext>`.
27
+ *
28
+ * Anything already addressed by a URL (`http(s):`, or a reference we wrote earlier) passes through
29
+ * untouched: it is not ours to copy, and re-encoding would lose the original bytes.
30
+ */
31
+ export async function storeImage(imagesDir, dataUrl) {
32
+ if (!dataUrl.startsWith("data:"))
33
+ return { ref: dataUrl, bytes: 0 };
34
+ const comma = dataUrl.indexOf(",");
35
+ const header = dataUrl.slice(5, comma);
36
+ if (comma < 0 || !header.includes("base64"))
37
+ throw new Error("image data URL is not base64");
38
+ const mime = header.split(";")[0] || "application/octet-stream";
39
+ const buf = Buffer.from(dataUrl.slice(comma + 1), "base64");
40
+ const name = `${crypto.createHash("sha256").update(buf).digest("hex")}${extFor(mime)}`;
41
+ await fs.mkdir(imagesDir, { recursive: true });
42
+ const dest = path.join(imagesDir, name);
43
+ // content-addressed, so an existing file already holds these bytes
44
+ try {
45
+ await fs.writeFile(dest, buf, { flag: "wx" });
46
+ }
47
+ catch (err) {
48
+ if (err.code !== "EEXIST")
49
+ throw err;
50
+ }
51
+ return { ref: `${BLOBS_DIR}/${name}`, bytes: buf.length };
52
+ }
53
+ /**
54
+ * Resolve a stored reference back to something the model can receive.
55
+ *
56
+ * `assets/<name>` is read from disk and encoded as a data URL at REQUEST time — the only point where
57
+ * the bytes have to be inline. A plain `data:` or `http(s):` value (an older session) is returned
58
+ * as-is, so logs written before this existed keep working.
59
+ */
60
+ export async function resolveImage(imagesDir, ref) {
61
+ if (!ref.startsWith(`${BLOBS_DIR}/`))
62
+ return ref;
63
+ const name = ref.slice(BLOBS_DIR.length + 1);
64
+ // a stored name is a basename; refuse anything that tries to climb out of the directory
65
+ if (!name || name !== path.basename(name))
66
+ return null;
67
+ try {
68
+ const buf = await fs.readFile(path.join(imagesDir, name));
69
+ return `data:${mimeForExt(path.extname(name))};base64,${buf.toString("base64")}`;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Resolve every image of one stored event, in order, dropping any whose bytes are gone.
77
+ *
78
+ * A missing file is DROPPED rather than faked: the text beside it stays, but an unreadable reference
79
+ * must not become an invented picture or a request the API rejects.
80
+ */
81
+ export async function resolveImages(imagesDir, refs) {
82
+ const out = [];
83
+ for (const r of refs) {
84
+ const resolved = await resolveImage(imagesDir, r);
85
+ if (resolved)
86
+ out.push(resolved);
87
+ }
88
+ return out;
89
+ }
90
+ function extFor(mime) {
91
+ switch (mime) {
92
+ case "image/png":
93
+ return ".png";
94
+ case "image/jpeg":
95
+ return ".jpg";
96
+ case "image/gif":
97
+ return ".gif";
98
+ case "image/webp":
99
+ return ".webp";
100
+ default:
101
+ return ".bin";
102
+ }
103
+ }
104
+ function mimeForExt(ext) {
105
+ switch (ext) {
106
+ case ".png":
107
+ return "image/png";
108
+ case ".jpg":
109
+ case ".jpeg":
110
+ return "image/jpeg";
111
+ case ".gif":
112
+ return "image/gif";
113
+ case ".webp":
114
+ return "image/webp";
115
+ default:
116
+ return "application/octet-stream";
117
+ }
118
+ }
@@ -830,6 +830,8 @@ export function buildApp(master) {
830
830
  await a.load();
831
831
  return c.json({ ok: true, agent: a.snapshot() });
832
832
  });
833
+ // 64MB encoded (~48MB of image); a vision model rejects anything near this anyway
834
+ const MAX_PROMPT_IMAGE_CHARS = 64 * 1024 * 1024;
833
835
  app.post("/api/agents/:id/prompt", async (c) => {
834
836
  const a = master.agents.get(c.req.param("id"));
835
837
  if (!a)
@@ -841,10 +843,29 @@ export function buildApp(master) {
841
843
  return c.json({ error: "invalid JSON" }, 400);
842
844
  if (!body.text?.trim() && !body.images?.length)
843
845
  return c.json({ error: "text or images required" }, 400);
844
- const images = (body.images ?? []).filter((im) => {
846
+ /**
847
+ * Accept any image the model could take; the cap is a SANITY bound, not a storage limit.
848
+ *
849
+ * The bytes now go to `blobs/` beside `chat.jsonl` instead of into it, so the old "keep the log
850
+ * small" reason for a tight cutoff is gone. What remains is refusing something absurd before it is
851
+ * base64-decoded — and reporting WHICH image was refused rather than silently dropping it, because
852
+ * a prompt that quietly loses its attachment is indistinguishable from a model that ignored it.
853
+ */
854
+ const offered = body.images ?? [];
855
+ const tooBig = offered.find((im) => {
856
+ const u = String(im?.url ?? "");
857
+ return u.startsWith("data:image/") && u.length > MAX_PROMPT_IMAGE_CHARS;
858
+ });
859
+ if (tooBig) {
860
+ return c.json({
861
+ error: `${tooBig.name || "an image"} is too large (${Math.round(String(tooBig.url).length / 1024 / 1024)}MB ` +
862
+ `encoded, limit ${MAX_PROMPT_IMAGE_CHARS / 1024 / 1024}MB)`,
863
+ }, 413);
864
+ }
865
+ const images = offered.filter((im) => {
845
866
  const u = String(im?.url ?? "");
846
867
  if (u.startsWith("data:image/"))
847
- return u.length < 12_000_000;
868
+ return true;
848
869
  return /^https?:\/\//.test(u);
849
870
  });
850
871
  const text = body.text ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.72",
3
+ "version": "0.37.73",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",