opencode-acp 1.18.4 → 1.18.5-pr.490.223

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.
Files changed (33) hide show
  1. package/README.md +7 -0
  2. package/README.zh-CN.md +5 -0
  3. package/dist/index.js +819 -388
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/compress/image-archive.d.ts +2 -0
  6. package/dist/lib/compress/image-archive.d.ts.map +1 -1
  7. package/dist/lib/compress/image-tokens.d.ts +68 -0
  8. package/dist/lib/compress/image-tokens.d.ts.map +1 -0
  9. package/dist/lib/compress/status.d.ts.map +1 -1
  10. package/dist/lib/config-validation.d.ts.map +1 -1
  11. package/dist/lib/config.d.ts +25 -0
  12. package/dist/lib/config.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts.map +1 -1
  14. package/dist/lib/messages/inject/inject.d.ts.map +1 -1
  15. package/dist/lib/messages/inject/utils.d.ts +2 -0
  16. package/dist/lib/messages/inject/utils.d.ts.map +1 -1
  17. package/dist/lib/messages/large-images.d.ts +35 -0
  18. package/dist/lib/messages/large-images.d.ts.map +1 -0
  19. package/dist/lib/messages/sticky-truncation.d.ts +55 -0
  20. package/dist/lib/messages/sticky-truncation.d.ts.map +1 -0
  21. package/dist/lib/messages/truncate-tools.d.ts.map +1 -1
  22. package/dist/lib/prompts/extensions/system.d.ts +6 -0
  23. package/dist/lib/prompts/extensions/system.d.ts.map +1 -1
  24. package/dist/lib/prompts/index.d.ts +1 -1
  25. package/dist/lib/prompts/index.d.ts.map +1 -1
  26. package/dist/lib/state/persistence.d.ts +4 -0
  27. package/dist/lib/state/persistence.d.ts.map +1 -1
  28. package/dist/lib/state/state.d.ts.map +1 -1
  29. package/dist/lib/state/types.d.ts +17 -0
  30. package/dist/lib/state/types.d.ts.map +1 -1
  31. package/dist/lib/token-utils.d.ts +14 -0
  32. package/dist/lib/token-utils.d.ts.map +1 -1
  33. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -920,7 +920,10 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
920
920
  "compress.images.enabled",
921
921
  "compress.images.minBytes",
922
922
  "compress.images.ttlDays",
923
+ "compress.images.largeBytes",
924
+ "compress.images.nudge",
923
925
  "compress.completionReserveTokens",
926
+ "compress.emergencyTruncation",
924
927
  "gc",
925
928
  "gc.algorithm",
926
929
  "gc.promotionThreshold",
@@ -1316,7 +1319,7 @@ function validateConfigTypes(config) {
1316
1319
  if (typeof compress.images !== "object" || compress.images === null || Array.isArray(compress.images)) {
1317
1320
  errors.push({
1318
1321
  key: "compress.images",
1319
- expected: "object { enabled?: boolean; minBytes?: integer (>= 0); ttlDays?: number (> 0) }",
1322
+ expected: "object { enabled?: boolean; minBytes?: integer (>= 0); ttlDays?: number (> 0); largeBytes?: integer (>= 0); nudge?: boolean }",
1320
1323
  actual: JSON.stringify(compress.images)
1321
1324
  });
1322
1325
  } else {
@@ -1341,6 +1344,20 @@ function validateConfigTypes(config) {
1341
1344
  actual: JSON.stringify(compress.images.ttlDays)
1342
1345
  });
1343
1346
  }
1347
+ if (compress.images.largeBytes !== void 0 && (typeof compress.images.largeBytes !== "number" || !Number.isInteger(compress.images.largeBytes) || compress.images.largeBytes < 0)) {
1348
+ errors.push({
1349
+ key: "compress.images.largeBytes",
1350
+ expected: "integer (>= 0)",
1351
+ actual: JSON.stringify(compress.images.largeBytes)
1352
+ });
1353
+ }
1354
+ if (compress.images.nudge !== void 0 && typeof compress.images.nudge !== "boolean") {
1355
+ errors.push({
1356
+ key: "compress.images.nudge",
1357
+ expected: "boolean",
1358
+ actual: typeof compress.images.nudge
1359
+ });
1360
+ }
1344
1361
  }
1345
1362
  }
1346
1363
  if (compress.completionReserveTokens !== void 0 && typeof compress.completionReserveTokens !== "number") {
@@ -1350,6 +1367,13 @@ function validateConfigTypes(config) {
1350
1367
  actual: typeof compress.completionReserveTokens
1351
1368
  });
1352
1369
  }
