-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathimageStore.js
More file actions
151 lines (139 loc) · 6.91 KB
/
Copy pathimageStore.js
File metadata and controls
151 lines (139 loc) · 6.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/*---------------------------------------------------------------------------------------------
* Pasted images on disk — content-addressed, beside the session that used them.
*
* 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 both add
* a failure mode and contradict the promise that we are not in the middle.
*
* WHY A SIBLING DIRECTORY RATHER THAN INLINE BASE64. Claude Code inlines image bytes in its
* own JSONL transcript and that works fine there. It does not work here, and the reason is
* specific to this codebase: sessionStore.scanProject readFileSync + JSON.parses EVERY session
* file in a project whenever index.json is missing, malformed, or on an older schema — which
* happens on 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.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
/** Claude accepts exactly these. Anything else is refused before it reaches a provider. */
const MEDIA_EXT = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp'
};
/**
* Per-image ceiling. The Claude API's own limit is 10MB of base64 (5MB on Bedrock and Vertex),
* and base64 inflates by 4/3 — so 5MB of BYTES is the largest thing that is safe everywhere.
* Normalization should keep real pastes far under this; the cap is for the pathological file.
*/
const MAX_BYTES = 5 * 1024 * 1024;
function mediaDir(root, slug) { return path.join(root, slug, 'media'); }
function refPath(root, slug, ref) { return path.join(mediaDir(root, slug), ref); }
/** true for a ref this module could have produced — 64 hex chars, a known extension, no path parts. */
function isRef(ref) {
return typeof ref === 'string' && /^[0-9a-f]{64}\.(png|jpg|gif|webp)$/.test(ref);
}
/**
* Store bytes and return the ref that identifies them.
*
* Content-addressed: the same screenshot pasted twice is one file, which is exactly what happens
* when someone re-pastes after a send fails. Writing is skipped when the file already exists, so
* a duplicate paste costs a hash and a stat.
*/
function put(root, slug, base64, mediaType) {
const ext = MEDIA_EXT[mediaType];
if (!ext) { throw new Error('imageStore: unsupported media type: ' + String(mediaType)); }
const buf = Buffer.from(String(base64 || ''), 'base64');
if (!buf.length) { throw new Error('imageStore: empty image'); }
if (buf.length > MAX_BYTES) {
throw new Error('imageStore: image is ' + Math.round(buf.length / 1024) + 'KB, over the '
+ Math.round(MAX_BYTES / 1024) + 'KB limit');
}
const ref = crypto.createHash('sha256').update(buf).digest('hex') + '.' + ext;
const dest = refPath(root, slug, ref);
if (!fs.existsSync(dest)) {
fs.mkdirSync(mediaDir(root, slug), { recursive: true });
// tmp + rename: a crash mid-write must never leave a truncated file under a hash that
// claims to describe its full contents.
const tmp = dest + '.' + process.pid + '.tmp';
fs.writeFileSync(tmp, buf);
fs.renameSync(tmp, dest);
}
return { ref, bytes: buf.length };
}
/** Read bytes back as base64 for a provider request. Returns null when the file is gone. */
function read(root, slug, ref) {
if (!isRef(ref)) { return null; }
try { return fs.readFileSync(refPath(root, slug, ref)).toString('base64'); }
catch { return null; }
}
/** The media type a ref implies, from its extension. */
function mediaTypeOf(ref) {
if (!isRef(ref)) { return null; }
const ext = ref.slice(ref.lastIndexOf('.') + 1);
return Object.keys(MEDIA_EXT).find((k) => MEDIA_EXT[k] === ext) || null;
}
/**
* A stored `{type:'image', ref, …}` block → the Anthropic wire block, bytes and all.
*
* Called only when a request is being built, and the result is never retained: the conversation,
* the session log and the token meter all keep the ref. Throws when the file is missing, because
* a request that silently drops its subject is the failure this whole feature exists to avoid.
*/
function materialize(root, slug, block) {
if (!block || block.type !== 'image') { return block; }
if (block.source) { return block; } // already materialized (or an inline block from elsewhere)
const data = read(root, slug, block.ref);
if (!data) { throw new Error('imageStore: attached image is missing from disk: ' + String(block.ref)); }
return { type: 'image', source: { type: 'base64', media_type: mediaTypeOf(block.ref), data } };
}
/** Refs still referenced by these messages — the keep-set for a sweep. */
function refsIn(msgs) {
const out = new Set();
for (const m of (Array.isArray(msgs) ? msgs : [])) {
for (const b of (Array.isArray(m && m.content) ? m.content : [])) {
if (b && b.type === 'image' && isRef(b.ref)) { out.add(b.ref); }
}
}
return out;
}
/**
* Delete media nothing refers to any more.
*
* Needed because nothing else deletes it. Sessions are append-only and `trash()` only writes a
* lifecycle event — the transcript stays on disk — so "the images go away with the session" was
* never true. And a normal (non-agent) chat writes media without ever calling recordTurn, so its
* refs are not in any session file at all.
*
* That second case is why there is an AGE FLOOR rather than a plain unreferenced-means-delete rule:
* a file written moments ago may belong to a live conversation whose refs have not been persisted
* and may never be. Deleting those would break the open chat. A week is long past the point where a
* conversation is still live, and it bounds the growth, which is the actual complaint.
*
* @param keep a Set of refs still referenced (from refsIn over the project's sessions)
*/
function sweep(root, slug, keep, maxAgeMs) {
const dir = mediaDir(root, slug);
const cutoff = Date.now() - (maxAgeMs > 0 ? maxAgeMs : 7 * 24 * 60 * 60 * 1000);
let removed = 0, bytes = 0;
let names;
try { names = fs.readdirSync(dir); } catch { return { removed: 0, bytes: 0 }; }
for (const name of names) {
if (!isRef(name)) { continue; } // never touch anything we did not write
if (keep && keep.has(name)) { continue; }
const full = path.join(dir, name);
try {
const st = fs.statSync(full);
if (st.mtimeMs > cutoff) { continue; } // young enough to belong to a live conversation
fs.unlinkSync(full);
removed++; bytes += st.size;
} catch { /* raced with another window, or already gone — either way, nothing to do */ }
}
return { removed, bytes };
}
module.exports = { MEDIA_EXT, MAX_BYTES, mediaDir, refPath, isRef, put, read, mediaTypeOf, materialize, refsIn, sweep };