teapot-coding-agent 0.37.71 → 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.
- package/dist/agent/agent.js +79 -15
- package/dist/log/image-store.js +118 -0
- package/dist/server/api.js +23 -2
- package/package.json +1 -1
- package/public/assets/{index-ChytaBSg.js → index-DtLfOFz4.js} +10 -10
- package/public/assets/index-DtLfOFz4.js.map +1 -0
- package/public/assets/{shiki-MyN5tF2P.js → shiki-qHmIppt3.js} +2 -2
- package/public/assets/{shiki-MyN5tF2P.js.map → shiki-qHmIppt3.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-ChytaBSg.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -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(() =>
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
|
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
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
+
}
|
package/dist/server/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
868
|
+
return true;
|
|
848
869
|
return /^https?:\/\//.test(u);
|
|
849
870
|
});
|
|
850
871
|
const text = body.text ?? "";
|