1370
+ if (compress.emergencyTruncation !== void 0 && typeof compress.emergencyTruncation !== "boolean") {
1371
+ errors.push({
1372
+ key: "compress.emergencyTruncation",
1373
+ expected: "boolean",
1374
+ actual: typeof compress.emergencyTruncation
1375
+ });
1376
+ }
1353
1377
  if (typeof compress.completionReserveTokens === "number" && compress.completionReserveTokens < 0) {
1354
1378
  errors.push({
1355
1379
  key: "compress.completionReserveTokens",
@@ -1422,6 +1446,7 @@ function validateConfigTypes(config) {
1422
1446
  const OVERRIDE_FIELD_TYPES = {
1423
1447
  showCompression: "boolean",
1424
1448
  summaryBuffer: "boolean",
1449
+ emergencyTruncation: "boolean",
1425
1450
  protectTags: "boolean",
1426
1451
  protectUserMessages: "boolean",
1427
1452
  lastSegmentSoftBlock: "boolean",
@@ -1733,7 +1758,9 @@ var DEFAULT_COMPRESS_REASONING = {
1733
1758
  var DEFAULT_COMPRESS_IMAGES = {
1734
1759
  enabled: true,
1735
1760
  minBytes: 16384,
1736
- ttlDays: 7
1761
+ ttlDays: 7,
1762
+ largeBytes: 524288,
1763
+ nudge: true
1737
1764
  };
1738
1765
  var DEFAULT_PROTECTED_TOOLS = [
1739
1766
  "task",
@@ -1809,6 +1836,7 @@ var defaultConfig = {
1809
1836
  permission: "allow",
1810
1837
  showCompression: true,
1811
1838
  summaryBuffer: true,
1839
+ emergencyTruncation: false,
1812
1840
  candidates: false,
1813
1841
  maxContextLimit: "80%",
1814
1842
  minContextLimit: "80%",
@@ -2015,9 +2043,12 @@ function mergeCompress(base, override) {
2015
2043
  images: {
2016
2044
  enabled: override.images?.enabled ?? base.images?.enabled ?? DEFAULT_COMPRESS_IMAGES.enabled,
2017
2045
  minBytes: override.images?.minBytes ?? base.images?.minBytes ?? DEFAULT_COMPRESS_IMAGES.minBytes,
2018
- ttlDays: override.images?.ttlDays ?? base.images?.ttlDays ?? DEFAULT_COMPRESS_IMAGES.ttlDays
2046
+ ttlDays: override.images?.ttlDays ?? base.images?.ttlDays ?? DEFAULT_COMPRESS_IMAGES.ttlDays,
2047
+ largeBytes: override.images?.largeBytes ?? base.images?.largeBytes ?? DEFAULT_COMPRESS_IMAGES.largeBytes,
2048
+ nudge: override.images?.nudge ?? base.images?.nudge ?? DEFAULT_COMPRESS_IMAGES.nudge
2019
2049
  },
2020
- completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens
2050
+ completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens,
2051
+ emergencyTruncation: override.emergencyTruncation ?? base.emergencyTruncation
2021
2052
  };
2022
2053
  }
2023
2054
  function mergeCommands(base, override) {
@@ -2410,174 +2441,18 @@ function isProtectedUserMessage(_config, message) {
2410
2441
  return false;
2411
2442
  }
2412
2443
 
2413
- // lib/token-utils.ts
2414
- var anthropicCountTokens = _anthropicTokenizer.countTokens ?? _anthropicTokenizer.default?.countTokens;
2415
- function getCurrentTokenUsage(state, messages) {
2416
- for (let i = messages.length - 1; i >= 0; i--) {
2417
- const msg = messages[i];
2418
- if (msg.info.role !== "assistant") {
2419
- continue;
2420
- }
2421
- const assistantInfo = msg.info;
2422
- const input = assistantInfo.tokens?.input || 0;
2423
- const output = assistantInfo.tokens?.output || 0;
2424
- const reasoning = assistantInfo.tokens?.reasoning || 0;
2425
- const cacheRead = assistantInfo.tokens?.cache?.read || 0;
2426
- const cacheWrite = assistantInfo.tokens?.cache?.write || 0;
2427
- if (input <= 0 && output <= 0) {
2428
- continue;
2429
- }
2430
- if (state.lastCompaction > 0 && (msg.info.time.created < state.lastCompaction || msg.info.summary === true && msg.info.time.created === state.lastCompaction)) {
2431
- return 0;
2432
- }
2433
- return input + cacheRead + cacheWrite + output + reasoning;
2434
- }
2435
- let estimated = 0;
2436
- for (const m of messages) {
2437
- estimated += countAllMessageTokens(m);
2438
- }
2439
- return estimated;
2440
- }
2441
- function estimateSystemPromptTokens(messages) {
2442
- let firstInput;
2443
- for (const msg of messages) {
2444
- if (msg.info.role !== "assistant") continue;
2445
- const assistantInfo = msg.info;
2446
- const t = assistantInfo.tokens;
2447
- if (!t) continue;
2448
- const input = (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0);
2449
- if (input > 0) {
2450
- firstInput = input;
2451
- break;
2452
- }
2453
- }
2454
- if (firstInput === void 0) return 0;
2455
- let firstUserText = "";
2456
- for (const msg of messages) {
2457
- if (msg.info.role !== "user") continue;
2458
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
2459
- for (const part of parts) {
2460
- if (part.type === "text" && typeof part.text === "string") {
2461
- firstUserText += part.text;
2462
- }
2463
- }
2464
- if (firstUserText) break;
2465
- }
2466
- return Math.max(0, firstInput - countTokens2(firstUserText));
2467
- }
2468
- function getCurrentParams(state, messages, logger) {
2469
- const userMsg = getLastUserMessage(messages);
2470
- if (!userMsg) {
2471
- logger.debug("No user message found when determining current params");
2472
- return {
2473
- providerId: void 0,
2474
- modelId: void 0,
2475
- agent: void 0,
2476
- variant: void 0
2477
- };
2478
- }
2479
- const userInfo = userMsg.info;
2480
- const agent = userInfo.agent;
2481
- const providerId = userInfo.model.providerID;
2482
- const modelId = userInfo.model.modelID;
2483
- const variant = userInfo.model.variant;
2484
- return { providerId, modelId, agent, variant };
2485
- }
2486
- function countTokens2(text) {
2487
- if (!text) return 0;
2488
- try {
2489
- return anthropicCountTokens(text);
2490
- } catch {
2491
- return Math.round(text.length / 4);
2492
- }
2493
- }
2494
- function estimateTokensBatch(texts) {
2495
- if (texts.length === 0) return 0;
2496
- return countTokens2(texts.join(" "));
2497
- }
2498
- var COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]";
2499
- function stringifyToolContent(value) {
2500
- return typeof value === "string" ? value : JSON.stringify(value);
2501
- }
2502
- function extractCompletedToolOutput(part) {
2503
- if (part?.type !== "tool" || part.state?.status !== "completed" || part.state?.output === void 0) {
2504
- return void 0;
2505
- }
2506
- if (part.state?.time?.compacted) {
2507
- return COMPACTED_TOOL_OUTPUT_PLACEHOLDER;
2508
- }
2509
- return stringifyToolContent(part.state.output);
2510
- }
2511
- function extractToolContent(part) {
2512
- const contents = [];
2513
- if (part?.type !== "tool") {
2514
- return contents;
2515
- }
2516
- if (part.state?.input !== void 0) {
2517
- contents.push(stringifyToolContent(part.state.input));
2518
- }
2519
- const completedOutput = extractCompletedToolOutput(part);
2520
- if (completedOutput !== void 0) {
2521
- contents.push(completedOutput);
2522
- } else if (part.state?.status === "error" && part.state?.error) {
2523
- contents.push(stringifyToolContent(part.state.error));
2524
- }
2525
- return contents;
2526
- }
2527
- function countAllMessageTokens(msg) {
2528
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
2529
- const texts = [];
2530
- for (const part of parts) {
2531
- if (part.type === "text") {
2532
- texts.push(part.text);
2533
- } else {
2534
- texts.push(...extractToolContent(part));
2535
- }
2536
- }
2537
- if (texts.length === 0) return 0;
2538
- return estimateTokensBatch(texts);
2539
- }
2540
- function countMessageCharacters(msg) {
2541
- const parts = Array.isArray(msg.parts) ? msg.parts : [];
2542
- let total = 0;
2543
- for (const part of parts) {
2544
- if (part.type === "text" && typeof part.text === "string") {
2545
- total += part.text.length;
2546
- } else {
2547
- for (const content of extractToolContent(part)) {
2548
- total += content.length;
2549
- }
2550
- }
2551
- }
2552
- return total;
2553
- }
2554
- function estimateAllMessageTokensFast(msg) {
2555
- return Math.round(countMessageCharacters(msg) / 4);
2556
- }
2557
-
2558
- // lib/prompts/extensions/tool.ts
2559
- var RANGE_FORMAT_EXTENSION = `
2560
-
2561
- THE FORMAT OF COMPRESS
2562
-
2563
- \`\`\`
2564
- {
2565
- topic?: string, // OPTIONAL fallback topic for entries without their own.
2566
- // Omit when every content entry specifies its own topic.
2567
- content: [ // One or more ranges to compress
2568
- {
2569
- topic?: string, // OPTIONAL per-entry topic for this range.
2570
- // Falls back to top-level topic.
2571
- // Give each entry its own topic when compressing
2572
- // unrelated ranges in one call.
2573
- startId: string, // Boundary ID at range start: mNNNNN or bN
2574
- endId: string, // Boundary ID at range end: mNNNNN or bN
2575
- summary: string // Complete technical summary replacing all content in range
2576
- }
2577
- ]
2578
- }
2579
- \`\`\`
2580
- Each entry needs a topic \u2014 either its own or the top-level fallback.`;
2444
+ // lib/compress/image-archive.ts
2445
+ import { createHash } from "crypto";
2446
+ import {
2447
+ existsSync as existsSync3,
2448
+ mkdirSync as mkdirSync2,
2449
+ readdirSync,
2450
+ rmdirSync,
2451
+ statSync as statSync2,
2452
+ unlinkSync,
2453
+ writeFileSync as writeFileSync2
2454
+ } from "fs";
2455
+ import { join as join3 } from "path";
2581
2456
 
2582
2457
  // lib/state/persistence.ts
2583
2458
  import * as fs from "fs/promises";
@@ -2731,7 +2606,7 @@ function loadPruneMessagesState(persisted) {
2731
2606
  deactivatedByUser: block.deactivatedByUser === true,
2732
2607
  deactivatedByUserDeep: block.deactivatedByUserDeep === true ? true : void 0,
2733
2608
  compressedTokens: typeof block.compressedTokens === "number" && Number.isFinite(block.compressedTokens) ? Math.max(0, block.compressedTokens) : 0,
2734
- summaryTokens: typeof block.summaryTokens === "number" && Number.isFinite(block.summaryTokens) ? Math.max(0, block.summaryTokens) : typeof block.summary === "string" ? countTokens2(block.summary) : 0,
2609
+ summaryTokens: typeof block.summaryTokens === "number" && Number.isFinite(block.summaryTokens) ? Math.max(0, block.summaryTokens) : typeof block.summary === "string" ? countTokens(block.summary) : 0,
2735
2610
  durationMs: typeof block.durationMs === "number" && Number.isFinite(block.durationMs) ? Math.max(0, block.durationMs) : 0,
2736
2611
  mode: block.mode === "range" || block.mode === "message" ? block.mode : void 0,
2737
2612
  topic: typeof block.topic === "string" ? block.topic : "",
@@ -2955,7 +2830,9 @@ function saveSessionState(sessionState, logger, sessionName) {
2955
2830
  modelContextLimit: sessionState.modelContextLimit,
2956
2831
  modelProviderID: sessionState.modelProviderID,
2957
2832
  modelID: sessionState.modelID,
2958
- imageArchive: sessionState.imageArchive
2833
+ imageArchive: sessionState.imageArchive,
2834
+ imageSurgeNudged: sessionState.imageSurgeNudged,
2835
+ stickyTruncations: sessionState.stickyTruncations
2959
2836
  };
2960
2837
  const key = saveQueueKey(sessionState.sessionId, sessionState.storageDir);
2961
2838
  let queue = saveQueues.get(key);
@@ -3070,22 +2947,509 @@ async function loadSessionState(sessionId, logger, storageDir) {
3070
2947
  ;
3071
2948
  state._persistedLastCompaction = persistedLastCompaction;
3072
2949
  }
3073
- logger.info("Loaded session state from disk", {
3074
- sessionId
3075
- });
3076
- return state;
3077
- } catch (error) {
3078
- logger.warn("Failed to load session state", {
3079
- sessionId,
3080
- error: error?.message
3081
- });
3082
- return null;
3083
- }
2950
+ logger.info("Loaded session state from disk", {
2951
+ sessionId
2952
+ });
2953
+ return state;
2954
+ } catch (error) {
2955
+ logger.warn("Failed to load session state", {
2956
+ sessionId,
2957
+ error: error?.message
2958
+ });
2959
+ return null;
2960
+ }
2961
+ }
2962
+
2963
+ // lib/compress/image-archive.ts
2964
+ var DATA_URL_REGEX = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/is;
2965
+ var IMAGE_EXTENSIONS = {
2966
+ "image/png": "png",
2967
+ "image/jpeg": "jpg",
2968
+ "image/jpg": "jpg",
2969
+ "image/gif": "gif",
2970
+ "image/webp": "webp",
2971
+ "image/avif": "avif",
2972
+ "image/bmp": "bmp"
2973
+ };
2974
+ function sha8(input) {
2975
+ return createHash("sha256").update(input).digest("hex").slice(0, 8);
2976
+ }
2977
+ function parseImageDataUrl(url) {
2978
+ if (typeof url !== "string") return void 0;
2979
+ const match = DATA_URL_REGEX.exec(url);
2980
+ if (!match) return void 0;
2981
+ return { mediaType: match[1].toLowerCase(), base64: match[2] };
2982
+ }
2983
+ function extForMediaType(mediaType) {
2984
+ return IMAGE_EXTENSIONS[mediaType.toLowerCase()];
2985
+ }
2986
+ function archiveDirName(sessionId) {
2987
+ let name = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 100);
2988
+ if (name !== sessionId) {
2989
+ name = `${name}-${sha8(sessionId)}`;
2990
+ }
2991
+ return name || "-";
2992
+ }
2993
+ function archiveRoot(storageDir, sessionId) {
2994
+ return join3(storageDir ?? getDefaultStorageDir(), "images", archiveDirName(sessionId));
2995
+ }
2996
+ function archiveSessionImages(state, messages, images, logger) {
2997
+ if (!images.enabled || !state.sessionId) return 0;
2998
+ const root = archiveRoot(state.storageDir, state.sessionId);
2999
+ let archived = 0;
3000
+ for (const message of messages) {
3001
+ const rawId = message.info.id;
3002
+ if (!rawId) continue;
3003
+ const parts = Array.isArray(message.parts) ? message.parts : [];
3004
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
3005
+ const part = parts[partIndex];
3006
+ if (part.type !== "file") continue;
3007
+ const parsed = parseImageDataUrl(part.url);
3008
+ if (!parsed) continue;
3009
+ const ext = extForMediaType(parsed.mediaType);
3010
+ if (!ext) continue;
3011
+ const fileName = `${sha8(rawId)}-p${partIndex}-${sha8(parsed.base64)}.${ext}`;
3012
+ const filePath = join3(root, fileName);
3013
+ const existing = state.imageArchive[rawId]?.find((e) => e.path === filePath);
3014
+ if (existing) continue;
3015
+ let bytes;
3016
+ try {
3017
+ if (existsSync3(filePath)) {
3018
+ bytes = statSync2(filePath).size;
3019
+ } else {
3020
+ const buffer = Buffer.from(parsed.base64, "base64");
3021
+ bytes = buffer.byteLength;
3022
+ if (bytes < images.minBytes) continue;
3023
+ mkdirSync2(root, { recursive: true });
3024
+ writeFileSync2(filePath, buffer, { mode: 384 });
3025
+ }
3026
+ } catch (error) {
3027
+ logger.warn(
3028
+ `[#478] failed to archive image ${filePath}: ${error instanceof Error ? error.message : String(error)}`
3029
+ );
3030
+ continue;
3031
+ }
3032
+ const entry = {
3033
+ path: filePath,
3034
+ mediaType: parsed.mediaType,
3035
+ bytes
3036
+ };
3037
+ if (!state.imageArchive[rawId]) {
3038
+ state.imageArchive[rawId] = [];
3039
+ }
3040
+ state.imageArchive[rawId].push(entry);
3041
+ state.stats.imagesArchived = (state.stats.imagesArchived ?? 0) + 1;
3042
+ state.stats.imageBytesArchived = (state.stats.imageBytesArchived ?? 0) + bytes;
3043
+ archived++;
3044
+ }
3045
+ }
3046
+ return archived;
3047
+ }
3048
+ function pruneImageArchive(state, images, logger) {
3049
+ if (state.imagePruneDone || !state.sessionId) return;
3050
+ state.imagePruneDone = true;
3051
+ try {
3052
+ const root = archiveRoot(state.storageDir, state.sessionId);
3053
+ if (!existsSync3(root)) return;
3054
+ const cutoff = Date.now() - images.ttlDays * 864e5;
3055
+ const prunedPaths = /* @__PURE__ */ new Set();
3056
+ for (const name of readdirSync(root)) {
3057
+ const filePath = join3(root, name);
3058
+ try {
3059
+ if (statSync2(filePath).mtimeMs < cutoff) {
3060
+ unlinkSync(filePath);
3061
+ prunedPaths.add(filePath);
3062
+ }
3063
+ } catch {
3064
+ }
3065
+ }
3066
+ if (prunedPaths.size > 0) {
3067
+ for (const [rawId, entries] of Object.entries(state.imageArchive)) {
3068
+ const kept = entries.filter((e) => !prunedPaths.has(e.path));
3069
+ if (kept.length === 0) {
3070
+ delete state.imageArchive[rawId];
3071
+ } else if (kept.length !== entries.length) {
3072
+ state.imageArchive[rawId] = kept;
3073
+ }
3074
+ }
3075
+ logger.info(`[#478] TTL sweep removed ${prunedPaths.size} expired archived image(s)`);
3076
+ }
3077
+ if (readdirSync(root).length === 0) {
3078
+ rmdirSync(root);
3079
+ }
3080
+ } catch (error) {
3081
+ logger.warn(
3082
+ `[#478] image TTL sweep failed: ${error instanceof Error ? error.message : String(error)}`
3083
+ );
3084
+ }
3085
+ }
3086
+ function appendArchivedImagePointers(summary, selection, state, enabled) {
3087
+ if (!enabled) return summary;
3088
+ const lines = [];
3089
+ for (const messageId of selection.messageIds) {
3090
+ const entries = state.imageArchive[messageId];
3091
+ if (!entries || entries.length === 0) continue;
3092
+ const ref = state.messageIds.byRawId.get(messageId) ?? messageId;
3093
+ for (const entry of entries) {
3094
+ lines.push(`- ${ref}: ${entry.path} (${entry.mediaType}, ${entry.bytes} bytes)`);
3095
+ }
3096
+ }
3097
+ if (lines.length === 0) return summary;
3098
+ const heading = "\n\nImages captured in this range were archived to disk (their pixels are NOT summarized). Re-read them with your file tools if you need the pixels again:";
3099
+ return summary + heading + "\n" + lines.join("\n");
3100
+ }
3101
+
3102
+ // lib/compress/image-tokens.ts
3103
+ var VISION_MAX_SIDE = 1568;
3104
+ var VISION_TILE_SIZE = 512;
3105
+ var TOKENS_PER_TILE = 167;
3106
+ var VISION_MAX_TILES = Math.ceil(VISION_MAX_SIDE / VISION_TILE_SIZE) ** 2;
3107
+ var UNKNOWN_DIM_LARGE_BYTES = 256 * 1024;
3108
+ function estimateVisionTokensForDimensions(width, height) {
3109
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
3110
+ return 0;
3111
+ }
3112
+ const scale = Math.min(1, VISION_MAX_SIDE / Math.max(width, height));
3113
+ const scaledWidth = Math.max(1, Math.floor(width * scale));
3114
+ const scaledHeight = Math.max(1, Math.floor(height * scale));
3115
+ const tiles = Math.ceil(scaledWidth / VISION_TILE_SIZE) * Math.ceil(scaledHeight / VISION_TILE_SIZE);
3116
+ return Math.min(tiles, VISION_MAX_TILES) * TOKENS_PER_TILE;
3117
+ }
3118
+ function validDimensions(width, height) {
3119
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || width > 1e6 || height > 1e6) {
3120
+ return void 0;
3121
+ }
3122
+ return { width, height };
3123
+ }
3124
+ function fourcc(bytes, offset) {
3125
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
3126
+ }
3127
+ function parseImageDimensions(bytes, mediaType) {
3128
+ const len = bytes.length;
3129
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3130
+ switch (mediaType.toLowerCase()) {
3131
+ case "image/png": {
3132
+ if (len < 24) return void 0;
3133
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
3134
+ for (let i = 0; i < sig.length; i++) {
3135
+ if (bytes[i] !== sig[i]) return void 0;
3136
+ }
3137
+ return validDimensions(dv.getUint32(16, false), dv.getUint32(20, false));
3138
+ }
3139
+ case "image/jpeg":
3140
+ case "image/jpg": {
3141
+ if (len < 4 || bytes[0] !== 255 || bytes[1] !== 216) return void 0;
3142
+ let i = 2;
3143
+ while (i + 9 < len) {
3144
+ if (bytes[i] !== 255) {
3145
+ i++;
3146
+ continue;
3147
+ }
3148
+ const marker = bytes[i + 1];
3149
+ if (marker === 216 || marker === 217 || marker >= 218 && marker <= 223) {
3150
+ return void 0;
3151
+ }
3152
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
3153
+ if (i + 9 >= len) return void 0;
3154
+ return validDimensions(dv.getUint16(i + 8, false), dv.getUint16(i + 6, false));
3155
+ }
3156
+ const segLen = dv.getUint16(i + 2, false);
3157
+ if (segLen < 2) return void 0;
3158
+ i += 2 + segLen;
3159
+ }
3160
+ return void 0;
3161
+ }
3162
+ case "image/gif": {
3163
+ if (len < 10) return void 0;
3164
+ if (fourcc(bytes, 0) !== "GIF8") return void 0;
3165
+ return validDimensions(dv.getUint16(6, true), dv.getUint16(8, true));
3166
+ }
3167
+ case "image/webp": {
3168
+ if (len < 30) return void 0;
3169
+ if (fourcc(bytes, 0) !== "RIFF" || fourcc(bytes, 8) !== "WEBP") return void 0;
3170
+ let i = 12;
3171
+ while (i + 8 < len) {
3172
+ const id = fourcc(bytes, i);
3173
+ const size = dv.getUint32(i + 4, true);
3174
+ const payload = i + 8;
3175
+ if (id === "VP8 ") {
3176
+ if (payload + 10 > len) return void 0;
3177
+ return validDimensions(
3178
+ dv.getUint16(payload + 6, true) & 16383,
3179
+ dv.getUint16(payload + 8, true) & 16383
3180
+ );
3181
+ }
3182
+ if (id === "VP8L") {
3183
+ if (payload + 5 > len) return void 0;
3184
+ if (bytes[payload] !== 47) return void 0;
3185
+ const packed = bytes[payload + 1] | bytes[payload + 2] << 8 | bytes[payload + 3] << 16;
3186
+ return validDimensions(
3187
+ 1 + (packed & 16383),
3188
+ 1 + (packed >>> 14 & 16383)
3189
+ );
3190
+ }
3191
+ if (id === "VP8X") {
3192
+ if (payload + 10 > len) return void 0;
3193
+ const w = bytes[payload + 4] | bytes[payload + 5] << 8 | bytes[payload + 6] << 16;
3194
+ const h = bytes[payload + 7] | bytes[payload + 8] << 8 | bytes[payload + 9] << 16;
3195
+ return validDimensions(w + 1, h + 1);
3196
+ }
3197
+ if (size === 4294967295) return void 0;
3198
+ i = payload + size + (size & 1);
3199
+ }
3200
+ return void 0;
3201
+ }
3202
+ case "image/bmp": {
3203
+ if (len < 26) return void 0;
3204
+ if (bytes[0] !== 66 || bytes[1] !== 77) return void 0;
3205
+ return validDimensions(
3206
+ Math.abs(dv.getInt32(18, true)),
3207
+ Math.abs(dv.getInt32(22, true))
3208
+ );
3209
+ }
3210
+ default:
3211
+ return void 0;
3212
+ }
3213
+ }
3214
+ function decodedBytesFromBase64(base64) {
3215
+ if (typeof base64 !== "string" || base64.length === 0) return 0;
3216
+ return Math.max(0, Math.floor(base64.length * 3 / 4));
3217
+ }
3218
+ function decodeBase64Prefix(base64, maxBytes) {
3219
+ const chars = Math.min(base64.length, Math.ceil(maxBytes * 4 / 3));
3220
+ const aligned = chars - chars % 4;
3221
+ if (aligned < 4) return void 0;
3222
+ try {
3223
+ return new Uint8Array(Buffer.from(base64.slice(0, aligned), "base64"));
3224
+ } catch {
3225
+ return void 0;
3226
+ }
3227
+ }
3228
+ var HEADER_SCAN_BYTES = 256 * 1024;
3229
+ var estimateCache = /* @__PURE__ */ new Map();
3230
+ var ESTIMATE_CACHE_CAP = 512;
3231
+ function remember(key, value) {
3232
+ if (estimateCache.size >= ESTIMATE_CACHE_CAP && !estimateCache.has(key)) {
3233
+ const oldest = estimateCache.keys().next().value;
3234
+ if (oldest !== void 0) {
3235
+ estimateCache.delete(oldest);
3236
+ }
3237
+ }
3238
+ estimateCache.set(key, value);
3239
+ }
3240
+ function estimateImagePartTokens(part) {
3241
+ if (!part || typeof part !== "object") return 0;
3242
+ const candidate = part;
3243
+ if (candidate.type !== "file") return 0;
3244
+ const url = typeof candidate.url === "string" ? candidate.url : void 0;
3245
+ if (!url) return 0;
3246
+ const parsed = parseImageDataUrl(url);
3247
+ if (!parsed) return 0;
3248
+ const key = `${parsed.mediaType}:${url.length}:${url.slice(-32)}`;
3249
+ const cached = estimateCache.get(key);
3250
+ if (cached !== void 0) return cached;
3251
+ let estimate;
3252
+ const prefix = decodeBase64Prefix(parsed.base64, HEADER_SCAN_BYTES);
3253
+ const dims = prefix ? parseImageDimensions(prefix, parsed.mediaType) : void 0;
3254
+ if (dims) {
3255
+ estimate = estimateVisionTokensForDimensions(dims.width, dims.height);
3256
+ } else {
3257
+ const bytes = decodedBytesFromBase64(parsed.base64);
3258
+ estimate = bytes >= UNKNOWN_DIM_LARGE_BYTES ? VISION_MAX_TILES * TOKENS_PER_TILE : Math.round(parsed.base64.length / 4);
3259
+ }
3260
+ remember(key, estimate);
3261
+ return estimate;
3262
+ }
3263
+
3264
+ // lib/token-utils.ts
3265
+ var anthropicCountTokens = _anthropicTokenizer.countTokens ?? _anthropicTokenizer.default?.countTokens;
3266
+ function countPendingImageTokens(messages, startIndex) {
3267
+ let total = 0;
3268
+ for (let i = Math.max(0, startIndex); i < messages.length; i++) {
3269
+ const parts = Array.isArray(messages[i].parts) ? messages[i].parts : [];
3270
+ for (const part of parts) {
3271
+ if (part.type === "file") {
3272
+ total += estimateImagePartTokens(part);
3273
+ }
3274
+ }
3275
+ }
3276
+ return total;
3277
+ }
3278
+ function getCurrentTokenUsage(state, messages) {
3279
+ for (let i = messages.length - 1; i >= 0; i--) {
3280
+ const msg = messages[i];
3281
+ if (msg.info.role !== "assistant") {
3282
+ continue;
3283
+ }
3284
+ const assistantInfo = msg.info;
3285
+ const input = assistantInfo.tokens?.input || 0;
3286
+ const output = assistantInfo.tokens?.output || 0;
3287
+ const reasoning = assistantInfo.tokens?.reasoning || 0;
3288
+ const cacheRead = assistantInfo.tokens?.cache?.read || 0;
3289
+ const cacheWrite = assistantInfo.tokens?.cache?.write || 0;
3290
+ if (input <= 0 && output <= 0) {
3291
+ continue;
3292
+ }
3293
+ if (state.lastCompaction > 0 && (msg.info.time.created < state.lastCompaction || msg.info.summary === true && msg.info.time.created === state.lastCompaction)) {
3294
+ return 0;
3295
+ }
3296
+ return input + cacheRead + cacheWrite + output + reasoning + countPendingImageTokens(messages, i + 1);
3297
+ }
3298
+ let estimated = 0;
3299
+ for (const m of messages) {
3300
+ estimated += countAllMessageTokens(m);
3301
+ }
3302
+ return estimated;
3303
+ }
3304
+ function estimateSystemPromptTokens(messages) {
3305
+ let firstInput;
3306
+ for (const msg of messages) {
3307
+ if (msg.info.role !== "assistant") continue;
3308
+ const assistantInfo = msg.info;
3309
+ const t = assistantInfo.tokens;
3310
+ if (!t) continue;
3311
+ const input = (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0);
3312
+ if (input > 0) {
3313
+ firstInput = input;
3314
+ break;
3315
+ }
3316
+ }
3317
+ if (firstInput === void 0) return 0;
3318
+ let firstUserText = "";
3319
+ for (const msg of messages) {
3320
+ if (msg.info.role !== "user") continue;
3321
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
3322
+ for (const part of parts) {
3323
+ if (part.type === "text" && typeof part.text === "string") {
3324
+ firstUserText += part.text;
3325
+ }
3326
+ }
3327
+ if (firstUserText) break;
3328
+ }
3329
+ return Math.max(0, firstInput - countTokens(firstUserText));
3330
+ }
3331
+ function getCurrentParams(state, messages, logger) {
3332
+ const userMsg = getLastUserMessage(messages);
3333
+ if (!userMsg) {
3334
+ logger.debug("No user message found when determining current params");
3335
+ return {
3336
+ providerId: void 0,
3337
+ modelId: void 0,
3338
+ agent: void 0,
3339
+ variant: void 0
3340
+ };
3341
+ }
3342
+ const userInfo = userMsg.info;
3343
+ const agent = userInfo.agent;
3344
+ const providerId = userInfo.model.providerID;
3345
+ const modelId = userInfo.model.modelID;
3346
+ const variant = userInfo.model.variant;
3347
+ return { providerId, modelId, agent, variant };
3348
+ }
3349
+ function countTokens(text) {
3350
+ if (!text) return 0;
3351
+ try {
3352
+ return anthropicCountTokens(text);
3353
+ } catch {
3354
+ return Math.round(text.length / 4);
3355
+ }
3356
+ }
3357
+ function estimateTokensBatch(texts) {
3358
+ if (texts.length === 0) return 0;
3359
+ return countTokens(texts.join(" "));
3360
+ }
3361
+ var COMPACTED_TOOL_OUTPUT_PLACEHOLDER = "[Old tool result content cleared]";
3362
+ function stringifyToolContent(value) {
3363
+ return typeof value === "string" ? value : JSON.stringify(value);
3364
+ }
3365
+ function extractCompletedToolOutput(part) {
3366
+ if (part?.type !== "tool" || part.state?.status !== "completed" || part.state?.output === void 0) {
3367
+ return void 0;
3368
+ }
3369
+ if (part.state?.time?.compacted) {
3370
+ return COMPACTED_TOOL_OUTPUT_PLACEHOLDER;
3371
+ }
3372
+ return stringifyToolContent(part.state.output);
3373
+ }
3374
+ function extractToolContent(part) {
3375
+ const contents = [];
3376
+ if (part?.type !== "tool") {
3377
+ return contents;
3378
+ }
3379
+ if (part.state?.input !== void 0) {
3380
+ contents.push(stringifyToolContent(part.state.input));
3381
+ }
3382
+ const completedOutput = extractCompletedToolOutput(part);
3383
+ if (completedOutput !== void 0) {
3384
+ contents.push(completedOutput);
3385
+ } else if (part.state?.status === "error" && part.state?.error) {
3386
+ contents.push(stringifyToolContent(part.state.error));
3387
+ }
3388
+ return contents;
3389
+ }
3390
+ function countAllMessageTokens(msg) {
3391
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
3392
+ const texts = [];
3393
+ let imageTokens = 0;
3394
+ for (const part of parts) {
3395
+ if (part.type === "text") {
3396
+ texts.push(part.text);
3397
+ } else if (part.type === "file") {
3398
+ imageTokens += estimateImagePartTokens(part);
3399
+ } else {
3400
+ texts.push(...extractToolContent(part));
3401
+ }
3402
+ }
3403
+ if (texts.length === 0 && imageTokens === 0) return 0;
3404
+ return imageTokens + (texts.length > 0 ? estimateTokensBatch(texts) : 0);
3405
+ }
3406
+ function countMessageCharacters(msg) {
3407
+ const parts = Array.isArray(msg.parts) ? msg.parts : [];
3408
+ let total = 0;
3409
+ for (const part of parts) {
3410
+ if (part.type === "text" && typeof part.text === "string") {
3411
+ total += part.text.length;
3412
+ } else if (part.type === "file") {
3413
+ total += estimateImagePartTokens(part) * 4;
3414
+ } else {
3415
+ for (const content of extractToolContent(part)) {
3416
+ total += content.length;
3417
+ }
3418
+ }
3419
+ }
3420
+ return total;
3421
+ }
3422
+ function estimateAllMessageTokensFast(msg) {
3423
+ return Math.round(countMessageCharacters(msg) / 4);
3424
+ }
3425
+
3426
+ // lib/prompts/extensions/tool.ts
3427
+ var RANGE_FORMAT_EXTENSION = `
3428
+
3429
+ THE FORMAT OF COMPRESS
3430
+
3431
+ \`\`\`
3432
+ {
3433
+ topic?: string, // OPTIONAL fallback topic for entries without their own.
3434
+ // Omit when every content entry specifies its own topic.
3435
+ content: [ // One or more ranges to compress
3436
+ {
3437
+ topic?: string, // OPTIONAL per-entry topic for this range.
3438
+ // Falls back to top-level topic.
3439
+ // Give each entry its own topic when compressing
3440
+ // unrelated ranges in one call.
3441
+ startId: string, // Boundary ID at range start: mNNNNN or bN
3442
+ endId: string, // Boundary ID at range end: mNNNNN or bN
3443
+ summary: string // Complete technical summary replacing all content in range
3444
+ }
3445
+ ]
3084
3446
  }
3447
+ \`\`\`
3448
+ Each entry needs a topic \u2014 either its own or the top-level fallback.`;
3085
3449
 
