mixdog 0.9.97 → 0.9.99
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/package.json +9 -2
- package/src/rules/agent/30-explorer.md +43 -32
- package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -84
- package/src/runtime/attachments/pdf-extract.mjs +83 -0
- package/src/runtime/attachments/store.mjs +515 -0
- package/src/runtime/attachments/store.test.mjs +188 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +1 -191
- package/src/runtime/channels/lib/runtime-paths.mjs +1 -4
- package/src/runtime/channels/lib/worker-ipc.mjs +0 -66
- package/src/runtime/channels/lib/worker-main.mjs +1 -20
- package/src/runtime/memory/lib/http-wire.mjs +39 -4
- package/src/runtime/shared/json-metrics.mjs +94 -0
- package/src/runtime/shared/turn-snapshot.mjs +13 -0
- package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
- package/src/session-runtime/provider-models.mjs +11 -1
- package/src/session-runtime/quick-model-rows.mjs +38 -7
- package/src/session-runtime/quick-search-models.mjs +15 -17
- package/src/session-runtime/runtime-core.mjs +9 -4
- package/src/session-runtime/runtime-tunables.mjs +9 -6
- package/src/session-runtime/session-title.mjs +27 -3
- package/src/standalone/channel-transport.mjs +28 -12
- package/src/standalone/daemon.mjs +3 -0
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/standalone/session-service.mjs +23 -1
- package/src/standalone/session-transport.mjs +3 -2
- package/src/tui/app/prompt-submit.mjs +7 -9
- package/src/tui/app/use-prompt-handlers.mjs +7 -3
- package/src/tui/dist/index.mjs +249 -43
- package/src/tui/paste-attachments.mjs +13 -7
- package/src/tui/prompt-history-store.mjs +52 -4
- package/src/tui/session/agent-job-feed.mjs +5 -1
- package/src/tui/session/live-share.mjs +21 -7
- package/src/tui/session/queue-helpers.mjs +6 -6
- package/src/tui/session/session-api-ext.mjs +12 -4
- package/src/tui/session/session-api.mjs +10 -1
- package/src/tui/session/session-flow.mjs +4 -2
- package/src/tui/session-local.mjs +15 -5
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed prompt attachment store.
|
|
3
|
+
*
|
|
4
|
+
* Renderer/TUI inputs carry the bytes exactly once to the daemon intake. From
|
|
5
|
+
* that boundary onward queues, checkpoints, session JSON, live-share frames,
|
|
6
|
+
* and desktop snapshots carry only a sha256 reference plus byte-free metadata.
|
|
7
|
+
* Provider lowering resolves the reference at the last possible boundary.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
utimesSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import {
|
|
20
|
+
readdir,
|
|
21
|
+
readFile as readFileAsync,
|
|
22
|
+
stat,
|
|
23
|
+
unlink,
|
|
24
|
+
} from 'node:fs/promises';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { resolvePluginData } from '../shared/plugin-paths.mjs';
|
|
28
|
+
import { inspectPdfBuffer } from './pdf-extract.mjs';
|
|
29
|
+
|
|
30
|
+
const ATTACHMENT_REF_RE = /^[a-f0-9]{64}$/;
|
|
31
|
+
const TEXT_TOKEN_RE = /\[(?:Pasted text|File) #(\d+)[^\]\r\n]*\]/g;
|
|
32
|
+
const TEXT_REFERENCE_THRESHOLD_BYTES = 800;
|
|
33
|
+
export const MAX_PROMPT_TEXT_BYTES = 1024 * 1024;
|
|
34
|
+
export const MAX_PROMPT_PDF_BYTES = 20 * 1024 * 1024;
|
|
35
|
+
export const MAX_PROMPT_PDF_PAGES = 100;
|
|
36
|
+
export const ATTACHMENT_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
37
|
+
export const ATTACHMENT_GC_MIN_AGE_MS = Math.max(
|
|
38
|
+
60_000,
|
|
39
|
+
Number(process.env.MIXDOG_ATTACHMENT_GC_MIN_AGE_MS) || 7 * 24 * 60 * 60 * 1000,
|
|
40
|
+
);
|
|
41
|
+
const ATTACHMENT_GC_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
42
|
+
const ATTACHMENT_GC_START_DELAY_MS = 5_000;
|
|
43
|
+
|
|
44
|
+
const bufferCache = new Map();
|
|
45
|
+
let bufferCacheBytes = 0;
|
|
46
|
+
let cacheHits = 0;
|
|
47
|
+
let cacheMisses = 0;
|
|
48
|
+
let attachmentGcTimer = null;
|
|
49
|
+
let lastAttachmentGcAt = 0;
|
|
50
|
+
|
|
51
|
+
function attachmentsDir() {
|
|
52
|
+
return join(resolvePluginData(), 'prompt-attachments', 'sha256');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function attachmentPath(ref) {
|
|
56
|
+
const value = String(ref || '');
|
|
57
|
+
if (!ATTACHMENT_REF_RE.test(value)) throw new TypeError('prompt attachment reference is invalid');
|
|
58
|
+
return join(attachmentsDir(), value.slice(0, 2), value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rememberBuffer(path, buffer) {
|
|
62
|
+
const existing = bufferCache.get(path);
|
|
63
|
+
if (existing) {
|
|
64
|
+
bufferCacheBytes -= existing.length;
|
|
65
|
+
bufferCache.delete(path);
|
|
66
|
+
}
|
|
67
|
+
if (buffer.length > ATTACHMENT_CACHE_MAX_BYTES) return;
|
|
68
|
+
bufferCache.set(path, buffer);
|
|
69
|
+
bufferCacheBytes += buffer.length;
|
|
70
|
+
while (bufferCacheBytes > ATTACHMENT_CACHE_MAX_BYTES && bufferCache.size > 0) {
|
|
71
|
+
const oldest = bufferCache.keys().next().value;
|
|
72
|
+
const evicted = bufferCache.get(oldest);
|
|
73
|
+
bufferCache.delete(oldest);
|
|
74
|
+
bufferCacheBytes -= evicted?.length || 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveBuffer(buffer) {
|
|
79
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
80
|
+
throw new TypeError('prompt attachment is empty');
|
|
81
|
+
}
|
|
82
|
+
const attachmentRef = createHash('sha256').update(buffer).digest('hex');
|
|
83
|
+
const target = attachmentPath(attachmentRef);
|
|
84
|
+
if (!existsSync(target)) {
|
|
85
|
+
mkdirSync(join(attachmentsDir(), attachmentRef.slice(0, 2)), { recursive: true, mode: 0o700 });
|
|
86
|
+
const temp = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
87
|
+
writeFileSync(temp, buffer, { mode: 0o600 });
|
|
88
|
+
try {
|
|
89
|
+
renameSync(temp, target);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!existsSync(target)) throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
try { unlinkSync(temp); } catch {}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// Reusing old content creates a fresh reference before its queue/session
|
|
97
|
+
// record is durable. Refresh mtime so the GC safety window protects that
|
|
98
|
+
// cross-process publication race.
|
|
99
|
+
try {
|
|
100
|
+
const now = new Date();
|
|
101
|
+
utimesSync(target, now, now);
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
rememberBuffer(target, buffer);
|
|
105
|
+
scheduleAttachmentGc();
|
|
106
|
+
return { attachmentRef, sizeBytes: buffer.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function isAttachmentReference(value) {
|
|
110
|
+
return Boolean(value && typeof value === 'object'
|
|
111
|
+
&& ATTACHMENT_REF_RE.test(String(value.attachmentRef || '')));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function readAttachmentBuffer(value) {
|
|
115
|
+
const ref = typeof value === 'string' ? value : value?.attachmentRef;
|
|
116
|
+
const path = attachmentPath(ref);
|
|
117
|
+
const cached = bufferCache.get(path);
|
|
118
|
+
if (cached) {
|
|
119
|
+
cacheHits += 1;
|
|
120
|
+
bufferCache.delete(path);
|
|
121
|
+
bufferCache.set(path, cached);
|
|
122
|
+
return cached;
|
|
123
|
+
}
|
|
124
|
+
cacheMisses += 1;
|
|
125
|
+
const buffer = readFileSync(path);
|
|
126
|
+
rememberBuffer(path, buffer);
|
|
127
|
+
return buffer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readAttachmentBase64(value) {
|
|
131
|
+
return readAttachmentBuffer(value).toString('base64');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function readAttachmentText(value) {
|
|
135
|
+
return readAttachmentBuffer(value).toString('utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function attachmentTextForPart(part) {
|
|
139
|
+
if (!isAttachmentReference(part)) return typeof part?.text === 'string' ? part.text : '';
|
|
140
|
+
const text = readAttachmentText(part);
|
|
141
|
+
if (part.textKind !== 'file') return text;
|
|
142
|
+
const safeName = String(part.filename || 'attachment').replace(/[<>"']/g, '_');
|
|
143
|
+
return `<file name="${safeName}">\n${text}\n</file>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function materializeText(text, metadata = {}) {
|
|
147
|
+
const value = String(text ?? '');
|
|
148
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
149
|
+
const ref = saveBuffer(bytes);
|
|
150
|
+
return {
|
|
151
|
+
type: 'text',
|
|
152
|
+
...ref,
|
|
153
|
+
...(metadata.textKind ? { textKind: metadata.textKind } : {}),
|
|
154
|
+
...(metadata.filename ? { filename: String(metadata.filename).slice(0, 200) } : {}),
|
|
155
|
+
...(metadata.mimeType ? { mimeType: String(metadata.mimeType).slice(0, 100) } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function materializePastedTexts(pastedTexts) {
|
|
160
|
+
if (!pastedTexts || typeof pastedTexts !== 'object') return { refs: {}, options: null };
|
|
161
|
+
const refs = {};
|
|
162
|
+
const options = {};
|
|
163
|
+
for (const [key, raw] of Object.entries(pastedTexts)) {
|
|
164
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
165
|
+
let refPart = null;
|
|
166
|
+
if (isAttachmentReference(raw)) {
|
|
167
|
+
refPart = {
|
|
168
|
+
type: 'text',
|
|
169
|
+
attachmentRef: raw.attachmentRef,
|
|
170
|
+
sizeBytes: Number(raw.sizeBytes) || 0,
|
|
171
|
+
...(raw.source === 'file' ? { textKind: 'file' } : {}),
|
|
172
|
+
...(raw.filename ? { filename: raw.filename } : {}),
|
|
173
|
+
...(raw.mimeType ? { mimeType: raw.mimeType } : {}),
|
|
174
|
+
};
|
|
175
|
+
} else if (typeof raw.text === 'string') {
|
|
176
|
+
refPart = materializeText(raw.text, {
|
|
177
|
+
textKind: raw.source === 'file' ? 'file' : 'paste',
|
|
178
|
+
filename: raw.filename,
|
|
179
|
+
mimeType: raw.mimeType,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (!refPart) continue;
|
|
183
|
+
refs[String(raw.id ?? key)] = refPart;
|
|
184
|
+
const { text: _text, ...meta } = raw;
|
|
185
|
+
options[key] = {
|
|
186
|
+
...meta,
|
|
187
|
+
attachmentRef: refPart.attachmentRef,
|
|
188
|
+
sizeBytes: refPart.sizeBytes,
|
|
189
|
+
chars: Number(raw.chars) || (typeof raw.text === 'string' ? raw.text.length : 0),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { refs, options: Object.keys(options).length ? options : null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function expandTextTokens(text, textRefs) {
|
|
196
|
+
const value = String(text ?? '');
|
|
197
|
+
TEXT_TOKEN_RE.lastIndex = 0;
|
|
198
|
+
let match;
|
|
199
|
+
let offset = 0;
|
|
200
|
+
const out = [];
|
|
201
|
+
while ((match = TEXT_TOKEN_RE.exec(value)) !== null) {
|
|
202
|
+
const ref = textRefs[String(match[1])];
|
|
203
|
+
if (!ref) continue;
|
|
204
|
+
if (match.index > offset) out.push({ type: 'text', text: value.slice(offset, match.index) });
|
|
205
|
+
out.push(ref);
|
|
206
|
+
offset = match.index + match[0].length;
|
|
207
|
+
}
|
|
208
|
+
if (offset === 0) return null;
|
|
209
|
+
if (offset < value.length) out.push({ type: 'text', text: value.slice(offset) });
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function materializeInlinePart(part) {
|
|
214
|
+
if (!part || typeof part !== 'object' || isAttachmentReference(part)) return part;
|
|
215
|
+
if (part.type === 'image' && typeof part.data === 'string' && part.data) {
|
|
216
|
+
const { data, ...metadata } = part;
|
|
217
|
+
return { ...metadata, ...saveBuffer(Buffer.from(data, 'base64')) };
|
|
218
|
+
}
|
|
219
|
+
if (part.type === 'file' && typeof part.data === 'string' && part.data) {
|
|
220
|
+
const { data, ...metadata } = part;
|
|
221
|
+
return { ...metadata, ...saveBuffer(Buffer.from(data, 'base64')) };
|
|
222
|
+
}
|
|
223
|
+
if (part.type === 'text' && typeof part.text === 'string'
|
|
224
|
+
&& Buffer.byteLength(part.text, 'utf8') >= TEXT_REFERENCE_THRESHOLD_BYTES) {
|
|
225
|
+
return materializeText(part.text, part);
|
|
226
|
+
}
|
|
227
|
+
return part;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function materializePromptContent(prompt, textRefs) {
|
|
231
|
+
if (typeof prompt === 'string') {
|
|
232
|
+
const expanded = expandTextTokens(prompt, textRefs);
|
|
233
|
+
if (expanded) return expanded.map(materializeInlinePart);
|
|
234
|
+
if (Buffer.byteLength(prompt, 'utf8') >= TEXT_REFERENCE_THRESHOLD_BYTES) {
|
|
235
|
+
return [materializeText(prompt)];
|
|
236
|
+
}
|
|
237
|
+
return prompt;
|
|
238
|
+
}
|
|
239
|
+
if (!Array.isArray(prompt)) return prompt;
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const part of prompt) {
|
|
242
|
+
if (part?.type === 'text' && typeof part.text === 'string') {
|
|
243
|
+
const expanded = expandTextTokens(part.text, textRefs);
|
|
244
|
+
if (expanded) {
|
|
245
|
+
out.push(...expanded.map(materializeInlinePart));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
out.push(materializeInlinePart(part));
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function promptTextBytes(content) {
|
|
255
|
+
if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
|
|
256
|
+
if (!Array.isArray(content)) return 0;
|
|
257
|
+
return content.reduce((sum, part) => {
|
|
258
|
+
if (part?.type !== 'text') return sum;
|
|
259
|
+
if (isAttachmentReference(part)) return sum + (Number(part.sizeBytes) || 0);
|
|
260
|
+
return sum + Buffer.byteLength(String(part?.text || ''), 'utf8');
|
|
261
|
+
}, 0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function materializePastedImages(pastedImages, imageParts) {
|
|
265
|
+
if (!pastedImages || typeof pastedImages !== 'object') return null;
|
|
266
|
+
const out = {};
|
|
267
|
+
let imageIndex = 0;
|
|
268
|
+
for (const [key, raw] of Object.entries(pastedImages)) {
|
|
269
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
270
|
+
let ref = isAttachmentReference(raw) ? raw : null;
|
|
271
|
+
if (!ref && typeof raw.content === 'string' && raw.content) {
|
|
272
|
+
ref = saveBuffer(Buffer.from(raw.content, 'base64'));
|
|
273
|
+
}
|
|
274
|
+
if (!ref) ref = imageParts[imageIndex] || null;
|
|
275
|
+
imageIndex += 1;
|
|
276
|
+
const { content: _content, ...meta } = raw;
|
|
277
|
+
out[key] = {
|
|
278
|
+
...meta,
|
|
279
|
+
...(ref ? {
|
|
280
|
+
attachmentRef: ref.attachmentRef,
|
|
281
|
+
sizeBytes: Number(ref.sizeBytes) || Number(raw.sizeBytes) || 0,
|
|
282
|
+
} : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return Object.keys(out).length ? out : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function materializePromptSubmission(prompt, options = {}) {
|
|
289
|
+
const textState = materializePastedTexts(options?.pastedTexts);
|
|
290
|
+
const materializedPrompt = materializePromptContent(prompt, textState.refs);
|
|
291
|
+
if (promptTextBytes(materializedPrompt) > MAX_PROMPT_TEXT_BYTES) {
|
|
292
|
+
throw new RangeError('prompt text exceeds the 1 MiB input limit');
|
|
293
|
+
}
|
|
294
|
+
const imageParts = (Array.isArray(materializedPrompt) ? materializedPrompt : [])
|
|
295
|
+
.filter((part) => part?.type === 'image' && isAttachmentReference(part));
|
|
296
|
+
return {
|
|
297
|
+
prompt: materializedPrompt,
|
|
298
|
+
options: {
|
|
299
|
+
...(options || {}),
|
|
300
|
+
...(options?.pastedImages
|
|
301
|
+
? { pastedImages: materializePastedImages(options.pastedImages, imageParts) }
|
|
302
|
+
: {}),
|
|
303
|
+
...(options?.pastedTexts ? { pastedTexts: textState.options } : {}),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const NATIVE_PDF_PROVIDERS = new Set([
|
|
309
|
+
'anthropic',
|
|
310
|
+
'anthropic-oauth',
|
|
311
|
+
'gemini',
|
|
312
|
+
'openai',
|
|
313
|
+
'openai-oauth',
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
export function providerSupportsNativePdf(provider) {
|
|
317
|
+
return NATIVE_PDF_PROVIDERS.has(String(provider || '').trim().toLowerCase());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Provider-aware PDF intake. Native document providers retain the original
|
|
322
|
+
* content-addressed PDF after validating its page count. Providers whose
|
|
323
|
+
* OpenAI-compatible wire contract has no portable file block receive bounded,
|
|
324
|
+
* page-labelled text instead.
|
|
325
|
+
*/
|
|
326
|
+
export async function preparePromptSubmissionForProvider(intake, provider) {
|
|
327
|
+
const prompt = intake?.prompt;
|
|
328
|
+
if (!Array.isArray(prompt)) return intake;
|
|
329
|
+
const nativePdf = providerSupportsNativePdf(provider);
|
|
330
|
+
let remainingTextBytes = Math.max(0, MAX_PROMPT_TEXT_BYTES - promptTextBytes(prompt));
|
|
331
|
+
let changed = false;
|
|
332
|
+
const prepared = [];
|
|
333
|
+
for (const part of prompt) {
|
|
334
|
+
const mimeType = String(part?.mimeType || part?.mediaType || '').toLowerCase();
|
|
335
|
+
if (part?.type !== 'file' || mimeType !== 'application/pdf' || !isAttachmentReference(part)) {
|
|
336
|
+
prepared.push(part);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const bytes = Number(part.sizeBytes) || readAttachmentBuffer(part).length;
|
|
340
|
+
if (bytes > MAX_PROMPT_PDF_BYTES) {
|
|
341
|
+
throw new RangeError(`PDF exceeds the ${MAX_PROMPT_PDF_BYTES / 1024 / 1024} MiB input limit`);
|
|
342
|
+
}
|
|
343
|
+
if (!nativePdf && remainingTextBytes < 256) {
|
|
344
|
+
throw new RangeError('PDF text cannot fit within the 1 MiB prompt text limit');
|
|
345
|
+
}
|
|
346
|
+
const inspected = await inspectPdfBuffer(readAttachmentBuffer(part), {
|
|
347
|
+
extractText: !nativePdf,
|
|
348
|
+
maxPages: MAX_PROMPT_PDF_PAGES,
|
|
349
|
+
maxOutputBytes: Math.max(256, remainingTextBytes),
|
|
350
|
+
});
|
|
351
|
+
changed = true;
|
|
352
|
+
if (nativePdf) {
|
|
353
|
+
prepared.push({ ...part, pageCount: inspected.pageCount });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const textPart = materializeText(inspected.text, {
|
|
357
|
+
textKind: 'file',
|
|
358
|
+
filename: part.filename || 'attachment.pdf',
|
|
359
|
+
mimeType: 'text/plain',
|
|
360
|
+
});
|
|
361
|
+
remainingTextBytes = Math.max(0, remainingTextBytes - textPart.sizeBytes);
|
|
362
|
+
prepared.push({ ...textPart, sourceMimeType: 'application/pdf', pageCount: inspected.pageCount });
|
|
363
|
+
}
|
|
364
|
+
if (!changed) return intake;
|
|
365
|
+
return { ...intake, prompt: prepared };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function hydratePastedAttachments(pastedImages, pastedTexts) {
|
|
369
|
+
const images = pastedImages && typeof pastedImages === 'object'
|
|
370
|
+
? Object.fromEntries(Object.entries(pastedImages).map(([key, raw]) => {
|
|
371
|
+
if (!raw || typeof raw !== 'object' || !isAttachmentReference(raw)) return [key, raw];
|
|
372
|
+
return [key, { ...raw, content: readAttachmentBase64(raw) }];
|
|
373
|
+
}))
|
|
374
|
+
: null;
|
|
375
|
+
const texts = pastedTexts && typeof pastedTexts === 'object'
|
|
376
|
+
? Object.fromEntries(Object.entries(pastedTexts).map(([key, raw]) => {
|
|
377
|
+
if (!raw || typeof raw !== 'object' || !isAttachmentReference(raw)) return [key, raw];
|
|
378
|
+
return [key, { ...raw, text: readAttachmentText(raw) }];
|
|
379
|
+
}))
|
|
380
|
+
: null;
|
|
381
|
+
return { pastedImages: images, pastedTexts: texts };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function attachmentStoreCacheStats() {
|
|
385
|
+
return {
|
|
386
|
+
entries: bufferCache.size,
|
|
387
|
+
bytes: bufferCacheBytes,
|
|
388
|
+
maxBytes: ATTACHMENT_CACHE_MAX_BYTES,
|
|
389
|
+
hits: cacheHits,
|
|
390
|
+
misses: cacheMisses,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function dropCachedAttachment(path) {
|
|
395
|
+
const cached = bufferCache.get(path);
|
|
396
|
+
if (!cached) return;
|
|
397
|
+
bufferCache.delete(path);
|
|
398
|
+
bufferCacheBytes -= cached.length;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function persistedAttachmentReferencePaths() {
|
|
402
|
+
const root = resolvePluginData();
|
|
403
|
+
const paths = [];
|
|
404
|
+
for (const dirname of ['sessions', 'turn-checkpoints']) {
|
|
405
|
+
const dir = join(root, dirname);
|
|
406
|
+
let entries;
|
|
407
|
+
try {
|
|
408
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (error?.code === 'ENOENT') continue;
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
if (entry.isFile() && entry.name.endsWith('.json')) paths.push(join(dir, entry.name));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
paths.push(join(root, 'session-pending-messages.json'));
|
|
418
|
+
return paths;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function collectPromptAttachments({
|
|
422
|
+
now = Date.now(),
|
|
423
|
+
minAgeMs = ATTACHMENT_GC_MIN_AGE_MS,
|
|
424
|
+
} = {}) {
|
|
425
|
+
const referenced = new Set();
|
|
426
|
+
let referenceFiles = 0;
|
|
427
|
+
for (const path of await persistedAttachmentReferencePaths()) {
|
|
428
|
+
let raw;
|
|
429
|
+
try {
|
|
430
|
+
raw = await readFileAsync(path, 'utf8');
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (error?.code === 'ENOENT') continue;
|
|
433
|
+
// A partial reference census must never authorize deletion.
|
|
434
|
+
return { deleted: 0, referenced: 0, fresh: 0, scanned: 0, incomplete: true };
|
|
435
|
+
}
|
|
436
|
+
referenceFiles += 1;
|
|
437
|
+
const regex = /"attachmentRef"\s*:\s*"([a-f0-9]{64})"/g;
|
|
438
|
+
let match;
|
|
439
|
+
while ((match = regex.exec(raw)) !== null) referenced.add(match[1]);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const cutoff = Number(now) - Math.max(0, Number(minAgeMs) || 0);
|
|
443
|
+
let prefixes;
|
|
444
|
+
try {
|
|
445
|
+
prefixes = await readdir(attachmentsDir(), { withFileTypes: true });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error?.code === 'ENOENT') {
|
|
448
|
+
return { deleted: 0, referenced: referenced.size, fresh: 0, scanned: 0, referenceFiles };
|
|
449
|
+
}
|
|
450
|
+
return { deleted: 0, referenced: referenced.size, fresh: 0, scanned: 0, referenceFiles, incomplete: true };
|
|
451
|
+
}
|
|
452
|
+
let deleted = 0;
|
|
453
|
+
let fresh = 0;
|
|
454
|
+
let scanned = 0;
|
|
455
|
+
for (const prefix of prefixes) {
|
|
456
|
+
if (!prefix.isDirectory() || !/^[a-f0-9]{2}$/.test(prefix.name)) continue;
|
|
457
|
+
const dir = join(attachmentsDir(), prefix.name);
|
|
458
|
+
let entries;
|
|
459
|
+
try {
|
|
460
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
461
|
+
} catch {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
for (const entry of entries) {
|
|
465
|
+
if (!entry.isFile() || !ATTACHMENT_REF_RE.test(entry.name)) continue;
|
|
466
|
+
scanned += 1;
|
|
467
|
+
if (referenced.has(entry.name)) continue;
|
|
468
|
+
const path = join(dir, entry.name);
|
|
469
|
+
try {
|
|
470
|
+
const first = await stat(path);
|
|
471
|
+
if (first.mtimeMs > cutoff) {
|
|
472
|
+
fresh += 1;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// Re-stat immediately before unlink so a concurrent intake mtime touch
|
|
476
|
+
// wins unless it races the final filesystem operation itself.
|
|
477
|
+
const latest = await stat(path);
|
|
478
|
+
if (latest.mtimeMs > cutoff || latest.mtimeMs !== first.mtimeMs) {
|
|
479
|
+
fresh += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
await unlink(path);
|
|
483
|
+
dropCachedAttachment(path);
|
|
484
|
+
deleted += 1;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (error?.code !== 'ENOENT') fresh += 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { deleted, referenced: referenced.size, fresh, scanned, referenceFiles };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function armAttachmentGc(delayMs) {
|
|
494
|
+
if (attachmentGcTimer) return;
|
|
495
|
+
attachmentGcTimer = setTimeout(() => {
|
|
496
|
+
attachmentGcTimer = null;
|
|
497
|
+
void collectPromptAttachments()
|
|
498
|
+
.catch(() => undefined)
|
|
499
|
+
.finally(() => {
|
|
500
|
+
lastAttachmentGcAt = Date.now();
|
|
501
|
+
armAttachmentGc(ATTACHMENT_GC_INTERVAL_MS);
|
|
502
|
+
});
|
|
503
|
+
}, Math.max(ATTACHMENT_GC_START_DELAY_MS, Number(delayMs) || 0));
|
|
504
|
+
attachmentGcTimer.unref?.();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function scheduleAttachmentGc() {
|
|
508
|
+
if (attachmentGcTimer) return;
|
|
509
|
+
const elapsed = Date.now() - lastAttachmentGcAt;
|
|
510
|
+
armAttachmentGc(lastAttachmentGcAt === 0
|
|
511
|
+
? ATTACHMENT_GC_START_DELAY_MS
|
|
512
|
+
: ATTACHMENT_GC_INTERVAL_MS - elapsed);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
scheduleAttachmentGc();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
utimesSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-attachments-'));
|
|
15
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
attachmentStoreCacheStats,
|
|
19
|
+
collectPromptAttachments,
|
|
20
|
+
hydratePastedAttachments,
|
|
21
|
+
materializePromptSubmission,
|
|
22
|
+
preparePromptSubmissionForProvider,
|
|
23
|
+
} = await import('./store.mjs');
|
|
24
|
+
const {
|
|
25
|
+
normalizeContentForAnthropic,
|
|
26
|
+
normalizeContentForOpenAIResponses,
|
|
27
|
+
sanitizeContentForStoredHistory,
|
|
28
|
+
} = await import('../agent/orchestrator/providers/media-normalization.mjs');
|
|
29
|
+
const {
|
|
30
|
+
imageResizeCacheStats,
|
|
31
|
+
openAIImagePatchCount,
|
|
32
|
+
resizeImageBuffer,
|
|
33
|
+
} = await import('../agent/orchestrator/tools/builtin/read-image-resize.mjs');
|
|
34
|
+
|
|
35
|
+
function minimalPdf(text = 'Hello PDF') {
|
|
36
|
+
const objects = [
|
|
37
|
+
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
38
|
+
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
39
|
+
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
|
40
|
+
`<< /Length ${text.length + 31} >>\nstream\nBT /F1 12 Tf 40 100 Td (${text}) Tj ET\nendstream`,
|
|
41
|
+
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
|
42
|
+
];
|
|
43
|
+
let body = '%PDF-1.4\n';
|
|
44
|
+
const offsets = [0];
|
|
45
|
+
objects.forEach((object, index) => {
|
|
46
|
+
offsets.push(Buffer.byteLength(body, 'latin1'));
|
|
47
|
+
body += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
48
|
+
});
|
|
49
|
+
const xref = Buffer.byteLength(body, 'latin1');
|
|
50
|
+
body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
51
|
+
body += offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
|
|
52
|
+
body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
53
|
+
return Buffer.from(body, 'latin1');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('daemon intake stores one payload and keeps byte-free refs through history/provider lowering', () => {
|
|
57
|
+
const imageData = Buffer.from('small-image').toString('base64');
|
|
58
|
+
const pdfData = Buffer.from('%PDF-1.7\nsmall-pdf').toString('base64');
|
|
59
|
+
const pastedText = 'large context '.repeat(100);
|
|
60
|
+
const before = {
|
|
61
|
+
prompt: [
|
|
62
|
+
{ type: 'text', text: 'Review [Pasted text #7 +100 lines]' },
|
|
63
|
+
{ type: 'image', data: imageData, mimeType: 'image/png' },
|
|
64
|
+
{ type: 'file', data: pdfData, mimeType: 'application/pdf', filename: 'notes.pdf' },
|
|
65
|
+
],
|
|
66
|
+
options: {
|
|
67
|
+
displayText: 'Review [Pasted text #7 +100 lines] [Image]',
|
|
68
|
+
pastedImages: {
|
|
69
|
+
1: { id: 1, type: 'image', mediaType: 'image/png', filename: 'shot.png', sizeBytes: 11 },
|
|
70
|
+
},
|
|
71
|
+
pastedTexts: {
|
|
72
|
+
7: { id: 7, text: pastedText, source: 'paste' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const intake = materializePromptSubmission(before.prompt, before.options);
|
|
78
|
+
const encoded = JSON.stringify(intake);
|
|
79
|
+
assert.doesNotMatch(encoded, new RegExp(imageData));
|
|
80
|
+
assert.doesNotMatch(encoded, /small-pdf/);
|
|
81
|
+
assert.doesNotMatch(encoded, /large context large context/);
|
|
82
|
+
const imagePart = intake.prompt.find((part) => part.type === 'image');
|
|
83
|
+
assert.match(imagePart.attachmentRef, /^[a-f0-9]{64}$/);
|
|
84
|
+
assert.equal(intake.options.pastedImages[1].attachmentRef, imagePart.attachmentRef);
|
|
85
|
+
assert.equal(intake.options.pastedTexts[7].text, undefined);
|
|
86
|
+
|
|
87
|
+
const stored = sanitizeContentForStoredHistory(intake.prompt);
|
|
88
|
+
assert.equal(stored, intake.prompt, 'byte-free attachment refs remain durable session content');
|
|
89
|
+
|
|
90
|
+
const anthropic = normalizeContentForAnthropic(intake.prompt);
|
|
91
|
+
assert.equal(anthropic.find((part) => part.type === 'text' && part.text === pastedText)?.text, pastedText);
|
|
92
|
+
assert.equal(anthropic.find((part) => part.type === 'image')?.source.data, imageData);
|
|
93
|
+
assert.equal(anthropic.find((part) => part.type === 'document')?.source.data, pdfData);
|
|
94
|
+
const responses = normalizeContentForOpenAIResponses(intake.prompt);
|
|
95
|
+
assert.equal(responses.find((part) => part.type === 'input_text' && part.text === pastedText)?.text, pastedText);
|
|
96
|
+
assert.match(responses.find((part) => part.type === 'input_image')?.image_url, /^data:image\/png;base64,/);
|
|
97
|
+
assert.match(responses.find((part) => part.type === 'input_file')?.file_data, /^data:application\/pdf;base64,/);
|
|
98
|
+
|
|
99
|
+
const hydrated = hydratePastedAttachments(
|
|
100
|
+
intake.options.pastedImages,
|
|
101
|
+
intake.options.pastedTexts,
|
|
102
|
+
);
|
|
103
|
+
assert.equal(hydrated.pastedImages[1].content, imageData);
|
|
104
|
+
assert.equal(hydrated.pastedTexts[7].text, pastedText);
|
|
105
|
+
assert.ok(attachmentStoreCacheStats().bytes <= attachmentStoreCacheStats().maxBytes);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('image resize output is reused from the bounded hash LRU', async () => {
|
|
109
|
+
const onePixelPng = Buffer.from(
|
|
110
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
111
|
+
'base64',
|
|
112
|
+
);
|
|
113
|
+
const before = imageResizeCacheStats();
|
|
114
|
+
const first = await resizeImageBuffer(onePixelPng, 'png');
|
|
115
|
+
assert.ok(first?.data, 'sharp is a required image backend');
|
|
116
|
+
const second = await resizeImageBuffer(onePixelPng, 'png');
|
|
117
|
+
assert.equal(second.data, first.data);
|
|
118
|
+
const after = imageResizeCacheStats();
|
|
119
|
+
assert.equal(after.hits, before.hits + 1);
|
|
120
|
+
assert.ok(after.bytes <= after.maxBytes);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('OpenAI image profile respects the 2048px and 1536-patch budget', async () => {
|
|
124
|
+
const sharp = (await import('sharp')).default;
|
|
125
|
+
const source = await sharp({
|
|
126
|
+
create: { width: 1600, height: 1200, channels: 3, background: '#abcdef' },
|
|
127
|
+
}).png().toBuffer();
|
|
128
|
+
const anthropic = await resizeImageBuffer(source, 'png', { profile: 'anthropic' });
|
|
129
|
+
const openai = await resizeImageBuffer(source, 'png', { profile: 'openai' });
|
|
130
|
+
assert.equal(anthropic.dimensions.displayWidth, 1600);
|
|
131
|
+
assert.ok(openAIImagePatchCount(
|
|
132
|
+
openai.dimensions.displayWidth,
|
|
133
|
+
openai.dimensions.displayHeight,
|
|
134
|
+
) <= 1536);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('PDF intake keeps native documents and extracts page text for compat providers', async () => {
|
|
138
|
+
const data = minimalPdf('Provider parity').toString('base64');
|
|
139
|
+
const native = await preparePromptSubmissionForProvider(
|
|
140
|
+
materializePromptSubmission([{ type: 'file', data, mimeType: 'application/pdf', filename: 'native.pdf' }]),
|
|
141
|
+
'anthropic',
|
|
142
|
+
);
|
|
143
|
+
assert.equal(native.prompt[0].type, 'file');
|
|
144
|
+
assert.equal(native.prompt[0].pageCount, 1);
|
|
145
|
+
const compat = await preparePromptSubmissionForProvider(
|
|
146
|
+
materializePromptSubmission([{ type: 'file', data, mimeType: 'application/pdf', filename: 'compat.pdf' }]),
|
|
147
|
+
'xai',
|
|
148
|
+
);
|
|
149
|
+
assert.equal(compat.prompt[0].type, 'text');
|
|
150
|
+
assert.match(normalizeContentForAnthropic(compat.prompt)[0].text, /Provider parity/);
|
|
151
|
+
assert.doesNotMatch(JSON.stringify(compat), new RegExp(data.slice(0, 40)));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('attachment GC preserves durable refs and the safety window while deleting stale orphans', async () => {
|
|
155
|
+
const makeFile = (text) => materializePromptSubmission([{
|
|
156
|
+
type: 'file',
|
|
157
|
+
data: Buffer.from(text).toString('base64'),
|
|
158
|
+
mimeType: 'application/octet-stream',
|
|
159
|
+
}]).prompt[0];
|
|
160
|
+
const referenced = makeFile('durably referenced attachment');
|
|
161
|
+
const orphan = makeFile('stale orphan attachment');
|
|
162
|
+
const fresh = makeFile('fresh orphan attachment');
|
|
163
|
+
const blobPath = (part) => join(
|
|
164
|
+
dataDir,
|
|
165
|
+
'prompt-attachments',
|
|
166
|
+
'sha256',
|
|
167
|
+
part.attachmentRef.slice(0, 2),
|
|
168
|
+
part.attachmentRef,
|
|
169
|
+
);
|
|
170
|
+
mkdirSync(join(dataDir, 'sessions'), { recursive: true });
|
|
171
|
+
writeFileSync(
|
|
172
|
+
join(dataDir, 'sessions', 'sess_attachment_gc.json'),
|
|
173
|
+
JSON.stringify({ id: 'sess_attachment_gc', messages: [{ content: [referenced] }] }),
|
|
174
|
+
);
|
|
175
|
+
const old = new Date(Date.now() - 10_000);
|
|
176
|
+
utimesSync(blobPath(referenced), old, old);
|
|
177
|
+
utimesSync(blobPath(orphan), old, old);
|
|
178
|
+
|
|
179
|
+
const result = await collectPromptAttachments({ now: Date.now(), minAgeMs: 1_000 });
|
|
180
|
+
assert.equal(existsSync(blobPath(referenced)), true);
|
|
181
|
+
assert.equal(existsSync(blobPath(fresh)), true);
|
|
182
|
+
assert.equal(existsSync(blobPath(orphan)), false);
|
|
183
|
+
assert.equal(result.deleted, 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test.after(() => {
|
|
187
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
188
|
+
});
|