Skip to content

Commit 59bdfd0

Browse files
committed
fix(images): nine review findings from #90, and the multi-image gap
Eleven comments. Two (the agent branch dropping images, and the generic .x handler stealing the image ×) were already fixed in commits later than the reviewed diff. The other nine were all real. THE METER WAS UNDERCOUNTING. estimateMsgTokens takes a modelId to pick the resolution tier — 4,784 visual tokens on high-res against 1,568 on standard — and BOTH compaction call sites omitted it. So every screenshot was costed at a third of what a Claude 4.7+ model is charged, by the very module whose contract says it must never under-count. meterModel() resolves it the same way currentContextLimit does, gateway and BYOK alike. THE SEND PATH HAD TWO HOLES. Normalization is async and a placeholder chip carries no bytes, so sending mid-decode posted an attachment with undefined media_type and base64 — refused host-side, and the image vanished from a message the user watched themselves attach. doSend now waits on in-flight work and refuses a surviving placeholder outright. Separately, the model was only checked at ATTACH time: switch models between attaching and sending and the images went to a model that cannot read them. Re-checked at send, refusing without discarding anything typed or attached. NEW CHAT INHERITED THE TRAY. The reset handler cleared the log and the context chips and never touched pendingImages, so the next conversation opened holding the last one's screenshots and stored them under the new session. THE NOT-ATTACHED COUNT COULD GO NEGATIVE: it subtracted the whole tray from the batch size. Four attached, cap five, drop two, and it claimed "-3 not attached". Counted from the position in the batch now. THE VISION GATE IGNORED THE PROVIDER. The registry enumerates vision providers — anthropic, openai, openrouter declare it; ollama and `custom` deliberately do not — and the gate read only the model id, so `custom` (an arbitrary user-supplied endpoint) was handed images whenever the model NAME looked right. Both halves must agree now, as supportsToolsForModel already required. I1 FIXED ONE LOOP AND LEFT THE OTHER. The assistant branch still dropped unknown blocks silently — the same bug, one branch over. It throws now; `thinking` is an explicit, documented drop rather than a fall-through, because an OpenAI-shaped request has nowhere to put it. "DELETED WITH THEM" WAS NOT TRUE. Sessions are append-only and trash() only writes a lifecycle event, so nothing ever removed a stored image — and refsIn, which I wrote for exactly this, had no caller. There is a real sweep now, run on session seal, with an AGE FLOOR: a normal chat writes media whose refs are never persisted anywhere, so unreferenced-means-delete would break the open conversation. The comment says what the code does now. AND THE FEATURE GAP: several images are introduced by name ("Image 1:", "Image 2:") per the vision guidance, so a question — and every follow-up turn — can refer to them. Only when there is more than one; labelling a lone screenshot is noise. TWO OF MY OWN GUARDS PASSED ON BROKEN CODE while verifying this, both from matching text rather than behaviour: a commented-out resetImages() still satisfied /resetImages\(\)/, and `inflightImages.size` appears twice in doSend so disabling the gate left the name present. Fixed by stripping comments before matching, and by asserting the gate's POSITION relative to the send plus the presence of the await. That is the fourth instance of this family this session. Bypass-verified, each by reverting: the undercount; the negative count; the wait deleted and the await removed; no model re-check; New Chat inheriting; custom taking images; the assistant loop silent again; the sweep removed; the labels dropped. 30 tests in imageAttach (was 21), 38 suites green.
1 parent e35472f commit 59bdfd0

7 files changed

Lines changed: 297 additions & 19 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ function contextLimitFor(providerId, model) {
148148
}
149149