3086
3450
  // lib/state/state.ts
3087
- import { existsSync as existsSync3 } from "fs";
3088
- import { join as join3 } from "path";
3451
+ import { existsSync as existsSync4 } from "fs";
3452
+ import { join as join4 } from "path";
3089
3453
  import { cwd } from "process";
3090
3454
 
3091
3455
  // lib/message-ids.ts
@@ -4630,7 +4994,7 @@ function appendMissingBlockSummaries(summary, _missingBlockIds, _summaryByBlockI
4630
4994
  }
4631
4995
 
4632
4996
  // lib/state/rebuild.ts
4633
- import { createHash } from "crypto";
4997
+ import { createHash as createHash2 } from "crypto";
4634
4998
  function collectCompressInvocations(messages) {
4635
4999
  const invocations = [];
4636
5000
  for (const message of messages) {
@@ -4701,7 +5065,7 @@ function fingerprintMessage(message) {
4701
5065
  }
4702
5066
  return { type: part.type };
4703
5067
  });
4704
- return createHash("sha256").update(JSON.stringify({ role: message.info.role, parts })).digest("hex");
5068
+ return createHash2("sha256").update(JSON.stringify({ role: message.info.role, parts })).digest("hex");
4705
5069
  }
4706
5070
  function mapForkIds(state, parent, parentMessages, forkMessages) {
4707
5071
  const parentRefs = parent.messageIds?.byRef;
@@ -4828,7 +5192,7 @@ function rebuildRangeInvocation(state, input, searchContext, invocation, protect
4828
5192
  ]);
