Skip to content

Commit e66d524

Browse files
committed
feat(images) I4: store pasted images locally, and make the meter tell the truth
Local, session-attached. Nothing is uploaded. A screenshot of someone's proprietary code never leaves their machine — which is also the only shape that works for BYOK, where the editor talks to the provider directly and a detour through our infrastructure would add a failure mode and contradict the promise that we are not in the middle. WHY A SIBLING media/ DIRECTORY RATHER THAN INLINE BASE64. Claude Code inlines image bytes in its own JSONL and that works fine there — I measured it: 24 images, all base64, in a 34MB transcript. It does not work here, for a reason specific to this codebase. sessionStore.scanProject does readFileSync + JSON.parse on EVERY session file in a project whenever index.json is missing, malformed, or on an older schema — first run, and after any schema bump. Inlined bytes would make drawing a list of session titles parse every screenshot in every session. Refs keep that scan cheap, keep the transcript greppable, and dedupe the re-paste that follows a failed send. Content-addressed by sha256, so the same screenshot twice is one file. Written tmp+rename, because a crash mid-write must never leave a truncated file under a hash claiming to describe its full contents. Capped at 5MB of bytes — the Claude API allows 10MB of base64, Bedrock and Vertex 5MB, and base64 inflates by 4/3, so 5MB of bytes is what is safe everywhere. REFS ARE NOT PATHS. A ref is read straight out of a session file, which is data on disk a user can edit. isRef pins the shape to 64 hex characters plus a known extension, and read() returns null rather than touching anything else — `../../etc/passwd` is not a ref. THE METER. estimateMsgTokens now counts an image by its real visual cost and excludes its JSON entirely. chars/4 is sound for text and wrong for an image in EITHER shape: inline base64 books about a third of its byte count (a 1MB screenshot read as ~350,000 tokens), while a stored ref is 64 hex characters and reads as ~18 — for the same ~4,800. A test pins that the storage shape does not move the number, which is the point of the design. Bypass-verified, each by reverting the fix: - path traversal accepted as a ref - not content-addressed, so duplicate pastes duplicate files - oversize and unsupported media types accepted - a missing file silently becoming empty text instead of throwing - tmp file left behind by a non-atomic write - the meter counting images by JSON length again; and costing them nothing at all 11 tests in imageStore, 36 suites green.
1 parent c7a46ae commit e66d524

3 files changed

Lines changed: 265 additions & 3 deletions

File tree

extensions/levelcode-ai/agentMemory.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*--------------------------------------------------------------------------------------------*/
77
'use strict';
88

9+
10+
const { imageBlockTokens } = require('./imageCost');
911
/** A "goal boundary": a user message with plain STRING content (a fresh user turn, never a tool_result).
1012
* It is the only splice point that cannot orphan a tool_use/tool_result pair — tool results always sit
1113
* in the message immediately after their tool_use, so any pair is wholly on one side of such a cut. */
@@ -34,10 +36,32 @@ function findCompactionCut(msgs, keepRecent) {
3436
return cut;
3537
}
3638