150150
/** The active model's context window (tokens) — drives the chat context-usage meter. */
151+
/**
152+
* The model the meter should cost against — the same resolution currentContextLimit uses.
153+
*
154+
* estimateMsgTokens needs it to pick an image's resolution tier: the high-res tier is 4,784 visual
155+
* tokens against the standard tier's 1,568, so calling it without a model silently costs every
156+
* screenshot at a THIRD of what a Claude 4.7+ model is actually charged.
157+
*/
158+
function meterModel() {
159+
const cfg = aiConfig();
160+
if (providerMode() === 'gateway' && cloudSignedIn) { return capsModel(gatewayModel()); }
161+
return activeModel(cfg, currentProviderId());
162+
}
163+
151164
function currentContextLimit() {
152165
const cfg = aiConfig();
153166
if (providerMode() === 'gateway' && cloudSignedIn) {
@@ -1109,7 +1122,17 @@ function sealLiveSession(why) {
11091122
if (!m) { return; }
11101123
const sealedId = m.liveId();
11111124
m.seal('done');
1112-
if (sealedId) { enrichMemoryAsync(sealedId); } // outcome + fact promotion, off the critical path
1125+
if (sealedId) { enrichMemoryAsync(sealedId); }
1126+
// Sealing is the natural moment to take out the rubbish: rare, already off the hot path, and
1127+
// the point at which a conversation's refs have just been written. Nothing else deletes media
1128+
// — sessions are append-only and trash() only marks a lifecycle — so without this the folder
1129+
// grows for the life of the project.
1130+
setTimeout(() => {
1131+
try {
1132+
const swept = m.sweepMedia();
1133+
if (swept.removed) { dbg('media.swept', { removed: swept.removed, kb: Math.round(swept.bytes / 1024) }); }
1134+
} catch (e) { dbg('media.sweep.error', { msg: String((e && e.message) || e) }); }
1135+
}, 0); // outcome + fact promotion, off the critical path
11131136
dbg('sessions.sealed', { why, id: sealedId });
11141137
} catch (e) {
11151138
dbg('sessions.seal.error', { why, msg: String((e && e.message) || e) });
@@ -1544,7 +1567,7 @@ async function compactAgentMemory() {
15441567
if (abort) { return { ok: false, reason: 'running' }; }
15451568
const msgs = agentMessages;
15461569
const KEEP_RECENT = 8;
1547-
const beforeMsgTokens = estimateMsgTokens(msgs);
1570+
const beforeMsgTokens = estimateMsgTokens(msgs, meterModel());
15481571
if (msgs.length <= KEEP_RECENT + 2) { return { ok: false, reason: 'small' }; }
15491572
const cut = findCompactionCut(msgs, KEEP_RECENT);
15501573
if (cut < 0) { return { ok: false, reason: 'noboundary' }; }
@@ -1591,7 +1614,7 @@ async function compactAgentMemory() {
15911614
// transcript for them (it guards on indexOf), so keeping them would offer a half-working rollback.
15921615
for (let i = checkpoints.length - 1; i >= 0; i--) { if (msgs.indexOf(checkpoints[i].goalMsg) < 0) { checkpoints.splice(i, 1); } }
15931616

1594-
const afterMsgTokens = estimateMsgTokens(msgs);
1617+
const afterMsgTokens = estimateMsgTokens(msgs, meterModel());
15951618
dbg('compact.done', { cut, beforeMsgTokens, afterMsgTokens, msgs: msgs.length });
15961619
return { ok: true, beforeMsgTokens, afterMsgTokens };
15971620
}
@@ -1620,7 +1643,7 @@ async function agentFlow(text, imageBlocks) {
16201643
// Agent mode is the DEFAULT, so this is the path most pasted screenshots take. Blocks only when
16211644
// there IS an image — a text-only goal stays a plain string so cached prefixes keep their bytes.
16221645
const goalMsg = (imageBlocks && imageBlocks.length)
1623-
? { role: 'user', content: text ? [...imageBlocks, { type: 'text', text }] : imageBlocks }
1646+
? { role: 'user', content: text ? [...labelImages(imageBlocks), { type: 'text', text }] : labelImages(imageBlocks) }
16241647
: { role: 'user', content: text };
16251648
currentCheckpoint = { turnId: ++checkpointSeq, label: (text || '').slice(0, 60), ts: Date.now(), goalMsg: goalMsg, files: new Map() };
16261649
checkpoints.push(currentCheckpoint);
@@ -1870,6 +1893,26 @@ function storeImages(images) {
18701893
return out;
18711894
}
18721895

1896+
/**
1897+
* Introduce each image with a short label when there is more than one.
1898+
*
1899+
* Straight from the vision guidance: with several images, precede each with "Image 1:", "Image 2:"
1900+
* so the conversation can refer to them by name — in the question being asked, and in every
1901+
* follow-up turn afterwards. Without it, "the second screenshot" has nothing to bind to.
1902+
*
1903+
* Only when there are several. A single image needs no name, and labelling it would put a pointless
1904+
* text block ahead of every screenshot anyone pastes.
1905+
*/
1906+
function labelImages(blocks) {
1907+
if (!Array.isArray(blocks) || blocks.length < 2) { return blocks; }
1908+
const out = [];
1909+
blocks.forEach((b, i) => {
1910+
out.push({ type: 'text', text: 'Image ' + (i + 1) + ':' });
1911+
out.push(b);
1912+
});
1913+
return out;
1914+
}
1915+
18731916
/**
18741917
* A copy of `msgs` with every stored image turned into a real wire block.
18751918
*
@@ -1924,7 +1967,7 @@ async function handleSend(text, images) {
19241967
// An empty text block is a 400 from Anthropic ("text content blocks must be non-empty"), and an
19251968
// image sent with no words produces exactly that. Include the text block only when there is text.
19261969
conversation.push(imageBlocks.length
1927-
? { role: 'user', content: userContent ? [...imageBlocks, { type: 'text', text: userContent }] : imageBlocks }
1970+
? { role: 'user', content: userContent ? [...labelImages(imageBlocks), { type: 'text', text: userContent }] : labelImages(imageBlocks) }
19281971
: { role: 'user', content: userContent });
19291972
post({ type: 'userMessage', text });
19301973
if (auto.names.length) { post({ type: 'autoContext', names: auto.names }); }

extensions/levelcode-ai/imageStore.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,39 @@ function refsIn(msgs) {
113113
return out;
114114
}
115115

116-
module.exports = { MEDIA_EXT, MAX_BYTES, mediaDir, refPath, isRef, put, read, mediaTypeOf, materialize, refsIn };
116+
/**
117+
* Delete media nothing refers to any more.
118+
*
119+
* Needed because nothing else deletes it. Sessions are append-only and `trash()` only writes a
120+
* lifecycle event — the transcript stays on disk — so "the images go away with the session" was
121+
* never true. And a normal (non-agent) chat writes media without ever calling recordTurn, so its
122+
* refs are not in any session file at all.
123+
*
124+
* That second case is why there is an AGE FLOOR rather than a plain unreferenced-means-delete rule:
125+
* a file written moments ago may belong to a live conversation whose refs have not been persisted
126+
* and may never be. Deleting those would break the open chat. A week is long past the point where a
127+
* conversation is still live, and it bounds the growth, which is the actual complaint.
128+
*
129+
* @param keep a Set of refs still referenced (from refsIn over the project's sessions)
130+
*/
131+
function sweep(root, slug, keep, maxAgeMs) {
132+
const dir = mediaDir(root, slug);
133+
const cutoff = Date.now() - (maxAgeMs > 0 ? maxAgeMs : 7 * 24 * 60 * 60 * 1000);
134+
let removed = 0, bytes = 0;
135+
let names;
136+
try { names = fs.readdirSync(dir); } catch { return { removed: 0, bytes: 0 }; }
137+
for (const name of names) {
138+
if (!isRef(name)) { continue; } // never touch anything we did not write
139+
if (keep && keep.has(name)) { continue; }
140+
const full = path.join(dir, name);
141+
try {
142+
const st = fs.statSync(full);
143+
if (st.mtimeMs > cutoff) { continue; } // young enough to belong to a live conversation
144+
fs.unlinkSync(full);
145+
removed++; bytes += st.size;
146+
} catch { /* raced with another window, or already gone — either way, nothing to do */ }
147+
}
148+
return { removed, bytes };
149+
}
150+
151+
module.exports = { MEDIA_EXT, MAX_BYTES, mediaDir, refPath, isRef, put, read, mediaTypeOf, materialize, refsIn, sweep };

extensions/levelcode-ai/media/chat.html

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,11 +3279,34 @@
32793279
function caretAtFirstLine(el){ return el.value.lastIndexOf('\n', el.selectionStart - 1) === -1; }
32803280
function caretAtLastLine(el){ return el.value.indexOf('\n', el.selectionEnd) === -1; }
32813281
function histSet(text){ input.value = text; auto(); const n = text.length; try { input.setSelectionRange(n, n); } catch (e) {} }
3282-
function doSend(){
3282+
async function doSend(){
32833283
if (streaming) { flushAll = true; ensurePump(); vscode.postMessage({ type: 'stop' }); return; }
32843284
const t = input.value.trim();
32853285
// An image with no words is a real message — "look at this" is implied by attaching it.
32863286
if (!t && !pendingImages.length) return;
3287+
3288+
// Wait for anything still decoding. A placeholder chip carries no bytes, so sending now would
3289+
// post an attachment with undefined media_type and base64 — refused host-side, and the image
3290+
// would vanish from a message the user watched themselves attach.
3291+
if (inflightImages.size) {
3292+
note('Still preparing ' + inflightImages.size + ' image' + (inflightImages.size === 1 ? '' : 's') + '…');
3293+
for (let i = 0; i < 200 && inflightImages.size; i++) { await new Promise(function(r){ setTimeout(r, 50); }); }
3294+
const el = document.getElementById('imgnote'); if (el) { el.hidden = true; }
3295+
if (inflightImages.size) { note('An image is taking too long to prepare — remove it or try again.'); return; }
3296+
}
3297+
// Nothing may be sent while a placeholder survives: a decode that failed silently would
3298+
// otherwise ride along as an empty attachment.
3299+
if (pendingImages.some(function(i){ return i.loading; })) {
3300+
note('An image did not finish preparing — remove it and try again.'); return;
3301+
}
3302+
3303+
// RE-CHECK the model. The attach-time gate is not enough: a model can be switched between
3304+
// attaching and sending, and this path would otherwise hand images to a model that cannot read
3305+
// them. Refuse without discarding anything the user typed or attached.
3306+
if (pendingImages.length && !canSeeImages) {
3307+
note((canSeeImagesModel || 'The selected model') + ' cannot read images. Switch back to a vision model, or remove the attachments.');
3308+
return;
3309+
}
32873310
if (t) { cmdHistory.push(t); if (cmdHistory.length > 200) { cmdHistory.shift(); } } // record for ↑/↓ recall
32883311
histIdx = -1; histDraft = '';
32893312
// Slash commands — handled locally (deterministic, no LLM call).
@@ -3325,6 +3348,11 @@
33253348
const IMG_OK = { 'image/png': 1, 'image/jpeg': 1, 'image/gif': 1, 'image/webp': 1 };
33263349
let pendingImages = []; // { id, url, w, h, media_type, base64, bytes }
33273350
let imgSeq = 0;
3351+
// Normalization is async, and a placeholder chip carries no bytes. Sending mid-decode would post
3352+
// an attachment with undefined media_type/base64 — the host would refuse it and the image would
3353+
// be silently lost from a message the user watched themselves attach. Track the work so doSend
3354+
// can wait for it.
3355+
const inflightImages = new Set();
33283356

33293357
/**
33303358
* A user-facing "that did not work" line, shown WHERE IT HAPPENED — directly under the composer,
@@ -3402,11 +3430,15 @@
34023430
+ 'Claude, GPT-4o and Gemini all read them — and paste again.');
34033431
return true;
34043432
}
3405-
for (const f of list) {
3433+
for (let fi = 0; fi < list.length; fi++) {
3434+
const f = list[fi];
34063435
if (pendingImages.length >= IMG_MAX_PER_TURN) {
3407-
const dropped = list.length - pendingImages.length;
3436+
// From the position in THIS batch, not from the tray total. Subtracting the tray from the
3437+
// batch goes negative the moment the tray already holds more than the batch does — four
3438+
// attached, cap five, drop two, and the message claimed "-3 were not attached".
3439+
const dropped = list.length - fi;
34083440
note('Up to ' + IMG_MAX_PER_TURN + ' images per message. '
3409-
+ (dropped > 0 ? dropped + ' were not attached — ' : '') + 'remove one to add another.');
3441+
+ (dropped > 0 ? dropped + ' not attached — ' : '') + 'remove one to add another.');
34103442
break;
34113443
}
34123444
// A placeholder goes in FIRST. Decoding and re-encoding a 4K screenshot takes long enough to
@@ -3415,6 +3447,7 @@
34153447
const id = 'img' + (++imgSeq);
34163448
pendingImages.push({ id: id, loading: true, name: f.name || 'image' });
34173449
renderChips();
3450+
inflightImages.add(id);
34183451
try {
34193452
const im = await normalizeImage(f);
34203453
im.id = id;
@@ -3427,6 +3460,8 @@
34273460
pendingImages = pendingImages.filter(function(x){ return x.id !== id; });
34283461
renderChips();
34293462
note(String((e && e.message) || e));
3463+
} finally {
3464+
inflightImages.delete(id);
34303465
}
34313466
}
34323467
return true;
@@ -3463,7 +3498,10 @@
34633498
: (n ? ('Attach an image (' + n + '/' + IMG_MAX_PER_TURN + ') — or paste with ⌘V')
34643499
: 'Attach an image — or paste a screenshot with ⌘V');
34653500
}
3466-
function clearImages(){ pendingImages = []; }
3501+
function clearImages(){ pendingImages = []; inflightImages.clear(); }
3502+
3503+
/** Everything a fresh conversation must forget about attachments. */
3504+
function resetImages(){ clearImages(); renderChips(); const el = document.getElementById('imgnote'); if (el) { el.hidden = true; } }
34673505

34683506
// Paste is the primary way in — the whole interaction is screenshot, then Cmd+V.
34693507
input.addEventListener('paste', function(ev){
@@ -4136,6 +4174,7 @@
41364174
else if (m.type === 'context'){ selLabel = m.label; renderChips(); }
41374175
else if (m.type === 'clearContext'){ selLabel = null; renderChips(); }
41384176
else if (m.type === 'reset'){
4177+
resetImages(); // a new conversation must not inherit the previous one's attachments
41394178
log.innerHTML = '<div id="empty"><div class="big">New chat</div>Ask about your code, or describe what to build.</div>';
41404179
// Wiping the log detaches any open group; drop the dangling refs so the next run starts clean —
41414180
// otherwise groupAppend() appends into a disconnected .groupbody (nothing shows), and a stale

extensions/levelcode-ai/providers/catalog.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ function supportsToolsForModel(providerId, modelId) {
130130
function supportsVisionForModel(providerId, modelId) {
131131
const p = getProvider(providerId);
132132
if (!p) { return false; }
133+
// The PROVIDER gate first, exactly as supportsToolsForModel does. Without it this checked only
134+
// the model id, so `custom` — an arbitrary OpenAI-compatible endpoint with no declared vision
135+
// capability — returned true for any model whose NAME looked like a vision model, and the
136+
// attachment gate would hand images to an endpoint nobody said could read them.
137+
//
138+
// The registry ENUMERATES vision providers — anthropic, openai, openrouter and the gateway
139+
// declare `vision: true`; ollama and `custom` deliberately do not. Reading only the model id
140+
// ignored that: `custom` is an arbitrary user-supplied endpoint, and any model NAMED like a
141+
// vision model would have been handed images nobody said it could read.
142+
//
143+
// So both halves must agree, exactly as supportsToolsForModel requires both. A custom endpoint
144+
// that does serve a vision model needs `vision: true` on its registry entry to opt in; there is
145+
// deliberately no per-user override, because the honest place to declare a provider's
146+
// capabilities is the provider registry.
147+
if (!(p.caps && p.caps.vision === true)) { return false; }
133148
return modelCaps(modelId).vision === true;
134149
}
135150

extensions/levelcode-ai/providers/translate.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ function toOpenAIMessages(system, messages, opts) {
106106
function: { name: b.name, arguments: JSON.stringify(b.input == null ? {} : b.input) }
107107
});
108108
}
109+
// Reasoning an assistant produced is not something an OpenAI-shaped request carries,
110+
// and re-sending it is not required to continue a conversation — dropping it is a
111+
// deliberate translation, not a loss, so it is named rather than left to the
112+
// fall-through below.
113+
else if (b.type === 'thinking' || b.type === 'redacted_thinking') { continue; }
114+
else {
115+
// Same rule as the user loop. A block type we do not understand is a bug in us,
116+
// and a request that silently loses part of an assistant turn desynchronises the
117+
// conversation the model is asked to continue.
118+
throw new Error('translate: unsupported content block in an assistant message: ' + String(b.type));
119+
}
109120
}
110121
const msg = { role: 'assistant', content: text ? text : null };
111122
if (toolCalls.length) { msg.tool_calls = toolCalls; }

extensions/levelcode-ai/sessions.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const store = require('./sessionStore');
1919
const events = require('./sessionEvents');
2020
const planner = require('./sessionResume');
2121
const memory = require('./sessionMemory');
22+
const imageStore = require('./imageStore');
2223

2324
/**
2425
* @param {{ root: string, slug: string, projectPath: string,
@@ -326,15 +327,35 @@ function createSessions(opts) {
326327
}
327328
/** Where this project's memory lives (for opening it — the transparency promise). */
328329
function memoryPaths() { return { dir: memory.memoryDir(root, slug), journal: memory.journalFile(root, slug), memoryMd: memory.memoryMdFile(root, slug) }; }
329-
/** Where attached images live: beside this project's sessions, so they are deleted with them. */
330+
/**
331+
* Where attached images live: `media/` beside this project's sessions.
332+
*
333+
* PROJECT-scoped, not session-scoped, and nothing removes a file when a session goes away —
334+
* sessions are append-only and `trash()` only writes a lifecycle event. `sweepMedia()` below is
335+
* what actually bounds this.
336+
*/
330337
function mediaRoot() { return { root, slug }; }
331338

339+
/**
340+
* Delete images no session in this project refers to any more, and that are old enough not to
341+
* belong to a live conversation. Returns { removed, bytes }; never throws.
342+
*/
343+
function sweepMedia(maxAgeMs) {
344+
try {
345+
const keep = new Set();
346+
for (const entry of list()) {
347+
for (const ref of imageStore.refsIn(transcript(entry.id) || [])) { keep.add(ref); }
348+
}
349+
return imageStore.sweep(root, slug, keep, maxAgeMs);
350+
} catch (e) { return { removed: 0, bytes: 0 }; }
351+
}
352+
332353
/** The session index for this project (what the panel/view list). Empty (never throws) if unreadable. */
333354
function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } }
334355

335356
function liveId() { return live ? live.id : null; }
336357

337-
return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, mediaRoot, list, liveId };
358+
return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, mediaRoot, sweepMedia, list, liveId };
338359
}
339360

340361
module.exports = { createSessions };

0 commit comments

Comments
 (0)