4829
5193
  const blockId = allocateBlockId(state);
4830
5194
  const storedSummary = wrapCompressedSummary(blockId, plan.entry.summary);
4831
- const summaryTokens = countTokens2(storedSummary);
5195
+ const summaryTokens = countTokens(storedSummary);
4832
5196
  applyCompressionState(
4833
5197
  state,
4834
5198
  {
@@ -4890,7 +5254,7 @@ function rebuildMessageInvocation(state, input, searchContext, invocation, gcCon
4890
5254
  }
4891
5255
  const blockId = allocateBlockId(state);
4892
5256
  const storedSummary = wrapCompressedSummary(blockId, entry.summary);
4893
- const summaryTokens = countTokens2(storedSummary);
5257
+ const summaryTokens = countTokens(storedSummary);
4894
5258
  applyCompressionState(
4895
5259
  state,
4896
5260
  {
@@ -5124,7 +5488,9 @@ function createSessionState() {
5124
5488
  qualityGateRetryPending: false,
5125
5489
  noContextLimitWarned: false,
5126
5490
  imageArchive: {},
5127
- imagePruneDone: false
5491
+ imagePruneDone: false,
5492
+ imageSurgeNudged: {},
5493
+ stickyTruncations: {}
5128
5494
  };
5129
5495
  }
5130
5496
  function resetSessionState(state) {
@@ -5170,6 +5536,8 @@ function resetSessionState(state) {
5170
5536
  state.noContextLimitWarned = false;
5171
5537
  state.imageArchive = {};
5172
5538
  state.imagePruneDone = false;
5539
+ state.imageSurgeNudged = {};
5540
+ state.stickyTruncations = {};
5173
5541
  }
5174
5542
  async function ensureSessionInitialized(client, state, sessionId, logger, messages, config, projectDir) {
5175
5543
  if (state.sessionId === sessionId) {
@@ -5186,8 +5554,8 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
5186
5554
  state.nudges.turnNudgeAnchors = collectTurnNudgeAnchors(messages);
5187
5555
  const persisted = await loadSessionState(sessionId, logger, state.storageDir);
5188
5556
  if (persisted === null) {
5189
- const defaultPath = join3(getDefaultStorageDir(), `${sessionId}.json`);
5190
- if (state.storageDir && existsSync3(defaultPath)) {
5557
+ const defaultPath = join4(getDefaultStorageDir(), `${sessionId}.json`);
5558
+ if (state.storageDir && existsSync4(defaultPath)) {
5191
5559
  logger.warn(
5192
5560
  "storagePath is set but no valid state was found there; a state file exists at the default location \u2014 move it manually to keep history",
5193
5561
  { sessionId, storageDir: state.storageDir, defaultPath }
@@ -5198,6 +5566,13 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
5198
5566
  if (parentSessionId) {
5199
5567
  try {
5200
5568
  const parent = await loadSessionState(parentSessionId, logger);
5569
+ if (parent?.stickyTruncations) {
5570
+ for (const [callID, output] of Object.entries(parent.stickyTruncations)) {
5571
+ if (typeof output === "string") {
5572
+ state.stickyTruncations[callID] = output;
5573
+ }
5574
+ }
5575
+ }
5201
5576
  const response = parent ? await client.session.messages({ path: { id: parentSessionId } }) : void 0;
5202
5577
  const parentMessages = Array.isArray(response?.data) ? response.data : Array.isArray(response) ? response : [];
5203
5578
  if (parent && parentMessages.length > 0) {
@@ -5263,6 +5638,21 @@ async function ensureSessionInitialized(client, state, sessionId, logger, messag
5263
5638
  }
5264
5639
  }
5265
5640
  }
5641
+ if (persisted.imageSurgeNudged && typeof persisted.imageSurgeNudged === "object") {
5642
+ for (const [key, ts] of Object.entries(persisted.imageSurgeNudged)) {
5643
+ if (typeof key === "string" && typeof ts === "number" && Number.isFinite(ts)) {
5644
+ state.imageSurgeNudged[key] = ts;
5645
+ }
5646
+ }
5647
+ }
5648
+ const persistedSticky = persisted.stickyTruncations;
5649
+ if (persistedSticky && typeof persistedSticky === "object") {
5650
+ for (const [callID, output] of Object.entries(persistedSticky)) {
5651
+ if (typeof output === "string") {
5652
+ state.stickyTruncations[callID] = output;
5653
+ }
5654
+ }
5655
+ }
5266
5656
  const persistedAny = persisted;
5267
5657
  if (persistedAny._persistedMessageIds) {
5268
5658
  state.messageIds = {
@@ -5438,7 +5828,7 @@ function cacheSystemPromptTokens(state, messages) {
5438
5828
  }
5439
5829
  break;
5440
5830
  }
5441
- const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens2(firstUserText));
5831
+ const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText));
5442
5832
  state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : void 0;
5443
5833
  }
5444
5834
 
@@ -6675,156 +7065,6 @@ function buildPhantomSkipNotice(identification) {
6675
7065
  return `Skipped ${identification.phantomIndices.length} already-compressed ${noun} (${skippedNums}) \u2014 they are no-ops; the remaining entries were compressed.`;
6676
7066
  }
6677
7067
 
6678
- // lib/compress/image-archive.ts
6679
- import { createHash as createHash2 } from "crypto";
6680
- import {
6681
- existsSync as existsSync4,
6682
- mkdirSync as mkdirSync2,
6683
- readdirSync,
6684
- rmdirSync,
6685
- statSync as statSync2,
6686
- unlinkSync,
6687
- writeFileSync as writeFileSync2
6688
- } from "fs";
6689
- import { join as join4 } from "path";
6690
- var DATA_URL_REGEX = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/is;
6691
- var IMAGE_EXTENSIONS = {
6692
- "image/png": "png",
6693
- "image/jpeg": "jpg",
6694
- "image/jpg": "jpg",
6695
- "image/gif": "gif",
6696
- "image/webp": "webp",
6697
- "image/avif": "avif",
6698
- "image/bmp": "bmp"
6699
- };
6700
- function sha8(input) {
6701
- return createHash2("sha256").update(input).digest("hex").slice(0, 8);
6702
- }
6703
- function parseImageDataUrl(url) {
6704
- if (typeof url !== "string") return void 0;
6705
- const match = DATA_URL_REGEX.exec(url);
6706
- if (!match) return void 0;
6707
- return { mediaType: match[1].toLowerCase(), base64: match[2] };
6708
- }
6709
- function extForMediaType(mediaType) {
6710
- return IMAGE_EXTENSIONS[mediaType.toLowerCase()];
6711
- }
6712
- function archiveDirName(sessionId) {
6713
- let name = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 100);
6714
- if (name !== sessionId) {
6715
- name = `${name}-${sha8(sessionId)}`;
6716
- }
6717
- return name || "-";
6718
- }
6719
- function archiveRoot(storageDir, sessionId) {
6720
- return join4(storageDir ?? getDefaultStorageDir(), "images", archiveDirName(sessionId));
6721
- }
6722
- function archiveSessionImages(state, messages, images, logger) {
6723
- if (!images.enabled || !state.sessionId) return 0;
6724
- const root = archiveRoot(state.storageDir, state.sessionId);
6725
- let archived = 0;
6726
- for (const message of messages) {
6727
- const rawId = message.info.id;
6728
- if (!rawId) continue;
6729
- const parts = Array.isArray(message.parts) ? message.parts : [];
6730
- for (let partIndex = 0; partIndex < parts.length; partIndex++) {
6731
- const part = parts[partIndex];
6732
- if (part.type !== "file") continue;
6733
- const parsed = parseImageDataUrl(part.url);
6734
- if (!parsed) continue;
6735
- const ext = extForMediaType(parsed.mediaType);
6736
- if (!ext) continue;
6737
- const fileName = `${sha8(rawId)}-p${partIndex}-${sha8(parsed.base64)}.${ext}`;
6738
- const filePath = join4(root, fileName);
6739
- const existing = state.imageArchive[rawId]?.find((e) => e.path === filePath);
6740
- if (existing) continue;
6741
- let bytes;
6742
- try {
6743
- if (existsSync4(filePath)) {
6744
- bytes = statSync2(filePath).size;
6745
- } else {
6746
- const buffer = Buffer.from(parsed.base64, "base64");
6747
- bytes = buffer.byteLength;
6748
- if (bytes < images.minBytes) continue;
6749
- mkdirSync2(root, { recursive: true });
6750
- writeFileSync2(filePath, buffer, { mode: 384 });
6751
- }
6752
- } catch (error) {
6753
- logger.warn(
6754
- `[#478] failed to archive image ${filePath}: ${error instanceof Error ? error.message : String(error)}`
6755
- );
6756
- continue;
6757
- }
6758
- const entry = {
6759
- path: filePath,
6760
- mediaType: parsed.mediaType,
6761
- bytes
6762
- };
6763
- if (!state.imageArchive[rawId]) {
6764
- state.imageArchive[rawId] = [];
6765
- }
6766
- state.imageArchive[rawId].push(entry);
6767
- state.stats.imagesArchived = (state.stats.imagesArchived ?? 0) + 1;
6768
- state.stats.imageBytesArchived = (state.stats.imageBytesArchived ?? 0) + bytes;
6769
- archived++;
6770
- }
6771
- }
6772
- return archived;
6773
- }
6774
- function pruneImageArchive(state, images, logger) {
6775
- if (state.imagePruneDone || !state.sessionId) return;
6776
- state.imagePruneDone = true;
6777
- try {
6778
- const root = archiveRoot(state.storageDir, state.sessionId);
6779
- if (!existsSync4(root)) return;
6780
- const cutoff = Date.now() - images.ttlDays * 864e5;
6781
- const prunedPaths = /* @__PURE__ */ new Set();
6782
- for (const name of readdirSync(root)) {
6783
- const filePath = join4(root, name);
6784
- try {
6785
- if (statSync2(filePath).mtimeMs < cutoff) {
6786
- unlinkSync(filePath);
6787
- prunedPaths.add(filePath);
6788
- }
6789
- } catch {
6790
- }
6791
- }
6792
- if (prunedPaths.size > 0) {
6793
- for (const [rawId, entries] of Object.entries(state.imageArchive)) {
6794
- const kept = entries.filter((e) => !prunedPaths.has(e.path));
6795
- if (kept.length === 0) {
6796
- delete state.imageArchive[rawId];
6797
- } else if (kept.length !== entries.length) {
6798
- state.imageArchive[rawId] = kept;
6799
- }
6800
- }
6801
- logger.info(`[#478] TTL sweep removed ${prunedPaths.size} expired archived image(s)`);
6802
- }
6803
- if (readdirSync(root).length === 0) {
6804
- rmdirSync(root);
6805
- }
6806
- } catch (error) {
6807
- logger.warn(
6808
- `[#478] image TTL sweep failed: ${error instanceof Error ? error.message : String(error)}`
6809
- );
6810
- }
6811
- }
6812
- function appendArchivedImagePointers(summary, selection, state, enabled) {
6813
- if (!enabled) return summary;
6814
- const lines = [];
6815
- for (const messageId of selection.messageIds) {
6816
- const entries = state.imageArchive[messageId];
6817
- if (!entries || entries.length === 0) continue;
6818
- const ref = state.messageIds.byRawId.get(messageId) ?? messageId;
6819
- for (const entry of entries) {
6820
- lines.push(`- ${ref}: ${entry.path} (${entry.mediaType}, ${entry.bytes} bytes)`);
6821
- }
6822
- }
6823
- if (lines.length === 0) return summary;
6824
- const heading = "\n\nImages captured in this range were archived to disk (their pixels are NOT summarized). Re-read them with your file tools if you need the pixels again:";
6825
- return summary + heading + "\n" + lines.join("\n");
6826
- }
6827
-
6828
7068
  // lib/compress/keep-markers.ts
6829
7069
  var KEEP_REGEX = /\[\[KEEP:(m\d+)\]\]/g;
6830
7070
  var REF_REGEX = /\[\[REF:(m\d+)\|([^\]]+)\]\]/g;
@@ -7576,6 +7816,7 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
7576
7816
  let messageTokens = 0;
7577
7817
  let protectedTokens = 0;
7578
7818
  let reasoningTokens = 0;
7819
+ let imageTokens = 0;
7579
7820
  const perMessage = [];
7580
7821
  const perTool = [];
7581
7822
  const perCode = [];
@@ -7633,6 +7874,12 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
7633
7874
  const tokens = Math.round(part.text.length / 4);
7634
7875
  msgTotal += tokens;
7635
7876
  reasoningTokens += tokens;
7877
+ } else if (part.type === "file") {
7878
+ const imgTokens = estimateImagePartTokens(part);
7879
+ if (imgTokens > 0) {
7880
+ msgTotal += imgTokens;
7881
+ imageTokens += imgTokens;
7882
+ }
7636
7883
  }
7637
7884
  }
7638
7885
  if (isProtected && !isSummary) {
@@ -7661,7 +7908,8 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
7661
7908
  systemTokens,
7662
7909
  protectedTokens,
7663
7910
  reasoningTokens,
7664
- total: systemTokens + toolTokens + summaryTokens + messageTokens + reasoningTokens,
7911
+ imageTokens,
7912
+ total: systemTokens + toolTokens + summaryTokens + messageTokens + reasoningTokens + imageTokens,
7665
7913
  largestRanges: perMessage.slice(0, 15),
7666
7914
  largestToolRanges: perTool.slice(0, 15),
7667
7915
  largestCodeRanges: perCode.slice(0, 5),
@@ -7942,6 +8190,8 @@ function computeProtectedRefs(messages, state, compress) {
7942
8190
  for (const part of msg.parts || []) {
7943
8191
  if (part.type === "text" && typeof part.text === "string") {
7944
8192
  tokens += Math.round(part.text.length / 4);
8193
+ } else if (part.type === "file") {
8194
+ tokens += estimateImagePartTokens(part);
7945
8195
  } else if (part.type !== "text" && part.type !== "reasoning") {
7946
8196
  tokens += Math.round(JSON.stringify(part).length / 4);
7947
8197
  }
@@ -8359,7 +8609,7 @@ function createCompressRangeTool(factoryCtx) {
8359
8609
  );
8360
8610
  preparedPlan.finalSummary = keepResult.summary;
8361
8611
  const storedSummary = wrapCompressedSummary(blockId, preparedPlan.finalSummary);
8362
- const summaryTokens = countTokens2(storedSummary);
8612
+ const summaryTokens = countTokens(storedSummary);
8363
8613
  const applied = applyCompressionState(
8364
8614
  ctx.state,
8365
8615
  {
@@ -8696,6 +8946,73 @@ var syncCompressPermissionState = (state, config, hostPermissions, messages) =>
8696
8946
  );
8697
8947
  };
8698
8948
 
8949
+ // lib/messages/large-images.ts
8950
+ var IMAGE_SURGE_DEDUP_CAP = 512;
8951
+ function detectNewLargeImages(messages, state, largeBytes) {
8952
+ if (!Number.isFinite(largeBytes) || largeBytes <= 0) return [];
8953
+ const nudged = state.imageSurgeNudged ??= {};
8954
+ const found = [];
8955
+ for (const message of messages) {
8956
+ const rawId = message.info.id;
8957
+ if (!rawId) continue;
8958
+ const ref = state.messageIds.byRawId.get(rawId);
8959
+ if (!ref) continue;
8960
+ const parts = Array.isArray(message.parts) ? message.parts : [];
8961
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
8962
+ const part = parts[partIndex];
8963
+ if (part.type !== "file") continue;
8964
+ const parsed = parseImageDataUrl(part.url);
8965
+ if (!parsed) continue;
8966
+ if (!extForMediaType(parsed.mediaType)) continue;
8967
+ const bytes = decodedBytesFromBase64(parsed.base64);
8968
+ if (bytes < largeBytes) continue;
8969
+ const slotPrefix = `${sha8(rawId)}:p${partIndex}:`;
8970
+ let slotSeen = false;
8971
+ for (const key2 of Object.keys(nudged)) {
8972
+ if (key2.startsWith(slotPrefix)) {
8973
+ slotSeen = true;
8974
+ break;
8975
+ }
8976
+ }
8977
+ if (slotSeen) continue;
8978
+ const key = `${slotPrefix}${sha8(parsed.base64)}`;
8979
+ if (nudged[key] !== void 0) continue;
8980
+ nudged[key] = Date.now();
8981
+ found.push({
8982
+ ref,
8983
+ mediaType: parsed.mediaType,
8984
+ bytes,
8985
+ estimatedTokens: estimateImagePartTokens(part)
8986
+ });
8987
+ }
8988
+ }
8989
+ const keys = Object.keys(nudged);
8990
+ if (keys.length > IMAGE_SURGE_DEDUP_CAP) {
8991
+ for (const key of keys.slice(0, keys.length - IMAGE_SURGE_DEDUP_CAP)) {
8992
+ delete nudged[key];
8993
+ }
8994
+ }
8995
+ return found;
8996
+ }
8997
+ function formatBytes(bytes) {
8998
+ if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
8999
+ return `${Math.max(1, Math.round(bytes / 1024))} KB`;
9000
+ }
9001
+ function formatLargeImageNotice(images) {
9002
+ const items = images.map(
9003
+ (image) => `- ${image.ref}: ${image.mediaType}, ${formatBytes(image.bytes)} (\u2248${formatTokens(image.estimatedTokens)} vision tokens; far more if your provider bills base64 as text)`
9004
+ ).join("\n");
9005
+ return `
9006
+
9007
+ \u{1F5BC}\uFE0F Large image(s) just entered context:
9008
+ ${items}
9009
+ Before doing anything else, make room: call the \`compress\` tool NOW on older conversation ranges you no longer need (acp_status lists them). Once you are done analyzing the image(s), compress the range(s) containing them \u2014 archived copies stay re-readable via their disk pointers.`;
9010
+ }
9011
+ function formatTokens(n) {
9012
+ if (!Number.isFinite(n) || n <= 0) return "0";
9013
+ return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
9014
+ }
9015
+
8699
9016
  // lib/prompts/context-limit-nudge.ts
8700
9017
  var CONTEXT_LIMIT_NUDGE = `
8701
9018
  <system-reminder>
@@ -9513,6 +9830,21 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
9513
9830
  let shouldInjectNotice = emergencyNoTargets && noticeCadenceMet;
9514
9831
  let shouldInject = shouldInjectNudge || shouldInjectNotice;
9515
9832
  let armedSmartPlan;
9833
+ const imagesConfig = config.compress?.images ?? DEFAULT_COMPRESS_IMAGES;
9834
+ let imageNoticeEmitted = false;
9835
+ if (suffixMessage && imagesConfig.nudge !== false && imagesConfig.largeBytes > 0) {
9836
+ const newLargeImages = detectNewLargeImages(messages, state, imagesConfig.largeBytes);
9837
+ if (newLargeImages.length > 0) {
9838
+ appendToLastTextPart(suffixMessage, formatLargeImageNotice(newLargeImages));
9839
+ imageNoticeEmitted = true;
9840
+ saveSessionState(state, logger).catch(() => {
9841
+ });
9842
+ logger.info("Large image notice injected", {
9843
+ session: state.sessionId,
9844
+ images: newLargeImages.map((image) => `${image.ref}:${image.mediaType}:${image.bytes}B`)
9845
+ });
9846
+ }
9847
+ }
9516
9848
  if (shouldInjectNudge && config.compress.smartPlanRequired === true && state.sessionId) {
9517
9849
  const planningRanges = candidatesEnabled ? candidatePlan.candidates.map((candidate) => ({
9518
9850
  startRef: candidate.startRef,
@@ -9575,7 +9907,7 @@ ${ITERATION_CANDIDATE_GUIDANCE}` : ITERATION_CANDIDATE_GUIDANCE
9575
9907
  state.nudges.lastPerMessageNudgeTokens = currentTokens;
9576
9908
  baselineReEstablished = true;
9577
9909
  }
9578
- if (suffixMessage && !shouldInject) {
9910
+ if (suffixMessage && !shouldInject && !imageNoticeEmitted) {
9579
9911
  const tierUsage = tierUsageEarly;
9580
9912
  const tierChecks = [
9581
9913
  {
@@ -9678,7 +10010,7 @@ ${rules}`;
9678
10010
  break;
9679
10011
  }
9680
10012
  }
9681
- state.nudges.shouldInjectThisTurn = shouldInject;
10013
+ state.nudges.shouldInjectThisTurn = shouldInject || imageNoticeEmitted;
9682
10014
  const usagePct = currentTokens !== void 0 && modelContextLimit ? `${(currentTokens / modelContextLimit * 100).toFixed(1)}%` : void 0;
9683
10015
  if (shouldInjectNudge) {
9684
10016
  logger.info("Compression nudge injected", {
@@ -9738,7 +10070,7 @@ ${rules}`;
9738
10070
  const efficiencyNote = effectiveTipsVariant !== "maxLimit" ? candidatesEnabled ? "\nThis is an efficiency nudge to compress early when content is no longer needed and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.\n\n" + COMPRESS_PHILOSOPHY : "\nThis is an efficiency nudge to compress early and keep context lean \u2014 not an overflow warning. A separate, stronger alert will appear if the context is actually full.\n\n" + COMPRESS_PHILOSOPHY : "";
9739
10071
  const sysPart = composition.systemTokens > 0 ? `${fmt(composition.systemTokens)} system (${pct2(composition.systemTokens)}%) | ` : "";
9740
10072
  let breakdown = `${efficiencyNote}
9741
- Breakdown: ${sysPart}${fmt(composition.toolTokens)} tool (${pct2(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct2(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct2(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct2(plainTextTokens)}%) | ${fmt(composition.reasoningTokens)} reasoning (${pct2(composition.reasoningTokens)}%)${growthStr}`;
10073
+ Breakdown: ${sysPart}${fmt(composition.toolTokens)} tool (${pct2(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct2(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct2(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct2(plainTextTokens)}%) | ${fmt(composition.reasoningTokens)} reasoning (${pct2(composition.reasoningTokens)}%) | ${fmt(composition.imageTokens)} images (${pct2(composition.imageTokens)}%)${growthStr}`;
9742
10074
  const compressibleTokens = composition.total - composition.systemTokens - composition.protectedTokens - composition.summaryTokens;
9743
10075
  if (composition.protectedTokens > 0) {
9744
10076
  breakdown += `
@@ -10698,7 +11030,7 @@ Use this tool to:
10698
11030
  - Focus on ranges only (scope:"uncompressed")
10699
11031
  - Find all messages of a specific tool type (scope:"uncompressed", view:"messages", tool:"bash")
10700
11032
  - Check block details before decompressing (scope:"compressed")`;
10701
- function formatTokens(n) {
11033
+ function formatTokens2(n) {
10702
11034
  if (!Number.isFinite(n) || n <= 0) return "0";
10703
11035
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
10704
11036
  }
@@ -10742,7 +11074,7 @@ function tierBreakdown(blocks) {
10742
11074
  const parts = [];
10743
11075
  for (const t of [1, 2, 3]) {
10744
11076
  if (tierTokens[t]) {
10745
- parts.push(`T${t}: ${formatTokens(tierTokens[t])}`);
11077
+ parts.push(`T${t}: ${formatTokens2(tierTokens[t])}`);
10746
11078
  }
10747
11079
  }
10748
11080
  return parts.join(" | ");
@@ -10768,10 +11100,13 @@ function collectVisibleMessages(rawMessages, ctx) {
10768
11100
  if (!ref) return;
10769
11101
  let tokens = 0;
10770
11102
  let reasoning = 0;
11103
+ let image = 0;
10771
11104
  let toolName = "";
10772
11105
  for (const part of msg.parts || []) {
10773
11106
  if (part.type === "text" && typeof part.text === "string") {
10774
11107
  tokens += Math.round(part.text.length / 4);
11108
+ } else if (part.type === "file") {
11109
+ image += estimateImagePartTokens(part);
10775
11110
  } else if (part.type === "tool") {
10776
11111
  const raw = JSON.stringify(part);
10777
11112
  tokens += Math.round(raw.length / 4);
@@ -10782,8 +11117,8 @@ function collectVisibleMessages(rawMessages, ctx) {
10782
11117
  reasoning += Math.round(part.text.length / 4);
10783
11118
  }
10784
11119
  }
10785
- if (tokens > 0 || reasoning > 0) {
10786
- result.push({ ref, tokens, tool: toolName || "text", index: idx, reasoning });
11120
+ if (tokens > 0 || reasoning > 0 || image > 0) {
11121
+ result.push({ ref, tokens, tool: toolName || "text", index: idx, reasoning, image });
10787
11122
  }
10788
11123
  });
10789
11124
  return {
@@ -10806,15 +11141,17 @@ function renderOverview(visibleMessages, summaryTokens, systemTokens, blocks, fe
10806
11141
  const totalTool = visibleMessages.filter((m) => m.tool !== "text" && m.tool !== "step-finish").reduce((s, m) => s + m.tokens, 0);
10807
11142
  const totalText = visibleMessages.filter((m) => m.tool === "text").reduce((s, m) => s + m.tokens, 0);
10808
11143
  const totalReasoning = visibleMessages.reduce((s, m) => s + m.reasoning, 0);
10809
- const total = systemTokens + totalTool + totalText + summaryTokens + totalReasoning;
11144
+ const totalImage = visibleMessages.reduce((s, m) => s + m.image, 0);
11145
+ const total = systemTokens + totalTool + totalText + summaryTokens + totalReasoning + totalImage;
10810
11146
  const sysPct = pct(systemTokens, total);
10811
11147
  const toolPct = pct(totalTool, total);
10812
11148
  const textPct = pct(totalText, total);
10813
11149
  const summaryPct = pct(summaryTokens, total);
10814
11150
  const reasoningPct = pct(totalReasoning, total);
11151
+ const imagePct = pct(totalImage, total);
10815
11152
  lines.push("CONTEXT BREAKDOWN");
10816
11153
  lines.push(
10817
- ` ${formatTokens(systemTokens)} system (${sysPct}%) | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%) | ${formatTokens(totalReasoning)} reasoning (${reasoningPct}%)`
11154
+ ` ${formatTokens2(systemTokens)} system (${sysPct}%) | ${formatTokens2(totalTool)} tool (${toolPct}%) | ${formatTokens2(totalText)} text (${textPct}%) | ${formatTokens2(summaryTokens)} summaries (${summaryPct}%) | ${formatTokens2(totalReasoning)} reasoning (${reasoningPct}%) | ${formatTokens2(totalImage)} images (${imagePct}%)`
10818
11155
  );
10819
11156
  const topTypes = Array.from(toolTypeMap.entries()).map(([tool6, tokens]) => ({ tool: tool6, tokens })).sort((a, b) => b.tokens - a.tokens).slice(0, 3);
10820
11157
  if (topTypes.length > 0) {
@@ -10834,7 +11171,7 @@ function renderOverview(visibleMessages, summaryTokens, systemTokens, blocks, fe
10834
11171
  (s, b) => s + getEffectiveCompressedTokens(b, blocksById),
10835
11172
  0
10836
11173
  );
10837
- const header = `COMPRESSED BLOCKS \u2014 ${blocks.length} active (${formatTokens(totalSummary)} summary, ${formatTokens(totalEffective)} original)`;
11174
+ const header = `COMPRESSED BLOCKS \u2014 ${blocks.length} active (${formatTokens2(totalSummary)} summary, ${formatTokens2(totalEffective)} original)`;
10838
11175
  lines.push(header);
10839
11176
  const breakdown = tierBreakdown(blocks);
10840
11177
  if (breakdown) {
@@ -10853,7 +11190,7 @@ function renderOverview(visibleMessages, summaryTokens, systemTokens, blocks, fe
10853
11190
  const tier = tierLabel(b);
10854
11191
  const effTokens = getEffectiveCompressedTokens(b, blocksById);
10855
11192
  lines.push(
10856
- ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}\u2192${formatTokens(b.summaryTokens)} ${ageStr} ${range} "${topic}"`
11193
+ ` b${b.blockId} (${tier}) ${formatTokens2(effTokens)}\u2192${formatTokens2(b.summaryTokens)} ${ageStr} ${range} "${topic}"`
10857
11194
  );
10858
11195
  }
10859
11196
  }
@@ -10914,7 +11251,7 @@ function renderUncompressedRanges(rawMessages, ctx) {
10914
11251
  const totalMsgs = compressible.reduce((s, r) => s + r.count, 0);
10915
11252
  const lines = [];
10916
11253
  lines.push(
10917
- `UNCOMPRESSED \u2014 ${formatTokens(totalTokens)} | ${totalMsgs} msgs in ${compressible.length} ranges`
11254
+ `UNCOMPRESSED \u2014 ${formatTokens2(totalTokens)} | ${totalMsgs} msgs in ${compressible.length} ranges`
10918
11255
  );
10919
11256
  lines.push("");
10920
11257
  if (compressible.length === 0 && contextRanges.protected.length === 0) {
@@ -10971,7 +11308,7 @@ function renderUncompressedDrilldown(visibleMessages, toolFilter, sort, limit) {
10971
11308
  if (toolFilter) {
10972
11309
  filtered = filtered.filter((m) => m.tool === toolFilter);
10973
11310
  }
10974
- const sizeOf = (m) => m.tokens + m.reasoning;
11311
+ const sizeOf = (m) => m.tokens + m.reasoning + m.image;
10975
11312
  if (sort === "time") {
10976
11313
  filtered.sort((a, b) => a.index - b.index);
10977
11314
  } else if (sort === "tool") {
@@ -10981,13 +11318,13 @@ function renderUncompressedDrilldown(visibleMessages, toolFilter, sort, limit) {
10981
11318
  }
10982
11319
  const totalTokens = filtered.reduce((s, m) => s + sizeOf(m), 0);
10983
11320
  const allTokens = visibleMessages.reduce((s, m) => s + sizeOf(m), 0);
10984
- const header = toolFilter ? `UNCOMPRESSED \u2014 ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` : `UNCOMPRESSED \u2014 ${formatTokens(totalTokens)} | ${filtered.length} msgs`;
11321
+ const header = toolFilter ? `UNCOMPRESSED \u2014 ${toolFilter}: ${formatTokens2(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` : `UNCOMPRESSED \u2014 ${formatTokens2(totalTokens)} | ${filtered.length} msgs`;
10985
11322
  lines.push(header);
10986
11323
  lines.push(`Sorted by ${sort}`);
10987
11324
  lines.push("");
10988
11325
  const shown = filtered.slice(0, limit);
10989
11326
  for (const m of shown) {
10990
- lines.push(` ${m.ref} (${formatTokens(sizeOf(m))}) ${m.tool}`);
11327
+ lines.push(` ${m.ref} (${formatTokens2(sizeOf(m))}) ${m.tool}`);
10991
11328
  }
10992
11329
  if (filtered.length > shown.length) {
10993
11330
  lines.push("");
@@ -11026,7 +11363,7 @@ function renderCompressedDrilldown(blocks, sort, limit, blocksById) {
11026
11363
  0
11027
11364
  );
11028
11365
  lines.push(
11029
- `COMPRESSED \u2014 ${sorted.length} blocks | ${formatTokens(totalEffective)} original \u2192 ${formatTokens(totalSummary)} summary`
11366
+ `COMPRESSED \u2014 ${sorted.length} blocks | ${formatTokens2(totalEffective)} original \u2192 ${formatTokens2(totalSummary)} summary`
11030
11367
  );
11031
11368
  const breakdown = tierBreakdown(sorted);
11032
11369
  if (breakdown) {
@@ -11051,7 +11388,7 @@ function renderCompressedDrilldown(blocks, sort, limit, blocksById) {
11051
11388
  const tier = tierLabel(b);
11052
11389
  const effTokens = getEffectiveCompressedTokens(b, blocksById);
11053
11390
  lines.push(
11054
- ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}\u2192${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}${status}`
11391
+ ` b${b.blockId} (${tier}) ${formatTokens2(effTokens)}\u2192${formatTokens2(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}${status}`
11055
11392
  );
11056
11393
  lines.push(` "${topic}"`);
11057
11394
  }
@@ -11329,7 +11666,7 @@ import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
11329
11666
  import { join as join5 } from "path";
11330
11667
  import { existsSync as existsSync5 } from "fs";
11331
11668
  import { homedir as homedir3 } from "os";
11332
- var LOG_VERSION = true ? "1.18.4" : "dev";
11669
+ var LOG_VERSION = true ? "1.18.5-pr.490.223" : "dev";
11333
11670
  var LEVEL_RANK = {
11334
11671
  debug: 10,
11335
11672
  info: 20,
@@ -11780,6 +12117,8 @@ NOTE: Message-mode blocks created in the same batch (same runId) are restored to
11780
12117
  Decompressing one block from a batch restores all blocks in that batch.
11781
12118
  </dcp-system-reminder>
11782
12119
  `;
12120
+ var IMAGE_GUARD_SYSTEM_EXTENSION = `
12121
+ IMAGE HANDLING: Before reading an image file into context, make sure there is room: if context usage is high, compress older conversation ranges first. A large image consumes significant context for as long as it stays uncompressed. Once you are done analyzing an image, prefer compressing the range that contains it \u2014 archived images remain re-readable through their disk pointers.`;
11783
12122
 
11784
12123
  // lib/prompts/store.ts
11785
12124
  var PROMPT_DEFINITIONS = [
@@ -12116,7 +12455,7 @@ var PromptStore = class {
12116
12455
  };
12117
12456
 
12118
12457
  // lib/prompts/index.ts
12119
- function renderSystemPrompt(prompts, protectedToolsExtension, subagent) {
12458
+ function renderSystemPrompt(prompts, protectedToolsExtension, subagent, imageGuardExtension) {
12120
12459
  const extensions = [];
12121
12460
  if (protectedToolsExtension) {
12122
12461
  extensions.push(protectedToolsExtension.trim());
@@ -12124,6 +12463,9 @@ function renderSystemPrompt(prompts, protectedToolsExtension, subagent) {
12124
12463
  if (subagent) {
12125
12464
  extensions.push(prompts.subagentExtension.trim());
12126
12465
  }
12466
+ if (imageGuardExtension) {
12467
+ extensions.push(imageGuardExtension.trim());
12468
+ }
12127
12469
  extensions.push(prompts.decompressExtension.trim());
12128
12470
  return [prompts.system.trim(), ...extensions].filter(Boolean).join("\n\n").replace(/\n([ \t]*\n)+/g, "\n\n").trim();
12129
12471
  }
@@ -12184,11 +12526,12 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
12184
12526
  const part = parts[pi];
12185
12527
  if (part?.type !== "tool") continue;
12186
12528
  if (part.state?.status !== "completed") continue;
12529
+ if (part.tool === "compress") continue;
12187
12530
  const content = extractCompletedToolOutput(part);
12188
12531
  if (content === void 0) continue;
12189
12532
  if (content === "[Old tool result content cleared]") continue;
12190
12533
  if (content.includes(TRUNCATION_MARKER)) continue;
12191
- const tokens = countTokens2(content);
12534
+ const tokens = countTokens(content);
12192
12535
  if (tokens < MIN_OUTPUT_TOKENS) continue;
12193
12536
  candidates.push({ part, content, tokens });
12194
12537
  }
@@ -12209,7 +12552,7 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
12209
12552
 
12210
12553
  ` + suffix;
12211
12554
  part.state.output = truncated;
12212
- savedTokens += tokens - countTokens2(truncated);
12555
+ savedTokens += tokens - countTokens(truncated);
12213
12556
  truncatedCount++;
12214
12557
  }
12215
12558
  if (truncatedCount > 0) {
@@ -12224,6 +12567,74 @@ function truncateLargeToolOutputs(state, config, logger, messages) {
12224
12567
  }
12225
12568
  }
12226
12569
 
12570
+ // lib/messages/sticky-truncation.ts
12571
+ function collectToolParts(messages) {
12572
+ const parts = [];
12573
+ for (const message of messages) {
12574
+ const messageParts = Array.isArray(message.parts) ? message.parts : [];
12575
+ for (const part of messageParts) {
12576
+ if (part?.type === "tool") {
12577
+ parts.push(part);
12578
+ }
12579
+ }
12580
+ }
12581
+ return parts;
12582
+ }
12583
+ function applyStickyTruncations(state, messages, logger) {
12584
+ const frozen = state.stickyTruncations;
12585
+ if (Object.keys(frozen).length === 0) return;
12586
+ let replayed = 0;
12587
+ const seen = /* @__PURE__ */ new Set();
12588
+ for (const part of collectToolParts(messages)) {
12589
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12590
+ if (!callID) continue;
12591
+ seen.add(callID);
12592
+ const recorded = frozen[callID];
12593
+ if (recorded === void 0) continue;
12594
+ if (!part.state) continue;
12595
+ if (part.state.output === recorded) continue;
12596
+ part.state.output = recorded;
12597
+ replayed++;
12598
+ }
12599
+ for (const callID of Object.keys(frozen)) {
12600
+ if (!seen.has(callID)) delete frozen[callID];
12601
+ }
12602
+ if (replayed > 0) {
12603
+ logger.debug("Sticky truncation: replayed frozen tool outputs", {
12604
+ replayed,
12605
+ frozen: Object.keys(frozen).length
12606
+ });
12607
+ }
12608
+ }
12609
+ function snapshotToolOutputs(messages) {
12610
+ const snapshot = /* @__PURE__ */ new Map();
12611
+ for (const part of collectToolParts(messages)) {
12612
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12613
+ if (!callID) continue;
12614
+ const content = extractCompletedToolOutput(part);
12615
+ if (content === void 0) continue;
12616
+ snapshot.set(callID, content);
12617
+ }
12618
+ return snapshot;
12619
+ }
12620
+ function recordStickyTruncations(state, messages, before, logger) {
12621
+ let recorded = 0;
12622
+ for (const part of collectToolParts(messages)) {
12623
+ const callID = typeof part.callID === "string" ? part.callID : void 0;
12624
+ if (!callID) continue;
12625
+ if (typeof part.state?.output !== "string") continue;
12626
+ const prior = before.get(callID);
12627
+ if (prior === void 0 || prior === part.state.output) continue;
12628
+ if (state.stickyTruncations[callID] === part.state.output) continue;
12629
+ state.stickyTruncations[callID] = part.state.output;
12630
+ recorded++;
12631
+ }
12632
+ if (recorded > 0) {
12633
+ logger.info("Sticky truncation: froze truncated tool outputs", { recorded });
12634
+ }
12635
+ return recorded;
12636
+ }
12637
+
12227
12638
  // lib/messages/enforce-budget.ts
12228
12639
  var DEFAULT_COMPLETION_RESERVE_TOKENS = 32768;
12229
12640
  var TRUNCATION_MARKER2 = "[truncated for context space";
@@ -12295,7 +12706,7 @@ function enforceContextBudget(state, config, logger, messages) {
12295
12706
  const content = extractCompletedToolOutput(part);
12296
12707
  if (content === void 0) continue;
12297
12708
  if (content === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
12298
- const tokens = countTokens2(content);
12709
+ const tokens = countTokens(content);
12299
12710
  if (tokens <= 0) continue;
12300
12711
  candidates.push({ part, content, tokens, index: mi });
12301
12712
  }
@@ -12317,20 +12728,20 @@ function enforceContextBudget(state, config, logger, messages) {
12317
12728
  ` + suffix;
12318
12729
  if (truncated.length >= c.content.length) continue;
12319
12730
  c.part.state.output = truncated;
12320
- saved += c.tokens - countTokens2(truncated);
12731
+ saved += c.tokens - countTokens(truncated);
12321
12732
  truncatedCount++;
12322
12733
  }
12323
12734
  if (estimatedTokens - saved > budget) {
12324
12735
  const clearable = candidates.filter((c) => {
12325
12736
  const out = extractCompletedToolOutput(c.part);
12326
- return out !== void 0 && out !== COMPACTED_TOOL_OUTPUT_PLACEHOLDER && countTokens2(out) > MIN_CLEAR_TOKENS;
12737
+ return out !== void 0 && out !== COMPACTED_TOOL_OUTPUT_PLACEHOLDER && countTokens(out) > MIN_CLEAR_TOKENS;
12327
12738
  }).sort((a, b) => a.index - b.index);
12328
12739
  for (const c of clearable) {
12329
12740
  if (estimatedTokens - saved <= budget) break;
12330
12741
  const current = extractCompletedToolOutput(c.part);
12331
12742
  if (current === void 0 || current === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue;
12332
12743
  c.part.state.output = COMPACTED_TOOL_OUTPUT_PLACEHOLDER;
12333
- saved += countTokens2(current) - countTokens2(COMPACTED_TOOL_OUTPUT_PLACEHOLDER);
12744
+ saved += countTokens(current) - countTokens(COMPACTED_TOOL_OUTPUT_PLACEHOLDER);
12334
12745
  clearedCount++;
12335
12746
  }
12336
12747
  }
@@ -12474,10 +12885,10 @@ function analyzeTokens(state, messages) {
12474
12885
  breakdown.toolsInContextCount = toolsInContextCount;
12475
12886
  breakdown.prunedToolCount = prunedToolIds.size;
12476
12887
  breakdown.prunedMessageCount = prunedMessageCount;
12477
- const firstUserTokens = countTokens2(firstUserText);
12478
- breakdown.user = countTokens2(userTextParts.join("\n"));
12479
- const toolInputTokens = countTokens2(toolInputParts.join("\n"));
12480
- const toolOutputTokens = countTokens2(toolOutputParts.join("\n"));
12888
+ const firstUserTokens = countTokens(firstUserText);
12889
+ breakdown.user = countTokens(userTextParts.join("\n"));
12890
+ const toolInputTokens = countTokens(toolInputParts.join("\n"));
12891
+ const toolOutputTokens = countTokens(toolOutputParts.join("\n"));
12481
12892
  if (firstAssistant) {
12482
12893
  const firstInput = (firstAssistant.tokens?.input || 0) + (firstAssistant.tokens?.cache?.read || 0) + (firstAssistant.tokens?.cache?.write || 0);
12483
12894
  breakdown.system = Math.max(0, firstInput - firstUserTokens);
@@ -12689,7 +13100,7 @@ function resolveDefaultOutputPath(sessionId, cwd2) {
12689
13100
  const short = (sessionId || "session").slice(0, 8);
12690
13101
  return join7(cwd2, ".opencode", `acp-export-${short}.md`);
12691
13102
  }
12692
- function formatTokens2(n) {
13103
+ function formatTokens3(n) {
12693
13104
  if (!Number.isFinite(n) || n <= 0) return "0";
12694
13105
  return n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
12695
13106
  }
@@ -12742,7 +13153,7 @@ function renderExportMarkdown(params) {
12742
13153
  const span = tokenSpans(blocks);
12743
13154
  if (blocks.length > 0) {
12744
13155
  out.push(
12745
- `- **Coverage**: ${formatTokens2(span.effective)} original \u2192 ${formatTokens2(span.summary)} summary`
13156
+ `- **Coverage**: ${formatTokens3(span.effective)} original \u2192 ${formatTokens3(span.summary)} summary`
12746
13157
  );
12747
13158
  }
12748
13159
  out.push("");
@@ -12773,7 +13184,7 @@ function renderExportMarkdown(params) {
12773
13184
  out.push(`- **Tier**: T${tier} \xB7 **Mode**: ${mode}`);
12774
13185
  out.push(`- **Messages**: ${msgCount} (effective)`);
12775
13186
  out.push(
12776
- `- **Tokens**: ${formatTokens2(effectiveTokens(block))} \u2192 ${formatTokens2(block.summaryTokens || 0)} summary`
13187
+ `- **Tokens**: ${formatTokens3(effectiveTokens(block))} \u2192 ${formatTokens3(block.summaryTokens || 0)} summary`
12777
13188
  );
12778
13189
  out.push(`- **Created**: ${created}`);
12779
13190
  out.push(`- **Age**: survived ${age} transform${age === 1 ? "" : "s"}`);
@@ -13243,7 +13654,7 @@ function mergeMarkedBlocks(state, markedIds, maxMergedLength) {
13243
13654
  const mergedRaw = bodies.join("\n---\n");
13244
13655
  const mergedBody = truncateMergedSummary(mergedRaw, maxMergedLength);
13245
13656
  const newSummary = wrapCompressedSummary(newBlockId, mergedBody);
13246
- const newSummaryTokens = countTokens2(newSummary);
13657
+ const newSummaryTokens = countTokens(newSummary);
13247
13658
  const oldest = sourceBlocks[0];
13248
13659
  const newest = sourceBlocks[sourceBlocks.length - 1];
13249
13660
  const effectiveMessageIds = /* @__PURE__ */ new Set();
@@ -13419,7 +13830,8 @@ function createSystemPromptHandler(registry4, logger, config, prompts) {
13419
13830
  const newPrompt = renderSystemPrompt(
13420
13831
  runtimePrompts,
13421
13832
  buildProtectedToolsExtension(config.compress.protectedTools),
13422
- state.isSubAgent && config.allowSubAgents
13833
+ state.isSubAgent && config.allowSubAgents,
13834
+ (config.compress.images ?? DEFAULT_COMPRESS_IMAGES).nudge !== false ? IMAGE_GUARD_SYSTEM_EXTENSION : void 0
13423
13835
  );
13424
13836
  if (output.system.length > 0) {
13425
13837
  output.system[output.system.length - 1] += "\n\n" + newPrompt;
@@ -13594,13 +14006,32 @@ ${text}`);
13594
14006
  prePruneTokens,
13595
14007
  candidateMessages
13596
14008
  );
13597
- truncateLargeToolOutputs(
13598
- state,
14009
+ const emergencyTruncationEnabled = applyCompressOverrides(
13599
14010
  config,
13600
- logger,
13601
- output.messages.filter((message) => !isSyntheticMessage(message))
13602
- );
13603
- enforceContextBudget(state, config, logger, output.messages);
14011
+ dropReasoningModel?.providerID ?? state.modelProviderID,
14012
+ dropReasoningModel?.modelID ?? state.modelID
14013
+ ).compress.emergencyTruncation;
14014
+ if (emergencyTruncationEnabled === true) {
14015
+ applyStickyTruncations(state, output.messages, logger);
14016
+ const toolOutputsBefore = snapshotToolOutputs(output.messages);
14017
+ truncateLargeToolOutputs(
14018
+ state,
14019
+ config,
14020
+ logger,
14021
+ output.messages.filter((message) => !isSyntheticMessage(message))
14022
+ );
14023
+ enforceContextBudget(state, config, logger, output.messages);
14024
+ const frozenCount = recordStickyTruncations(
14025
+ state,
14026
+ output.messages,
14027
+ toolOutputsBefore,
14028
+ logger
14029
+ );
14030
+ if (frozenCount > 0) {
14031
+ saveSessionState(state, logger).catch(() => {
14032
+ });
14033
+ }
14034
+ }
13604
14035
  injectMessageIds(state, config, output.messages, compressionPriorities);
13605
14036
  hideFailedCompressCalls(output.messages);
13606
14037
  stripStaleMetadata(output.messages);
@@ -14039,7 +14470,7 @@ var server = (async (ctx) => {
14039
14470
  }
14040
14471
  const logger = new Logger(config.debug, config.debug ? "debug" : config.logLevel);
14041
14472
  logger.info("ACP plugin initialized", {
14042
- version: true ? "1.18.4" : "dev",
14473
+ version: true ? "1.18.5-pr.490.223" : "dev",
14043
14474
  workspace: ctx.directory,
14044
14475
  logLevel: logger.level,
14045
14476
  debug: config.debug,