acp-kernel 0.0.7 → 0.0.9

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.
@@ -1 +1 @@
1
- {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAcpB,OAAO,EAGL,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,gBAAgB,EAEhB,MAAM,EAEN,WAAW,EAGX,iBAAiB,EAEjB,YAAY,EACb,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,KAAK;IACpB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,iBAAiB,CAAC;IACxD,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,sBAAsB,CAAC;IACvE,YAAY,IAAI,YAAY,EAAE,CAAC;IAC/B,UAAU,CACR,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,gBAAgB,GACtB,gBAAgB,GAAG,SAAS,CAAC;IAChC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAAC;IACnE,MAAM,CACJ,KAAK,EAAE,gBAAgB,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,GACb,YAAY,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,eAAe,CAAC,EAAE,MAAM,CAAC;KAC1B,EAAE,CAAC;IACJ,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACnC;AAED,wBAAgB,UAAU,CAAC,KAAK,GAAE,KAAU,GAAG,eAAe,CAyM7D;AA0kBD,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAcpB,OAAO,EAGL,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,sBAAsB,EACtB,gBAAgB,EAChB,gBAAgB,EAEhB,MAAM,EAEN,WAAW,EAGX,iBAAiB,EAEjB,YAAY,EACb,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,KAAK;IACpB,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,iBAAiB,CAAC;IACxD,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,sBAAsB,CAAC;IACvE,YAAY,IAAI,YAAY,EAAE,CAAC;IAC/B,UAAU,CACR,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,gBAAgB,GACtB,gBAAgB,GAAG,SAAS,CAAC;IAChC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,gBAAgB,EAAE,CAAC;IACnE,MAAM,CACJ,KAAK,EAAE,gBAAgB,EACvB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,GACb,YAAY,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE;QACN,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,eAAe,CAAC,EAAE,MAAM,CAAC;KAC1B,EAAE,CAAC;IACJ,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACnC;AAED,wBAAgB,UAAU,CAAC,KAAK,GAAE,KAAU,GAAG,eAAe,CAoN7D;AAsoBD,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
package/dist/index.js CHANGED
@@ -81,7 +81,8 @@ function createInitialState() {
81
81
  lastPerMessageNudgeTokens: 0,
82
82
  lastNudgeShownTokens: 0,
83
83
  baselineTokens: 0,
84
- anchors: {}
84
+ anchors: {},
85
+ lastShownByTier: {}
85
86
  },
86
87
  stats: { tokensCompressed: 0, compressionCount: 0 },
87
88
  nextBlockId: 1,
@@ -269,9 +270,9 @@ import { createRequire } from "module";
269
270
  var require2 = createRequire(import.meta.url);
270
271
  function defaultCountTokens(text) {
271
272
  if (!text) return 0;
272
- const ascii = text.match(/[a-zA-Z][a-zA-Z0-9_'-]*/g);
273
273
  const cjk = text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
274
- return (ascii?.length ?? 0) + (cjk?.length ?? 0);
274
+ const cjkCount = cjk?.length ?? 0;
275
+ return cjkCount + Math.ceil((text.length - cjkCount) / 4);
275
276
  }
276
277
  function estimateTokensFast(text) {
277
278
  if (!text) return 0;
@@ -704,6 +705,7 @@ var renderRefsNode = {
704
705
  };
705
706
 
706
707
  // src/protected.ts
708
+ var ALWAYS_PROTECTED_TOOLS = ["compress"];
707
709
  function matchToolPattern(toolName, pattern) {
708
710
  if (pattern.endsWith("*")) {
709
711
  return toolName.startsWith(pattern.slice(0, -1));
@@ -711,13 +713,34 @@ function matchToolPattern(toolName, pattern) {
711
713
  return toolName === pattern;
712
714
  }
713
715
  function isMessageProtected(msg, config) {
714
- if (msg.contentType !== "tool-call" || !msg.toolName) return false;
716
+ if (msg.contentType !== "tool-call" && msg.contentType !== "tool-result" || !msg.toolName) {
717
+ return false;
718
+ }
719
+ if (ALWAYS_PROTECTED_TOOLS.includes(msg.toolName)) {
720
+ return true;
721
+ }
715
722
  for (const pattern of config.protectedTools) {
716
723
  if (matchToolPattern(msg.toolName, pattern)) return true;
717
724
  }
718
725
  if (config.isToolProtected?.(msg.toolName, msg.text)) return true;
719
726
  return false;
720
727
  }
728
+ function collectProtectedToolCallIds(messages, config) {
729
+ const ids = /* @__PURE__ */ new Set();
730
+ for (const m of messages) {
731
+ if (m.contentType === "tool-call" && m.toolCallId && isMessageProtected(m, config)) {
732
+ ids.add(m.toolCallId);
733
+ }
734
+ }
735
+ return ids;
736
+ }
737
+ function isMessageProtectedWithPairing(msg, config, protectedCallIds) {
738
+ if (isMessageProtected(msg, config)) return true;
739
+ if (msg.contentType === "tool-result" && msg.toolCallId && protectedCallIds.has(msg.toolCallId)) {
740
+ return true;
741
+ }
742
+ return false;
743
+ }
721
744
 
722
745
  // src/tool-pairs.ts
723
746
  function adjustBoundariesForToolPairs(startIndex, endIndex, messages, maxScan = 20) {
@@ -795,24 +818,27 @@ function computeProtectedRefs(messages, state, config) {
795
818
  tokenAccum += visible[i].tokens;
796
819
  }
797
820
  }
798
- for (let i = messages.length - 1; i >= 0; i--) {
799
- const msg = messages[i];
800
- if (msg.role !== "user" || isSyntheticOrPruned(msg, state)) continue;
801
- const ref = state.messageRefs.byRaw[msg.id];
802
- if (ref && ref !== "BLOCKED") result.add(ref);
803
- break;
821
+ if (preserveN > 0) {
822
+ for (let i = messages.length - 1; i >= 0; i--) {
823
+ const msg = messages[i];
824
+ if (msg.role !== "user" || isSyntheticOrPruned(msg, state)) continue;
825
+ const ref = state.messageRefs.byRaw[msg.id];
826
+ if (ref && ref !== "BLOCKED") result.add(ref);
827
+ break;
828
+ }
804
829
  }
805
830
  return result;
806
831
  }
807
832
  function buildCompressibleRanges(messages, state, config, protectedZoneRefs) {
808
833
  const compressibleMsgs = [];
809
834
  const protectedMsgs = [];
835
+ const protectedCallIds = collectProtectedToolCallIds(messages, config);
810
836
  for (const msg of messages) {
811
837
  if (isSyntheticOrPruned(msg, state)) continue;
812
838
  const ref = state.messageRefs.byRaw[msg.id];
813
839
  if (!ref || ref === "BLOCKED") continue;
814
840
  const rn = refNum(ref);
815
- if (isMessageProtected(msg, config)) {
841
+ if (isMessageProtectedWithPairing(msg, config, protectedCallIds)) {
816
842
  protectedMsgs.push({
817
843
  ref,
818
844
  refNum: rn,
@@ -828,7 +854,8 @@ function buildCompressibleRanges(messages, state, config, protectedZoneRefs) {
828
854
  ref,
829
855
  refNum: rn,
830
856
  tokens: estimateMessageTokens(msg),
831
- isTool: isToolMessage(msg)
857
+ isTool: isToolMessage(msg),
858
+ isUser: msg.role === "user"
832
859
  });
833
860
  }
834
861
  const compressible = [];
@@ -836,7 +863,7 @@ function buildCompressibleRanges(messages, state, config, protectedZoneRefs) {
836
863
  let prevRefNum = -2;
837
864
  for (const info of compressibleMsgs) {
838
865
  const hasGap = info.refNum > prevRefNum + 1;
839
- if (cur && hasGap) {
866
+ if (cur && (info.isUser && cur.count >= 3 || hasGap)) {
840
867
  compressible.push(cur);
841
868
  cur = null;
842
869
  }
@@ -919,6 +946,7 @@ function createCore(ports = {}) {
919
946
  let blocksCreated = 0;
920
947
  let tokensCompressed = 0;
921
948
  const errors = [];
949
+ const protectedMessageIds = input.protectedMessageIds ?? computeProtectedRefs(input.messages, input.state, input.config);
922
950
  const preExistingCoverage = collectCoverage(state);
923
951
  const rangeIndexSets = [];
924
952
  for (const spec of input.ranges) {
@@ -1006,7 +1034,7 @@ function createCore(ports = {}) {
1006
1034
  state,
1007
1035
  runId,
1008
1036
  config: input.config,
1009
- protectedMessageIds: input.protectedMessageIds,
1037
+ protectedMessageIds,
1010
1038
  countTokens,
1011
1039
  preExistingCoverage
1012
1040
  });
@@ -1021,6 +1049,7 @@ function createCore(ports = {}) {
1021
1049
  if (blocksCreated > 0) {
1022
1050
  state.nudge.lastPerMessageNudgeTokens = 0;
1023
1051
  state.nudge.lastNudgeShownTokens = 0;
1052
+ state.nudge.lastShownByTier = {};
1024
1053
  }
1025
1054
  return { state, result: { blocksCreated, tokensCompressed, errors } };
1026
1055
  }
@@ -1156,7 +1185,8 @@ var nudgeNode = {
1156
1185
  config: ctx.config,
1157
1186
  state: io.state,
1158
1187
  messages: io.messages,
1159
- recommendation: io.effects.recommendation
1188
+ recommendation: io.effects.recommendation,
1189
+ countTokens: ctx.countTokens
1160
1190
  });
1161
1191
  const baseline = io.state.nudge.lastPerMessageNudgeTokens;
1162
1192
  const nudgeGrowthTokens = resolveAdaptiveGrowth(
@@ -1173,6 +1203,9 @@ var nudgeNode = {
1173
1203
  }
1174
1204
  if (nudge.shouldInject) {
1175
1205
  stamped.lastNudgeShownTokens = ctx.tokenCount;
1206
+ if (nudge.tier !== null) {
1207
+ stamped.lastShownByTier = { ...stamped.lastShownByTier, [nudge.tier]: ctx.tokenCount };
1208
+ }
1176
1209
  }
1177
1210
  return {
1178
1211
  ...io,
@@ -1248,11 +1281,25 @@ function applySingleRange(input) {
1248
1281
  const directMessageIds = [...effectiveMessageIds].filter(
1249
1282
  (id) => !input.preExistingCoverage.has(id)
1250
1283
  );
1251
- const { filteredIds, appendedProtectedText } = filterProtectedToolMessages(
1284
+ const filteredIds = filterProtectedToolMessages(
1252
1285
  directMessageIds,
1253
1286
  input.messages,
1254
1287
  input.config
1255
1288
  );
1289
+ const protectedRefs = input.protectedMessageIds;
1290
+ const hitProtectedRaw = protectedRefs ? filteredIds.filter((id) => {
1291
+ const ref = input.state.messageRefs.byRaw[id];
1292
+ return ref !== void 0 && protectedRefs.has(ref);
1293
+ }) : [];
1294
+ if (hitProtectedRaw.length > 0) {
1295
+ const recentN = input.config.preserveRecentMessages;
1296
+ const hitRefs = hitProtectedRaw.map((id) => input.state.messageRefs.byRaw[id]).filter(Boolean);
1297
+ throw new Error(
1298
+ `Range includes protected messages (the last ${recentN} messages and/or the most recent user message) which must not be compressed: ${hitRefs.join(
1299
+ ", "
1300
+ )}. Adjust startId/endId to exclude them.`
1301
+ );
1302
+ }
1256
1303
  validateCompressionRange(input, filteredIds, consumedBlockIds.length);
1257
1304
  let compressedTokens = 0;
1258
1305
  for (const id of filteredIds) {
@@ -1266,22 +1313,12 @@ function applySingleRange(input) {
1266
1313
  }
1267
1314
  }
1268
1315
  const blockId = allocateBlockId(input.state);
1269
- const finalSummary = appendedProtectedText.length > 0 ? `${input.spec.summary}
1270
-
1271
- The following protected tool calls were used in this conversation as well:
1272
- ${appendedProtectedText.join("")}` : input.spec.summary;
1273
- const maxLen = input.spec.summaryMaxChars ?? input.config.compress.maxSummaryLength;
1274
- if (maxLen > 0 && finalSummary.length > maxLen) {
1275
- throw new Error(
1276
- `Summary too long after appending protected content (${finalSummary.length} chars, max ${maxLen}). Reduce the summary or remove protected tools from the range.`
1277
- );
1278
- }
1279
1316
  const block = {
1280
1317
  blockId,
1281
1318
  runId: input.runId,
1282
1319
  tier: outputTier,
1283
1320
  topic: input.spec.topic,
1284
- summary: finalSummary,
1321
+ summary: input.spec.summary,
1285
1322
  directMessageIds: filteredIds,
1286
1323
  effectiveMessageIds: [...effectiveMessageIds],
1287
1324
  directBlockIds: [...consumedBlockIds],
@@ -1344,41 +1381,30 @@ function validateCompressionRange(input, directMessageIds, consumedBlockCount) {
1344
1381
  }
1345
1382
  }
1346
1383
  function filterProtectedToolMessages(directMessageIds, messages, config) {
1347
- const removedToolCallIds = /* @__PURE__ */ new Set();
1348
- const allProtectedCallIds = /* @__PURE__ */ new Set();
1384
+ const protectedCallIds = /* @__PURE__ */ new Set();
1349
1385
  const removedIds = /* @__PURE__ */ new Set();
1350
- const appendedProtectedText = [];
1351
1386
  for (const msg of messages) {
1352
1387
  if (isMessageProtected(msg, config) && msg.toolCallId) {
1353
- allProtectedCallIds.add(msg.toolCallId);
1388
+ protectedCallIds.add(msg.toolCallId);
1354
1389
  }
1355
1390
  }
1356
1391
  for (const id of directMessageIds) {
1357
1392
  const msg = messages.find((m) => m.id === id);
1358
1393
  if (!msg) continue;
1359
- if (!isMessageProtected(msg, config)) continue;
1360
- removedIds.add(id);
1361
- if (msg.toolCallId) removedToolCallIds.add(msg.toolCallId);
1362
- if (msg.text) {
1363
- appendedProtectedText.push(
1364
- `
1365
- ### Protected: ${msg.toolName ?? "tool"}
1366
- ${msg.text}`
1367
- );
1394
+ if (isMessageProtected(msg, config)) {
1395
+ removedIds.add(id);
1396
+ if (msg.toolCallId) protectedCallIds.add(msg.toolCallId);
1368
1397
  }
1369
1398
  }
1370
1399
  for (const id of directMessageIds) {
1371
1400
  if (removedIds.has(id)) continue;
1372
1401
  const msg = messages.find((m) => m.id === id);
1373
1402
  if (!msg) continue;
1374
- if (msg.contentType === "tool-result" && msg.toolCallId && (removedToolCallIds.has(msg.toolCallId) || allProtectedCallIds.has(msg.toolCallId))) {
1403
+ if (msg.contentType === "tool-result" && msg.toolCallId && protectedCallIds.has(msg.toolCallId)) {
1375
1404
  removedIds.add(id);
1376
1405
  }
1377
1406
  }
1378
- return {
1379
- filteredIds: directMessageIds.filter((id) => !removedIds.has(id)),
1380
- appendedProtectedText
1381
- };
1407
+ return directMessageIds.filter((id) => !removedIds.has(id));
1382
1408
  }
1383
1409
  function resolveTargetTier(state, nestedBlockIds, isBlockBoundary) {
1384
1410
  if (!isBlockBoundary) return 1;
@@ -1407,8 +1433,19 @@ function resolveAdaptiveGrowth(modelContextLimit, nudge) {
1407
1433
  )
1408
1434
  );
1409
1435
  }
1436
+ function pendingByTier(state, recommendation, countTokens) {
1437
+ const out = {};
1438
+ const compressible = recommendation?.contextRanges.compressible ?? [];
1439
+ out[1] = { pending: compressible.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
1440
+ const active = activeBlocks(state);
1441
+ const t1 = active.filter((b) => b.tier === 1);
1442
+ const t2 = active.filter((b) => b.tier === 2);
1443
+ out[2] = { pending: t1.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t1 };
1444
+ out[3] = { pending: t2.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t2 };
1445
+ return out;
1446
+ }
1410
1447
  function decideNudge(input) {
1411
- const { config, state, tokenCount, recommendation } = input;
1448
+ const { config, state, tokenCount, recommendation, countTokens } = input;
1412
1449
  const limit = config.modelContextLimit;
1413
1450
  const usage = limit > 0 ? tokenCount / limit : 0;
1414
1451
  const nudgeGrowthTokens = resolveAdaptiveGrowth(limit, config.nudge);
@@ -1423,33 +1460,39 @@ function decideNudge(input) {
1423
1460
  config.nudge.minGrowthRatio * nudgeGrowthTokens
1424
1461
  );
1425
1462
  const growthSinceReference = tokenCount - growthReference;
1426
- const growthTriggered = growthSinceReference >= effectiveThreshold;
1427
- let shouldContextNudge = emergencyOverride || growthTriggered;
1428
- if (shouldContextNudge && !emergencyOverride) {
1429
- shouldContextNudge = growthSinceReference >= growthFloor;
1430
- }
1431
- let tier = null;
1432
- if (config.tiers.enabled) {
1433
- const active = activeBlocks(state);
1434
- const activeT1 = active.filter((b) => b.tier === 1);
1435
- const activeT2 = active.filter((b) => b.tier === 2);
1436
- if (activeT2.length >= config.tiers.tier3Trigger) tier = 3;
1437
- else if (activeT1.length >= config.tiers.tier2Trigger) tier = 2;
1438
- }
1439
1463
  const rec = recommendation;
1440
- const nothingToCompress = rec ? rec.contextRanges.compressible.length === 0 : false;
1441
- const shouldInject = shouldContextNudge || tier !== null;
1464
+ const tiers = pendingByTier(state, rec, countTokens);
1465
+ let injectedTier = null;
1466
+ let injectedReason = "";
1467
+ const growthReady = growthSinceReference >= growthFloor;
1468
+ if (!emergencyOverride && growthReady) {
1469
+ for (const tier of [1, 2, 3]) {
1470
+ if (!config.tiers.enabled && tier > 1) break;
1471
+ const info = tiers[tier];
1472
+ if (!info || info.pending < nudgeGrowthTokens) continue;
1473
+ const lastShown = state.nudge.lastShownByTier[tier] ?? 0;
1474
+ const cadenceMet = lastShown === 0 || tokenCount - lastShown >= growthFloor;
1475
+ if (!cadenceMet) continue;
1476
+ injectedTier = tier;
1477
+ injectedReason = tier === 1 ? `T1 compressible ${info.pending} >= ${nudgeGrowthTokens}, growth ${growthSinceReference}, usage ${Math.round(usage * 100)}%` : `T${tier} distill ready: ${info.targetBlocks.length} tier-${tier - 1} blocks (${info.pending} tokens) >= ${nudgeGrowthTokens}, usage ${Math.round(usage * 100)}%`;
1478
+ break;
1479
+ }
1480
+ }
1481
+ const shouldInject = emergencyOverride || injectedTier !== null;
1442
1482
  let reason;
1443
- if (!shouldInject) {
1444
- reason = `growth ${growthSinceReference} < threshold ${effectiveThreshold}${hasPendingNudge ? " (halved)" : ""} (floor ${growthFloor})`;
1445
- } else if (emergencyOverride) {
1483
+ if (emergencyOverride) {
1446
1484
  reason = `EMERGENCY: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.emergencyThresholdPct * 100)}%`;
1447
- } else if (tier !== null) {
1448
- reason = `tier-${tier} distillation trigger`;
1449
- } else if (nothingToCompress) {
1450
- reason = `growth ${growthSinceReference} >= ${effectiveThreshold} but no specific ranges worth compressing`;
1485
+ } else if (injectedTier !== null) {
1486
+ reason = injectedReason;
1451
1487
  } else {
1452
- reason = `growth ${growthSinceReference} >= ${effectiveThreshold}${hasPendingNudge ? " (halved)" : ""}, usage ${Math.round(usage * 100)}%`;
1488
+ const tiersList = [1, 2, 3];
1489
+ const eligible = tiersList.filter((t) => config.tiers.enabled || t === 1);
1490
+ const ready = eligible.filter((t) => (tiers[t]?.pending ?? 0) >= nudgeGrowthTokens).map((t) => `T${t} ${tiers[t].pending}`);
1491
+ const readyHint = ready.length > 0 ? `, ready: ${ready.join(", ")}` : "";
1492
+ const blocked = eligible.filter((t) => (tiers[t]?.pending ?? 0) >= nudgeGrowthTokens && (state.nudge.lastShownByTier[t] ?? 0) > 0 && tokenCount - (state.nudge.lastShownByTier[t] ?? 0) < growthFloor).map((t) => `T${t} (cadence)`);
1493
+ const blockedHint = blocked.length > 0 ? `, blocked: ${blocked.join(", ")}` : "";
1494
+ const maxPending = Math.max(0, ...Object.values(tiers).map((t) => t.pending));
1495
+ reason = `max compressible ${maxPending} < threshold ${nudgeGrowthTokens}${readyHint}${blockedHint}, growth ${growthSinceReference} (floor ${growthFloor})`;
1453
1496
  }
1454
1497
  const ctxBreakdown = computeContextBreakdown(input.messages, tokenCount, growthSinceReference);
1455
1498
  return {
@@ -1457,8 +1500,9 @@ function decideNudge(input) {
1457
1500
  reason,
1458
1501
  compressibleRanges: rec?.recommendedRanges ?? [],
1459
1502
  protectedRanges: rec?.contextRanges.protected ?? [],
1503
+ tierTargetBlocks: injectedTier ? tiers[injectedTier].targetBlocks : [],
1460
1504
  contextUsage: usage,
1461
- tier,
1505
+ tier: injectedTier,
1462
1506
  breakdown: {
1463
1507
  usage,
1464
1508
  growth: growthSinceReference,
@@ -1467,7 +1511,10 @@ function decideNudge(input) {
1467
1511
  nudgeGrowthTokens,
1468
1512
  growthFloor,
1469
1513
  hasPendingNudge: hasPendingNudge ? 1 : 0,
1470
- emergencyOverride: emergencyOverride ? 1 : 0
1514
+ emergencyOverride: emergencyOverride ? 1 : 0,
1515
+ pendingT1: tiers[1].pending,
1516
+ pendingT2: tiers[2].pending,
1517
+ pendingT3: tiers[3].pending
1471
1518
  },
1472
1519
  contextBreakdown: ctxBreakdown
1473
1520
  };
@@ -1733,6 +1780,18 @@ function mergeRanges(compressible, protected_) {
1733
1780
  }
1734
1781
  return merged;
1735
1782
  }
1783
+ function formatTierTargetBlocks(blocks) {
1784
+ if (blocks.length === 0) {
1785
+ return "Target blocks: (none \u2014 no tier blocks found)";
1786
+ }
1787
+ const lines = blocks.map((b) => {
1788
+ const summaryTokens = Math.ceil((b.summary ?? "").length / 4);
1789
+ const topic = b.topic ? ` "${b.topic}"` : "";
1790
+ return ` ${b.blockId} ${b.effectiveMessageIds.length} msgs ${formatK(b.compressedTokens)}\u2192${formatK(summaryTokens)}${topic}`;
1791
+ });
1792
+ return `Target ${blocks[0].tier === 1 ? "tier-1" : "tier-2"} blocks to distill (${blocks.length}):
1793
+ ${lines.join("\n")}`;
1794
+ }
1736
1795
  function formatRanges(compressible, protected_) {
1737
1796
  const merged = mergeRanges(compressible, protected_);
1738
1797
  if (merged.length === 0) {
@@ -1756,6 +1815,10 @@ function renderNudgeText(decision) {
1756
1815
  const rangesStr = formatRanges(decision.compressibleRanges, decision.protectedRanges ?? []);
1757
1816
  if (decision.tier !== null && decision.tier >= 2) {
1758
1817
  const isT2 = decision.tier === 2;
1818
+ const targets = decision.tierTargetBlocks ?? [];
1819
+ const blockList = formatTierTargetBlocks(targets);
1820
+ const startId = targets[0]?.blockId ?? "b1";
1821
+ const endId = targets[targets.length - 1]?.blockId ?? "b5";
1759
1822
  return {
1760
1823
  voice: "gentle",
1761
1824
  text: [
@@ -1764,8 +1827,9 @@ function renderNudgeText(decision) {
1764
1827
  breakdownStr,
1765
1828
  "",
1766
1829
  `[TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"} TRIGGER]`,
1767
- isT2 ? "Your tier-1 compression summaries have accumulated. Distill them into a single denser tier-2 summary. Use block IDs as boundaries." : "Your tier-2 compression summaries have accumulated. Condense them further into a tier-3 ultra-condensed summary. Use block IDs as boundaries.",
1768
- `Example: compress({ content: [{ startId: "b1", endId: "b5", summary: "..." }] })`,
1830
+ isT2 ? `Your tier-1 compression summaries have accumulated. Distill them into a single denser tier-2 summary. Use block IDs as boundaries.` : `Your tier-2 compression summaries have accumulated. Condense them further into a tier-3 ultra-condensed summary. Use block IDs as boundaries.`,
1831
+ blockList,
1832
+ `Example: compress({ content: [{ startId: "${startId}", endId: "${endId}", summary: "..." }] })`,
1769
1833
  "",
1770
1834
  HOW_TO_COMPRESS_RULES,
1771
1835
  "",
@@ -2105,11 +2169,31 @@ function renderUncompressedRanges(visible) {
2105
2169
  lines.push("");
2106
2170
  if (visible.length === 0) {
2107
2171
  lines.push(" (no uncompressed messages)");
2108
- } else {
2109
- for (const message of visible.slice(0, 30)) {
2110
- lines.push(` ${message.ref} (${formatTokens2(message.tokens)}) ${message.tool}`);
2172
+ return lines.join("\n");
2173
+ }
2174
+ const refNum2 = (ref) => {
2175
+ const m = ref.match(/\d+/);
2176
+ return m ? parseInt(m[0], 10) : 0;
2177
+ };
2178
+ const merged = [];
2179
+ for (const m of visible) {
2180
+ const num = refNum2(m.ref);
2181
+ const last = merged[merged.length - 1];
2182
+ if (last && num === last.startNum + last.count) {
2183
+ last.endRef = m.ref;
2184
+ last.count += 1;
2185
+ last.tokens += m.tokens;
2186
+ } else {
2187
+ merged.push({ startRef: m.ref, endRef: m.ref, startNum: num, count: 1, tokens: m.tokens, tool: m.tool });
2111
2188
  }
2112
2189
  }
2190
+ for (const r of merged.slice(0, 30)) {
2191
+ const range = r.count === 1 ? r.startRef : `${r.startRef}\u2013${r.endRef}`;
2192
+ lines.push(` ${range} (${r.count} msgs, ${formatTokens2(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${r.tool}`);
2193
+ }
2194
+ if (merged.length > 30) {
2195
+ lines.push(` ... and ${merged.length - 30} more ranges`);
2196
+ }
2113
2197
  return lines.join("\n");
2114
2198
  }
2115
2199
  function renderMessageDrilldown(visible, toolFilter, sort, limit) {