37-
/** Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter. */
38-
function estimateMsgTokens(msgs) {
39+
/**
40+
* Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter.
41+
*
42+
* Images are counted by their real visual cost, not by their JSON. chars/4 is sound for text and
43+
* wrong for an image in whichever shape it takes: inline base64 books about a third of its byte
44+
* count (a 1MB screenshot reads as ~333,000 tokens, more than most context windows, for something
45+
* that really costs ~4,800), and a stored ref swings the other way — 64 hex characters read as ~18
46+
* tokens for the same ~4,800. Both would make the meter lie about how much room is left.
47+
*
48+
* `modelId` picks the resolution tier; omitting it costs the standard tier, which over-counts
49+
* rather than under-counts. See imageCost.js.
50+
*/
51+
function estimateMsgTokens(msgs, modelId) {
3952
if (!Array.isArray(msgs)) { return 0; }
40-
return Math.round(msgs.reduce((n, m) => n + JSON.stringify(m).length, 0) / 4);
53+
let chars = 0;
54+
let imageTokens = 0;
55+
for (const m of msgs) {
56+
if (!m) { continue; }
57+
if (!Array.isArray(m.content)) { chars += JSON.stringify(m).length; continue; }
58+
chars += 24; // role + envelope, roughly what the object costs around its blocks
59+
for (const b of m.content) {
60+
if (b && b.type === 'image') { imageTokens += imageBlockTokens(b, modelId); }
61+
else { chars += JSON.stringify(b).length; }
62+
}
63+
}
64+
return Math.round(chars / 4) + imageTokens;
4165
}
4266

4367
module.exports = { isGoalBoundary, findCompactionCut, estimateMsgTokens };
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Pasted images on disk — content-addressed, beside the session that used them.
3+
*
4+
* LOCAL, SESSION-ATTACHED. Nothing is uploaded. A screenshot of someone's proprietary code
5+
* never leaves their machine, which is also the only shape that works for BYOK, where the
6+
* editor talks to the provider directly and a detour through our infrastructure would both add
7+
* a failure mode and contradict the promise that we are not in the middle.
8+
*
9+
* WHY A SIBLING DIRECTORY RATHER THAN INLINE BASE64. Claude Code inlines image bytes in its
10+
* own JSONL transcript and that works fine there. It does not work here, and the reason is
11+
* specific to this codebase: sessionStore.scanProject readFileSync + JSON.parses EVERY session
12+
* file in a project whenever index.json is missing, malformed, or on an older schema — which
13+
* happens on first run and after any schema bump. Inlined bytes would make drawing a list of
14+
* session titles parse every screenshot in every session. Refs keep that scan cheap, keep the
15+
* transcript greppable, and dedupe the re-paste that follows a failed send.
16+
*--------------------------------------------------------------------------------------------*/
17+
// @ts-check
18+
'use strict';
19+
20+
const fs = require('fs');
21+
const path = require('path');
22+
const crypto = require('crypto');
23+
24+
/** Claude accepts exactly these. Anything else is refused before it reaches a provider. */
25+
const MEDIA_EXT = {
26+
'image/png': 'png',
27+
'image/jpeg': 'jpg',
28+
'image/gif': 'gif',
29+
'image/webp': 'webp'
30+
};
31+
32+
/**
33+
* Per-image ceiling. The Claude API's own limit is 10MB of base64 (5MB on Bedrock and Vertex),
34+
* and base64 inflates by 4/3 — so 5MB of BYTES is the largest thing that is safe everywhere.
35+
* Normalization should keep real pastes far under this; the cap is for the pathological file.
36+
*/
37+
const MAX_BYTES = 5 * 1024 * 1024;
38+
39+
function mediaDir(root, slug) { return path.join(root, slug, 'media'); }
40+
function refPath(root, slug, ref) { return path.join(mediaDir(root, slug), ref); }
41+
42+
/** true for a ref this module could have produced — 64 hex chars, a known extension, no path parts. */
43+
function isRef(ref) {
44+
return typeof ref === 'string' && /^[0-9a-f]{64}\.(png|jpg|gif|webp)$/.test(ref);
45+
}
46+
47+
/**
48+
* Store bytes and return the ref that identifies them.
49+
*
50+
* Content-addressed: the same screenshot pasted twice is one file, which is exactly what happens
51+
* when someone re-pastes after a send fails. Writing is skipped when the file already exists, so
52+
* a duplicate paste costs a hash and a stat.
53+
*/
54+
function put(root, slug, base64, mediaType) {
55+
const ext = MEDIA_EXT[mediaType];
56+
if (!ext) { throw new Error('imageStore: unsupported media type: ' + String(mediaType)); }
57+
const buf = Buffer.from(String(base64 || ''), 'base64');
58+
if (!buf.length) { throw new Error('imageStore: empty image'); }
59+
if (buf.length > MAX_BYTES) {
60+
throw new Error('imageStore: image is ' + Math.round(buf.length / 1024) + 'KB, over the '
61+
+ Math.round(MAX_BYTES / 1024) + 'KB limit');
62+
}
63+
const ref = crypto.createHash('sha256').update(buf).digest('hex') + '.' + ext;
64+
const dest = refPath(root, slug, ref);
65+
if (!fs.existsSync(dest)) {
66+
fs.mkdirSync(mediaDir(root, slug), { recursive: true });
67+
// tmp + rename: a crash mid-write must never leave a truncated file under a hash that
68+
// claims to describe its full contents.
69+
const tmp = dest + '.' + process.pid + '.tmp';
70+
fs.writeFileSync(tmp, buf);
71+
fs.renameSync(tmp, dest);
72+
}
73+
return { ref, bytes: buf.length };
74+
}
75+
76+
/** Read bytes back as base64 for a provider request. Returns null when the file is gone. */
77+
function read(root, slug, ref) {
78+
if (!isRef(ref)) { return null; }
79+
try { return fs.readFileSync(refPath(root, slug, ref)).toString('base64'); }
80+
catch { return null; }
81+
}
82+
83+
/** The media type a ref implies, from its extension. */
84+
function mediaTypeOf(ref) {
85+
if (!isRef(ref)) { return null; }
86+
const ext = ref.slice(ref.lastIndexOf('.') + 1);
87+
return Object.keys(MEDIA_EXT).find((k) => MEDIA_EXT[k] === ext) || null;
88+
}
89+
90+
/**
91+
* A stored `{type:'image', ref, …}` block → the Anthropic wire block, bytes and all.
92+
*
93+
* Called only when a request is being built, and the result is never retained: the conversation,
94+
* the session log and the token meter all keep the ref. Throws when the file is missing, because
95+
* a request that silently drops its subject is the failure this whole feature exists to avoid.
96+
*/
97+
function materialize(root, slug, block) {
98+
if (!block || block.type !== 'image') { return block; }
99+
if (block.source) { return block; } // already materialized (or an inline block from elsewhere)
100+
const data = read(root, slug, block.ref);
101+
if (!data) { throw new Error('imageStore: attached image is missing from disk: ' + String(block.ref)); }
102+
return { type: 'image', source: { type: 'base64', media_type: mediaTypeOf(block.ref), data } };
103+
}
104+
105+
/** Refs still referenced by these messages — the keep-set for a sweep. */
106+
function refsIn(msgs) {
107+
const out = new Set();
108+
for (const m of (Array.isArray(msgs) ? msgs : [])) {
109+
for (const b of (Array.isArray(m && m.content) ? m.content : [])) {
110+
if (b && b.type === 'image' && isRef(b.ref)) { out.add(b.ref); }
111+
}
112+
}
113+
return out;
114+
}
115+
116+
module.exports = { MEDIA_EXT, MAX_BYTES, mediaDir, refPath, isRef, put, read, mediaTypeOf, materialize, refsIn };
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Local image store + the meter that counts what it holds — run: node test/imageStore.test.js
3+
*--------------------------------------------------------------------------------------------*/
4+
// @ts-check
5+
'use strict';
6+
7+
const assert = require('assert');
8+
const fs = require('fs');
9+
const os = require('os');
10+
const path = require('path');
11+
const S = require('../imageStore');
12+
const { estimateMsgTokens } = require('../agentMemory');
13+
14+
let n = 0;
15+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
16+
17+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-img-'));
18+
const slug = 'proj';
19+
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex').toString('base64');
20+
21+
test('ROUND TRIP: bytes go in, the same bytes come back', () => {
22+
const { ref, bytes } = S.put(root, slug, png, 'image/png');
23+
assert.ok(S.isRef(ref), 'ref should be sha256 + extension: ' + ref);
24+
assert.strictEqual(bytes, Buffer.from(png, 'base64').length);
25+
assert.strictEqual(S.read(root, slug, ref), png);
26+
assert.strictEqual(S.mediaTypeOf(ref), 'image/png');
27+
});
28+
29+
test('CONTENT ADDRESSED: the same screenshot twice is one file', () => {
30+
// Exactly what happens when someone re-pastes after a send fails.
31+
const a = S.put(root, slug, png, 'image/png');
32+
const b = S.put(root, slug, png, 'image/png');
33+
assert.strictEqual(a.ref, b.ref);
34+
const files = fs.readdirSync(S.mediaDir(root, slug)).filter((f) => f.endsWith('.png'));
35+
assert.strictEqual(files.length, 1, 'a duplicate paste must not write a second file');
36+
});
37+
38+
test('CONTENT ADDRESSED: different bytes get different refs', () => {
39+
const other = Buffer.from('ffd8ffe000104a464946', 'hex').toString('base64');
40+
assert.notStrictEqual(S.put(root, slug, png, 'image/png').ref,
41+
S.put(root, slug, other, 'image/jpeg').ref);
42+
});
43+
44+
test('NO TMP LEFT BEHIND: a completed write leaves only the final file', () => {
45+
assert.ok(!fs.readdirSync(S.mediaDir(root, slug)).some((f) => f.includes('.tmp')),
46+
'tmp+rename must not leave a .tmp file behind');
47+
});
48+
49+
test('REFUSED: unsupported media type, empty bytes, and oversize', () => {
50+
assert.throws(() => S.put(root, slug, png, 'image/tiff'), /unsupported media type/);
51+
assert.throws(() => S.put(root, slug, png, 'image/svg+xml'), /unsupported media type/);
52+
assert.throws(() => S.put(root, slug, '', 'image/png'), /empty image/);
53+
const huge = Buffer.alloc(S.MAX_BYTES + 1).toString('base64');
54+
assert.throws(() => S.put(root, slug, huge, 'image/png'), /over the/);
55+
});
56+
57+
test('REFS ARE NOT PATHS: traversal and junk are rejected, not read', () => {
58+
// read() takes a ref straight from a session file, which is data on disk that a user could edit.
59+
for (const bad of ['../../etc/passwd', '../secrets.png', 'a/b.png', 'notahash.png',
60+
'a'.repeat(64) + '.exe', 'a'.repeat(63) + '.png', '', null, undefined]) {
61+
assert.strictEqual(S.isRef(bad), false, 'should not look like a ref: ' + String(bad));
62+
assert.strictEqual(S.read(root, slug, bad), null, 'must not read: ' + String(bad));
63+
assert.strictEqual(S.mediaTypeOf(bad), null);
64+
}
65+
});
66+
67+
test('MATERIALIZE: a ref becomes a wire block only when a request is built', () => {
68+
const { ref } = S.put(root, slug, png, 'image/png');
69+
const out = S.materialize(root, slug, { type: 'image', ref, w: 100, h: 50 });
70+
assert.deepStrictEqual(out, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } });
71+
// already-materialized blocks pass through untouched
72+
const inline = { type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } };
73+
assert.strictEqual(S.materialize(root, slug, inline), inline);
74+
assert.deepStrictEqual(S.materialize(root, slug, { type: 'text', text: 'hi' }), { type: 'text', text: 'hi' });
75+
});
76+
77+
test('MATERIALIZE: a missing file throws rather than sending a request without its subject', () => {
78+
assert.throws(
79+
() => S.materialize(root, slug, { type: 'image', ref: 'b'.repeat(64) + '.png' }),
80+
/missing from disk/,
81+
'a silently dropped image is the exact failure this feature exists to avoid'
82+
);
83+
});
84+
85+
test('REFS IN: the keep-set sees every attached image and nothing else', () => {
86+
const r1 = 'a'.repeat(64) + '.png', r2 = 'c'.repeat(64) + '.webp';
87+
const got = S.refsIn([
88+
{ role: 'user', content: [{ type: 'image', ref: r1 }, { type: 'text', text: 'x' }] },
89+
{ role: 'user', content: 'a plain string turn' },
90+
{ role: 'user', content: [{ type: 'image', ref: r2 }, { type: 'image', ref: '../evil' }] }
91+
]);
92+
assert.deepStrictEqual([...got].sort(), [r1, r2].sort());
93+
});
94+
95+
test('METER: the storage shape does not change the number', () => {
96+
// This is the point of the whole design. An image costs what it costs; whether the bytes are
97+
// inline or on disk behind a ref must not move the meter.
98+
const big = 'A'.repeat(1_400_000);
99+
const inline = [{ role: 'user', content: [
100+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: big } },
101+
{ type: 'text', text: 'why?' }] }];
102+
const ref = [{ role: 'user', content: [
103+
{ type: 'image', ref: 'a'.repeat(64) + '.png', w: 3840, h: 2160 },
104+
{ type: 'text', text: 'why?' }] }];
105+
106+
const a = estimateMsgTokens(inline, 'claude-opus-5');
107+
const b = estimateMsgTokens(ref, 'claude-opus-5');
108+
assert.strictEqual(a, b, 'inline and ref must cost the same');
109+
assert.ok(a < 6000, 'a 1MB image must not book six figures of tokens — got ' + a);
110+
assert.ok(a > 4000, 'nor may it be under-counted as a short string — got ' + a);
111+
assert.ok(estimateMsgTokens(ref, 'gpt-4o') < b, 'the standard tier costs less than high-res');
112+
});
113+
114+
test('METER: text-only messages are unchanged by any of this', () => {
115+
const msgs = [{ role: 'user', content: 'hello there' }, { role: 'assistant', content: 'hi' }];
116+
assert.strictEqual(estimateMsgTokens(msgs), Math.round(
117+
msgs.reduce((n2, m) => n2 + JSON.stringify(m).length, 0) / 4),
118+
'the old heuristic must still hold exactly for text');
119+
});
120+
121+
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
122+
console.log('\nimageStore: ' + n + ' tests passed.');

0 commit comments

Comments
 (0)