billion-context-omp 0.1.7 → 0.1.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.
package/dist/index.js CHANGED
@@ -287,7 +287,8 @@ function defaultConfig(modelContextLimit, overrides = {}) {
287
287
  growthCap: 5e4,
288
288
  minGrowthFloor: 2e4,
289
289
  minGrowthRatio: 0.45,
290
- emergencyThresholdPct: 0.95
290
+ emergencyThresholdPct: 0.95,
291
+ tier2GrowthMultiplier: 1.5
291
292
  },
292
293
  promotionThreshold: 5,
293
294
  truncate: { threshold: 0.95 },
@@ -1012,6 +1013,44 @@ function buildCompressibleRanges(messages, state, config, protectedZoneRefs, cou
1012
1013
  protected: protectedRanges
1013
1014
  };
1014
1015
  }
1016
+ function mergeBatch(batch) {
1017
+ const first = batch[0];
1018
+ const last = batch[batch.length - 1];
1019
+ const count = batch.reduce((s, r) => s + r.count, 0);
1020
+ const tokens = batch.reduce((s, r) => s + r.tokens, 0);
1021
+ const toolPct = Math.round(
1022
+ batch.reduce((s, r) => s + r.toolPct * r.count, 0) / count
1023
+ );
1024
+ const merged = {
1025
+ startRef: first.startRef,
1026
+ endRef: last.endRef,
1027
+ count,
1028
+ tokens,
1029
+ toolPct,
1030
+ textPct: 100 - toolPct
1031
+ };
1032
+ if (batch.some((r) => r.dangerous === true)) {
1033
+ merged.dangerous = true;
1034
+ }
1035
+ return merged;
1036
+ }
1037
+ function mergeRangesToThreshold(ranges, minChars) {
1038
+ if (minChars <= 0 || ranges.length === 0) return ranges;
1039
+ const result = [];
1040
+ let batch = [];
1041
+ for (const r of ranges) {
1042
+ batch.push(r);
1043
+ const batchTokens = batch.reduce((s, x) => s + x.tokens, 0);
1044
+ if (batchTokens * 4 >= minChars) {
1045
+ result.push(mergeBatch(batch));
1046
+ batch = [];
1047
+ }
1048
+ }
1049
+ if (batch.length > 0) {
1050
+ result.push(mergeBatch(batch));
1051
+ }
1052
+ return result;
1053
+ }
1015
1054
  function runPipeline(nodes, initial, ctx) {
1016
1055
  let io = initial;
1017
1056
  for (const node of nodes) {
@@ -1291,7 +1330,10 @@ var recommendNode = {
1291
1330
  const nothingToCompress = contextRanges.compressible.length === 0;
1292
1331
  const recommendation = {
1293
1332
  contextRanges,
1294
- recommendedRanges: contextRanges.compressible,
1333
+ recommendedRanges: mergeRangesToThreshold(
1334
+ contextRanges.compressible,
1335
+ ctx.config.compress.minCompressRange
1336
+ ),
1295
1337
  nothingToCompress
1296
1338
  };
1297
1339
  return { ...io, effects: { ...io.effects, recommendation } };
@@ -1317,6 +1359,7 @@ var nudgeNode = {
1317
1359
  if (baseline > 0 && ctx.tokenCount < baseline - nudgeGrowthTokens) {
1318
1360
  stamped.lastPerMessageNudgeTokens = ctx.tokenCount;
1319
1361
  stamped.lastNudgeShownTokens = 0;
1362
+ stamped.lastShownByTier = {};
1320
1363
  }
1321
1364
  if (stamped.lastPerMessageNudgeTokens === 0) {
1322
1365
  stamped.lastPerMessageNudgeTokens = ctx.tokenCount;
@@ -1585,10 +1628,11 @@ function resolveAdaptiveGrowth(modelContextLimit, nudge) {
1585
1628
  )
1586
1629
  );
1587
1630
  }
1588
- function pendingByTier(state, recommendation, countTokens) {
1631
+ function pendingByTier(state, recommendation, countTokens, minCompressRange) {
1589
1632
  const out = {};
1590
- const compressible = recommendation?.contextRanges.compressible ?? [];
1591
- out[1] = { pending: compressible.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
1633
+ const merged = recommendation?.recommendedRanges ?? [];
1634
+ const effective = minCompressRange > 0 ? merged.filter((r) => r.tokens * 4 >= minCompressRange) : merged;
1635
+ out[1] = { pending: effective.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
1592
1636
  const active = activeBlocks(state);
1593
1637
  const t1 = active.filter((b) => b.tier === 1);
1594
1638
  const t2 = active.filter((b) => b.tier === 2);
@@ -1603,6 +1647,7 @@ function decideNudge(input) {
1603
1647
  const nudgeGrowthTokens = resolveAdaptiveGrowth(limit, config.nudge);
1604
1648
  const overLimit = usage >= config.nudge.maxContextLimitPct;
1605
1649
  const emergencyOverride = usage >= config.nudge.emergencyThresholdPct;
1650
+ const pressure = overLimit || emergencyOverride;
1606
1651
  const baseline = state.nudge.lastPerMessageNudgeTokens;
1607
1652
  const hadPendingNudge = state.nudge.lastNudgeShownTokens > 0;
1608
1653
  const hasPendingNudge = hadPendingNudge;
@@ -1614,44 +1659,67 @@ function decideNudge(input) {
1614
1659
  );
1615
1660
  const growthSinceReference = tokenCount - growthReference;
1616
1661
  const rec = recommendation;
1617
- const tiers = pendingByTier(state, rec, countTokens);
1662
+ const tiers = pendingByTier(
1663
+ state,
1664
+ rec,
1665
+ countTokens,
1666
+ config.compress.minCompressRange
1667
+ );
1668
+ const tier2Threshold = Math.round(
1669
+ nudgeGrowthTokens * (config.nudge.tier2GrowthMultiplier ?? 1.5)
1670
+ );
1618
1671
  let injectedTier = null;
1619
1672
  let injectedReason = "";
1620
1673
  const growthReady = growthSinceReference >= growthFloor;
1621
- if (!overLimit && growthReady) {
1622
- for (const tier of [1, 2, 3]) {
1623
- if (!config.tiers.enabled && tier > 1) break;
1624
- const info = tiers[tier];
1625
- if (!info || info.pending < nudgeGrowthTokens) continue;
1626
- const lastShown = state.nudge.lastShownByTier[tier] ?? 0;
1627
- const cadenceMet = lastShown === 0 || tokenCount - lastShown >= growthFloor;
1628
- if (!cadenceMet) continue;
1629
- injectedTier = tier;
1630
- 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)}%`;
1631
- break;
1674
+ const t1Eff = tiers[1]?.pending ?? 0;
1675
+ const t2Pen = tiers[2]?.pending ?? 0;
1676
+ const t3Pen = tiers[3]?.pending ?? 0;
1677
+ if (pressure) {
1678
+ const candidates = [1];
1679
+ if (config.tiers.enabled) {
1680
+ candidates.push(2, 3);
1681
+ }
1682
+ let best = null;
1683
+ let bestPending = 0;
1684
+ for (const t of candidates) {
1685
+ const p = tiers[t]?.pending ?? 0;
1686
+ if (p > bestPending) {
1687
+ bestPending = p;
1688
+ best = t;
1689
+ }
1632
1690
  }
1633
- } else if (overLimit) {
1634
- for (const tier of [1, 2, 3]) {
1635
- if (!config.tiers.enabled && tier > 1) break;
1636
- const info = tiers[tier];
1637
- if (!info || info.pending < config.compress.minCompressRange) continue;
1638
- injectedTier = tier;
1639
- injectedReason = emergencyOverride ? `EMERGENCY: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.emergencyThresholdPct * 100)}%, T${tier} pending ${info.pending}` : `OVER-LIMIT: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.maxContextLimitPct * 100)}%, T${tier} pending ${info.pending}`;
1640
- break;
1691
+ if (best !== null && bestPending > 0) {
1692
+ injectedTier = best;
1693
+ const label = emergencyOverride ? "EMERGENCY" : "OVER-LIMIT";
1694
+ injectedReason = best === 1 ? `${label} T1: max effective pending ${bestPending}, usage ${Math.round(usage * 100)}%` : `${label} T${best} distill: max pending ${bestPending} (T1 effective ${t1Eff}, T2 ${t2Pen}, T3 ${t3Pen}), usage ${Math.round(usage * 100)}%`;
1695
+ }
1696
+ } else if (growthReady) {
1697
+ if (t1Eff >= nudgeGrowthTokens) {
1698
+ injectedTier = 1;
1699
+ injectedReason = `T1 effective ${t1Eff} >= ${nudgeGrowthTokens}, growth ${growthSinceReference}, usage ${Math.round(usage * 100)}%`;
1700
+ } else if (config.tiers.enabled && t2Pen >= tier2Threshold && t2Pen > t1Eff) {
1701
+ const lastShown = state.nudge.lastShownByTier[2] ?? 0;
1702
+ const cadenceMet = lastShown === 0 || tokenCount - lastShown >= growthFloor;
1703
+ if (cadenceMet) {
1704
+ injectedTier = 2;
1705
+ injectedReason = `T2 distill ready: ${tiers[2].targetBlocks.length} tier-1 blocks (${t2Pen} tokens) >= ${tier2Threshold} (1.5x) and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`;
1706
+ }
1707
+ } else if (config.tiers.enabled && t3Pen >= tier2Threshold && t3Pen > t2Pen && t3Pen > t1Eff) {
1708
+ const lastShown = state.nudge.lastShownByTier[3] ?? 0;
1709
+ const cadenceMet = lastShown === 0 || tokenCount - lastShown >= growthFloor;
1710
+ if (cadenceMet) {
1711
+ injectedTier = 3;
1712
+ injectedReason = `T3 condense ready: ${tiers[3].targetBlocks.length} tier-2 blocks (${t3Pen} tokens) >= ${tier2Threshold} (1.5x) and > T2 ${t2Pen} and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`;
1713
+ }
1641
1714
  }
1642
1715
  }
1643
- const shouldInject = injectedTier !== null || overLimit && (rec?.recommendedRanges?.length ?? 0) > 0;
1716
+ const shouldInject = injectedTier !== null;
1644
1717
  let reason;
1645
- if (emergencyOverride && injectedTier !== null) {
1646
- reason = injectedReason;
1647
- } else if (emergencyOverride) {
1648
- reason = `EMERGENCY: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.emergencyThresholdPct * 100)}% (no compressible content)`;
1649
- } else if (overLimit && injectedTier !== null) {
1650
- reason = injectedReason;
1651
- } else if (overLimit) {
1652
- reason = `OVER-LIMIT: usage ${Math.round(usage * 100)}% >= ${Math.round(config.nudge.maxContextLimitPct * 100)}% (no compressible content)`;
1653
- } else if (injectedTier !== null) {
1718
+ if (injectedTier !== null) {
1654
1719
  reason = injectedReason;
1720
+ } else if (pressure) {
1721
+ const label = emergencyOverride ? "EMERGENCY" : "OVER-LIMIT";
1722
+ reason = `${label}: usage ${Math.round(usage * 100)}% but no tier has effective compressible content (T1 effective ${t1Eff}, T2 ${t2Pen}, T3 ${t3Pen}) \u2014 nudge suppressed to avoid offering ranges below minCompressRange`;
1655
1723
  } else {
1656
1724
  const tiersList = [1, 2, 3];
1657
1725
  const eligible = tiersList.filter((t) => config.tiers.enabled || t === 1);
@@ -2000,20 +2068,23 @@ ${lines.join("\n")}`;
2000
2068
  function renderNudgeText(decision, prompts = defaultPrompts) {
2001
2069
  const breakdownStr = formatBreakdown(decision.contextBreakdown);
2002
2070
  const rangesStr = formatRanges(decision.compressibleRanges, decision.protectedRanges ?? []);
2071
+ const isEmergency = !!decision.breakdown?.emergencyOverride || !!decision.breakdown?.overLimit;
2003
2072
  if (decision.tier !== null && decision.tier >= 2) {
2004
2073
  const isT2 = decision.tier === 2;
2005
2074
  const targets = decision.tierTargetBlocks ?? [];
2006
2075
  const blockList = formatTierTargetBlocks(targets);
2007
2076
  const startId = targets[0]?.blockId ?? "b1";
2008
2077
  const endId = targets[targets.length - 1]?.blockId ?? "b5";
2078
+ const voice = isEmergency ? "emergency" : "gentle";
2079
+ const triggerLine = isEmergency ? `[EMERGENCY \u2014 TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"}] Context limit reached \u2014 distill NOW into a denser summary to reclaim tokens.` : `[TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"} TRIGGER]`;
2009
2080
  return {
2010
- voice: "gentle",
2081
+ voice,
2011
2082
  text: [
2012
2083
  efficiencyNote(prompts),
2013
2084
  "",
2014
2085
  breakdownStr,
2015
2086
  "",
2016
- `[TIER ${decision.tier} ${isT2 ? "DISTILLATION" : "CONDENSATION"} TRIGGER]`,
2087
+ triggerLine,
2017
2088
  isT2 ? `Your tier-1 compression summaries have accumulated. Distill them into a single denser tier-2 summary. Use block IDs as boundaries (startId and endId as bN). Any raw (uncompressed) messages sitting between the boundary blocks are absorbed into the tier-2 block as well \u2014 apply HOW TO COMPRESS to those raw messages and the TIER 2 distillation rules to the existing summaries, so the whole span is covered and nothing is lost.` : `Your tier-2 compression summaries have accumulated. Condense them further into a tier-3 ultra-condensed summary. Use block IDs as boundaries (startId and endId as bN). Any raw (uncompressed) messages sitting between the boundary blocks are absorbed into the tier-3 block as well \u2014 apply HOW TO COMPRESS to those raw messages and the TIER 3 condensation rules to the existing summaries, so the whole span is covered and nothing is lost.`,
2018
2089
  blockList,
2019
2090
  `Example: compress({ content: [{ startId: "${startId}", endId: "${endId}", summary: "..." }] })`,
@@ -2024,7 +2095,6 @@ function renderNudgeText(decision, prompts = defaultPrompts) {
2024
2095
  ].join("\n")
2025
2096
  };
2026
2097
  }
2027
- const isEmergency = !!decision.breakdown?.emergencyOverride || !!decision.breakdown?.overLimit;
2028
2098
  if (isEmergency) {
2029
2099
  return {
2030
2100
  voice: "emergency",
@@ -2388,9 +2458,9 @@ function charBigrams(text) {
2388
2458
  }
2389
2459
  return grams;
2390
2460
  }
2391
- function tfMap(text, stem2) {
2461
+ function tfMap(text, stem22) {
2392
2462
  const m = /* @__PURE__ */ new Map();
2393
- for (const t of tokenize(text, { stem: stem2 })) m.set(t, (m.get(t) ?? 0) + 1);
2463
+ for (const t of tokenize(text, { stem: stem22 })) m.set(t, (m.get(t) ?? 0) + 1);
2394
2464
  return m;
2395
2465
  }
2396
2466
  var bm25Algorithm = {
@@ -2784,6 +2854,13 @@ function compressToolArgs(call) {
2784
2854
  function projectMessage(message, id) {
2785
2855
  const msg = message;
2786
2856
  const role = msg.role;
2857
+ if (role === "compactionSummary" || role === "branchSummary") {
2858
+ const text = (msg.summary ?? "").trim();
2859
+ if (text.length === 0) return [];
2860
+ const label = role === "branchSummary" ? "branch summary" : "compaction summary";
2861
+ return [{ id, role: "user", contentType: "text", text: `${SUMMARY_HEADER} \u2014 omp ${label}
2862
+ ${text}` }];
2863
+ }
2787
2864
  if (role === "user") {
2788
2865
  return [{ id, role: "user", contentType: "text", text: extractText(msg.content) }];
2789
2866
  }
@@ -3081,8 +3158,13 @@ function rangeFingerprints(ranges, coreMessages, byRef, blocks) {
3081
3158
  }
3082
3159
 
3083
3160
  // src/runtime.ts
3084
- function freshSlot() {
3085
- return { identities: [], foldedLen: 0, preview: false, state: createInitialState(), coreMessages: [], appliedCallIds: /* @__PURE__ */ new Set() };
3161
+ function freshSlot(preserveFrom) {
3162
+ const slot = { identities: [], foldedLen: 0, preview: false, state: createInitialState(), coreMessages: [], appliedCallIds: /* @__PURE__ */ new Set(), rejectStreak: 0 };
3163
+ if (preserveFrom) {
3164
+ slot.state = { ...slot.state, nudge: preserveFrom.state.nudge };
3165
+ slot.rejectStreak = preserveFrom.rejectStreak;
3166
+ }
3167
+ return slot;
3086
3168
  }
3087
3169
  function stateHasCompressCall(state, callId) {
3088
3170
  return state.blocks.some((b) => b.compressCallId === callId);
@@ -3128,7 +3210,7 @@ function createRuntime(adapter) {
3128
3210
  let slot = slotFor(sid);
3129
3211
  if (slot.preview) {
3130
3212
  debug.event("fold-refold", { sid, foldedLen: slot.foldedLen, lcp: 0, streamLen: stream.length, reason: "preview" });
3131
- slot = freshSlot();
3213
+ slot = freshSlot(slot);
3132
3214
  slots.set(sid, slot);
3133
3215
  }
3134
3216
  const ids = stream.map(messageIdentity);
@@ -3136,7 +3218,7 @@ function createRuntime(adapter) {
3136
3218
  while (lcp < Math.min(ids.length, slot.identities.length) && ids[lcp] === slot.identities[lcp]) lcp++;
3137
3219
  if (lcp < slot.foldedLen) {
3138
3220
  debug.event("fold-refold", { sid, foldedLen: slot.foldedLen, lcp, streamLen: ids.length });
3139
- slot = freshSlot();
3221
+ slot = freshSlot(slot);
3140
3222
  slots.set(sid, slot);
3141
3223
  lcp = 0;
3142
3224
  }
@@ -3218,6 +3300,11 @@ function createRuntime(adapter) {
3218
3300
  slot.state = state;
3219
3301
  if (toolCallId) slot.appliedCallIds.add(toolCallId);
3220
3302
  }
3303
+ function noteCompressOutcome(ctx, ok) {
3304
+ const slot = slotFor(sidOf(ctx));
3305
+ slot.rejectStreak = ok ? 0 : slot.rejectStreak + 1;
3306
+ return slot.rejectStreak;
3307
+ }
3221
3308
  return {
3222
3309
  core,
3223
3310
  get adapter() {
@@ -3237,6 +3324,7 @@ function createRuntime(adapter) {
3237
3324
  foldStream,
3238
3325
  stateFor,
3239
3326
  commitFoldState,
3327
+ noteCompressOutcome,
3240
3328
  forgetSession,
3241
3329
  primeFold,
3242
3330
  acquireLock
@@ -3310,7 +3398,17 @@ function makeCompressTool(runtime) {
3310
3398
  return {
3311
3399
  name: "compress",
3312
3400
  label: "Compress",
3313
- description: 'Replace older conversation ranges with detailed summaries you write. Single range: compress({ content: [{ "topic": "Session Opener", "startId": "m00004", "endId": "m00022", "summary": "..." }] }) \u2014 a short topic label is recommended but optional. Batch: one entry per range in content[]. The JSON you write must be strict: escape every double quote inside summaries.',
3401
+ // Stay a top-level tool. Extension tools default to "discoverable", which
3402
+ // omp's tools.xdev mounts behind the xd://compress device — forcing the
3403
+ // model to hand-write JSON-inside-a-JSON-string via the write tool. That
3404
+ // double-escaping layer was the direct cause of issue #21's parse errors
3405
+ // and truncated write calls; top-level structured args eliminate it.
3406
+ // (Found independently in #36, which also surfaced that device-mounted
3407
+ // descriptions are capped at 200 chars — XDEV_EXTERNAL_DESCRIPTION_CAP in
3408
+ // the host — so the escaping guidance in this description never even
3409
+ // reached the model while device-mounted.)
3410
+ loadMode: "essential",
3411
+ description: 'Replace older conversation ranges with detailed summaries you write. Single range: compress({ content: [{ "topic": "Session Opener", "startId": "m00004", "endId": "m00022", "summary": "..." }] }) \u2014 a short topic label is recommended but optional. Batch: one entry per range in content[].',
3314
3412
  parameters: CompressParams,
3315
3413
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
3316
3414
  let result;
@@ -3357,7 +3455,7 @@ async function handleCompress(args, runtime, ctx, toolCallId) {
3357
3455
  const invalidRanges = rangeSpecs.filter((r) => !r.startRef || !r.endRef || typeof r.startRef !== "string" || typeof r.endRef !== "string");
3358
3456
  if (invalidRanges.length > 0) {
3359
3457
  logError("compress", { sid: ctx.sessionManager.getSessionId(), event: "invalid-ranges", count: invalidRanges.length, ranges: invalidRanges.map((r) => `${r.startRef}..${r.endRef}`) });
3360
- return `Rejected: ${invalidRanges.length} range(s) have invalid startId or endId (missing or non-string). All ranges must have valid message refs (e.g. "m00005") or block IDs (e.g. "b3"). No changes applied \u2014 run acp_status for current refs.`;
3458
+ return rejectionMessage(ctx, runtime, `Rejected: ${invalidRanges.length} range(s) have invalid startId or endId (missing or non-string). All ranges must have valid message refs (e.g. "m00005") or block IDs (e.g. "b3"). No changes applied \u2014 run acp_status for current refs.`);
3361
3459
  }
3362
3460
  let applied;
3363
3461
  try {
@@ -3369,12 +3467,13 @@ async function handleCompress(args, runtime, ctx, toolCallId) {
3369
3467
  });
3370
3468
  } catch (e) {
3371
3469
  logThrow("compress", e, { sid: ctx.sessionManager.getSessionId(), phase: "applyCompression", ranges: rangeSpecs.length });
3372
- return `Compression failed: ${e instanceof Error ? e.message : String(e)}. No changes applied \u2014 state is unchanged.`;
3470
+ return rejectionMessage(ctx, runtime, `Compression failed: ${e instanceof Error ? e.message : String(e)}. No changes applied \u2014 state is unchanged.`);
3373
3471
  }
3374
3472
  if (applied.result.errors.length > 0) {
3375
3473
  logError("compress", { sid: ctx.sessionManager.getSessionId(), event: "apply-errors", count: applied.result.errors.length, errors: applied.result.errors.slice(0, 5) });
3376
- return `Compression rejected: ${applied.result.errors.join("; ")}. No changes applied \u2014 run acp_status to verify current state.`;
3474
+ return rejectionMessage(ctx, runtime, `Compression rejected: ${applied.result.errors.join("; ")}. No changes applied \u2014 run acp_status to verify current state.`);
3377
3475
  }
3476
+ runtime.noteCompressOutcome(ctx, true);
3378
3477
  await runtime.commitFoldState(ctx, applied.state, toolCallId);
3379
3478
  const { blocksCreated, tokensCompressed, warnings } = applied.result;
3380
3479
  const afterTokens = Math.max(0, beforeTokens - tokensCompressed);
@@ -3415,6 +3514,22 @@ async function handleCompress(args, runtime, ctx, toolCallId) {
3415
3514
  releaseLock();
3416
3515
  }
3417
3516
  }
3517
+ var LOOP_GUARD_STOP = 3;
3518
+ var LOOP_GUARD_SUPPRESS = 4;
3519
+ function rejectionMessage(ctx, runtime, base) {
3520
+ const streak = runtime.noteCompressOutcome(ctx, false);
3521
+ if (streak >= LOOP_GUARD_SUPPRESS) {
3522
+ logWarn("compress", { sid: ctx.sessionManager.getSessionId(), event: "loop-guard", streak, mode: "suppressed" });
3523
+ return `Compression rejected (again \u2014 ${streak} consecutive rejections). No changes applied. STOP calling compress; it is not converging. Continue the task. Compress stays available: a fresh attempt works when acp_status shows a range that can meet the minimum size.`;
3524
+ }
3525
+ if (streak >= LOOP_GUARD_STOP) {
3526
+ logWarn("compress", { sid: ctx.sessionManager.getSessionId(), event: "loop-guard", streak, mode: "stop-directive" });
3527
+ return `${base}
3528
+
3529
+ STOP: ${streak} compress calls rejected in a row. Do NOT retry the same range. Run acp_status to see what is actually compressible now; if no range can meet the minimum size, nothing is left to compress \u2014 stop and continue the actual task.`;
3530
+ }
3531
+ return base;
3532
+ }
3418
3533
 
3419
3534
  // src/decompress-tool.ts
3420
3535
  import { type as type2 } from "@oh-my-pi/omptype";
@@ -3435,6 +3550,9 @@ function makeDecompressTool(runtime) {
3435
3550
  return {
3436
3551
  name: "decompress",
3437
3552
  label: "Decompress",
3553
+ // Top-level (see compress-tool.ts): the xd:// device indirection trades
3554
+ // a few schema tokens per request for a JSON-in-JSON escaping trap.
3555
+ loadMode: "essential",
3438
3556
  description: "Restore a previously compressed block's content, or a single message by its ref. The block/message stays compressed \u2014 context and cache prefix are not disrupted. BLOCK decompress (blockId b5) defaults to writing a file (blocks can be large); use the read tool to access it, or inline:true to return inline. MESSAGE decompress (blockId = a message ref from search_context) returns that ONE message's original text \u2014 defaults to inline since a single message is usually small; oversized messages go to a file. full:true recurses through nested block tiers (block mode only). You can pass a block id (b5) OR a message ref (e.g. m00123) from search_context results.",
3439
3557
  parameters: DecompressParams,
3440
3558
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -3629,6 +3747,9 @@ function makeSearchTool(runtime) {
3629
3747
  return {
3630
3748
  name: "search_context",
3631
3749
  label: "Search Context",
3750
+ // Top-level (see compress-tool.ts): schema is tiny; the xd://
3751
+ // indirection costs more in call friction than it saves in tokens.
3752
+ loadMode: "essential",
3632
3753
  description: "Search compressed blocks AND historical messages by keyword. Use to cheaply locate detail before decompressing. Returns ranked results with ref, size, preview, and the decompress command to retrieve full content.",
3633
3754
  parameters: SearchParams,
3634
3755
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -3697,6 +3818,420 @@ function getSystemPromptText(ctx) {
3697
3818
  return normalizeSystemPrompt(result);
3698
3819
  }
3699
3820
 
3821
+ // node_modules/billion-context-kit/node_modules/acp-kernel/dist/index.js
3822
+ import { createRequire as createRequire2 } from "module";
3823
+ var BLOCKED_REF2 = "BLOCKED";
3824
+ function refForRaw2(map, rawId) {
3825
+ return map.byRaw[rawId] ?? null;
3826
+ }
3827
+ var require22 = createRequire2(import.meta.url);
3828
+ function defaultCountTokens2(text) {
3829
+ if (!text) return 0;
3830
+ const cjk = text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g);
3831
+ const cjkCount = cjk?.length ?? 0;
3832
+ return cjkCount + Math.ceil((text.length - cjkCount) / 4);
3833
+ }
3834
+ function formatTokens4(tokens) {
3835
+ if (tokens < 1e3) return String(tokens);
3836
+ if (tokens < 1e4) return (tokens / 1e3).toFixed(1) + "K";
3837
+ return Math.round(tokens / 1e3) + "K";
3838
+ }
3839
+ function classifyType2(message) {
3840
+ if (message.contentType === "tool-call" || message.contentType === "tool-result") {
3841
+ return message.toolName || "tool";
3842
+ }
3843
+ return message.contentType;
3844
+ }
3845
+ function escapeRegex2(s) {
3846
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3847
+ }
3848
+ var LT2 = "<";
3849
+ var GT2 = ">";
3850
+ var TAG_OPEN2 = LT2 + "acp ";
3851
+ var TAG_CLOSE2 = LT2 + "/acp" + GT2;
3852
+ function acpTag2(ref, tokens, type5) {
3853
+ return TAG_OPEN2 + 'tokens="' + formatTokens4(tokens) + '" type="' + type5 + '"' + GT2 + ref + TAG_CLOSE2;
3854
+ }
3855
+ function renderMessage2(message, map, countTokens, strategy) {
3856
+ const ref = refForRaw2(map, message.id);
3857
+ if (!ref || ref === BLOCKED_REF2) return message;
3858
+ if (strategy === "none") return message;
3859
+ if (strategy === "text-only" && message.contentType !== "text") {
3860
+ return message;
3861
+ }
3862
+ const ownTagRe = new RegExp(
3863
+ "^" + escapeRegex2(TAG_OPEN2) + "[^>]*" + GT2 + escapeRegex2(ref) + escapeRegex2(TAG_CLOSE2) + "\\n?"
3864
+ );
3865
+ const cleanText = (message.text || "").replace(ownTagRe, "");
3866
+ const tokens = countTokens(cleanText);
3867
+ const type5 = classifyType2(message);
3868
+ const prefix = acpTag2(ref, tokens, type5) + "\n";
3869
+ if (!cleanText) return { ...message, text: prefix };
3870
+ return { ...message, text: prefix + cleanText };
3871
+ }
3872
+ function renderVisibleRefs2(messages, state, countTokens = (text) => Math.ceil(text.length / 4), strategy = "all") {
3873
+ const map = state.messageRefs;
3874
+ return messages.map(
3875
+ (message) => renderMessage2(message, map, countTokens, strategy)
3876
+ );
3877
+ }
3878
+ function createRenderRefsNode2(strategy) {
3879
+ return {
3880
+ name: "render-refs",
3881
+ run(io, ctx) {
3882
+ return {
3883
+ ...io,
3884
+ messages: renderVisibleRefs2(io.messages, io.state, ctx.countTokens, strategy)
3885
+ };
3886
+ }
3887
+ };
3888
+ }
3889
+ var renderRefsNode2 = createRenderRefsNode2("all");
3890
+ var COMPRESS_PHILOSOPHY2 = `Compression Philosophy:
3891
+ - All compression serves the primary task, but be frugal.
3892
+ - Context capacity is precious. Save context by compressing consumed outputs, not by avoiding tools.
3893
+ - Compress by need, not by percentage.
3894
+ - Work from summaries, not raw tool outputs. All listed ranges (user prompts, tool outputs, code, logs, exploration, intermediate steps) should be compressed to summary format \u2014 the ONLY exceptions are protected content, content the current step is actively using, or critical content you cannot reconstruct.`;
3895
+ var HOW_TO_COMPRESS_RULES2 = `HOW TO COMPRESS
3896
+
3897
+ When you call \`compress\`, the summary you write becomes the only record of the replaced conversation. Make it self-contained and complete: every user request, experiment purpose, and work task in the range must be accurately captured. A later reader (or you, after decompressing) should be able to continue the task WITHOUT needing the original.
3898
+
3899
+ KEEP VERBATIM \u2014 never paraphrase or abbreviate these:
3900
+ - Full file paths with line numbers, directory prefix on every mention (\`lib/hooks.ts:347\`, \`src/index.ts:12-18\`, \`gatenet_v3/model.py:45\`). Never abbreviate to a bare filename (\`hooks.ts\`, \`model.py\`) \u2014 they are ambiguous and cannot be grepped or decompressed-to later.
3901
+ - Function, class, and type signatures (exact names, params, return types) AND critical code lines that encode logic \u2014 the line that IS the finding, not just the function name (e.g. \`kv_keys += define_gate * a_key[i](emb)\` is more useful than "see model_kvnet.py").
3902
+ - Error messages and stack traces (exact text \u2014 you need the literal string to grep for it later).
3903
+ - Key details from reports and analyses \u2014 not just the conclusion. Keep the comparison numbers and the mechanism, not "X is worse" alone (write "1.76\xD7 PPL gap because KV store is static", not "KVNet underperforms").
3904
+ - Decisions and their rationale ("chose X over Y because Z" \u2014 the "because" is load-bearing; without it the decision looks arbitrary).
3905
+ - Constraints discovered ("must support Node 22", "no new dependencies", "AGENTS.md forbids \`as any\`").
3906
+ - Exact values: versions, config keys, thresholds, magic numbers.
3907
+ - User intent \u2014 quote short user messages verbatim. When the message is too long to quote, preserve intent with extra care: do not change scope, constraints, priorities, acceptance criteria, or requested outcomes. Mark them clearly as past quotes (e.g., "User said: ..."), not as current directives. Losing these changes the task itself.
3908
+ - The user's overall goal and any changes to it \u2014 the big-picture objective plus how it evolved during the compressed range. Each summary must reflect the goal as it stood at the end of the range, including pivots (e.g., "initially: fix bug X \u2192 pivoted to: refactor module Y after discovering root cause"). Losing the goal or its evolution makes all subsequent work appear unmotivated.
3909
+ - Purpose behind each significant action \u2014 preserve not just what was done but why: the hypothesis behind each experiment, the question behind each exploration, the task goal behind each work action. Without purpose, the summary reads as disconnected technical steps with no through-line.
3910
+ - Open questions and unresolved TODOs \u2014 losing these changes what work appears to remain.
3911
+ - Message refs of key anchors (\`m00420\`, \`m00510\u2013m00520\`) \u2014 they let you or a later reader jump back via decompress to the exact original.
3912
+
3913
+ DROP \u2014 extract the signal, discard the vessel:
3914
+ - Verbose logs (build/test/\`npm\` output) once you have captured the error line or the result.
3915
+ - Duplicate file reads once the needed content is recorded.
3916
+ - Consumed exploration \u2014 search hits, agent return values, successful tool outputs \u2014 once you have extracted the facts you need (same rule as dead-ends, but nothing went wrong; the content is simply spent).
3917
+ - Dead-end exploration \u2014 but PRESERVE the lesson in one line: "tried X, failed because Y".
3918
+ - Back-and-forth discussion and self-corrections once the final position is captured (keep the outcome, drop the journey to it).
3919
+ - Repeated status checks (\`git status\`, \`ls\`) once state is known.
3920
+
3921
+ For each significant item you DROP (scripts, reports, large analyses, long tool outputs), add a one-line CONTENT description of what it covers \u2014 not where it lives. Bad: "probe script at /path/probe_kvnet.py". Good: "probe_kvnet.py: tests n-gram baseline, generation quality, long-range dependency, position sensitivity, op pipeline, QUERY attention." This lets a later decompress target the right block by relevance, not by guessing locations.
3922
+
3923
+ PRIORITY \u2014 when the summary must be compact, preserve in this order:
3924
+ 1. User's overall goal, goal evolution, intent, and hard constraints (losing these changes the task).
3925
+ 2. Decisions and rationale.
3926
+ 3. Exact technical artifacts: paths, signatures, errors, values.
3927
+ 4. Conclusions and key findings.
3928
+ 5. Lessons learned: what failed and why.
3929
+
3930
+ Write dense, scannable bullets \u2014 not narrative prose. If the range spans distinct concerns (request \u2192 findings \u2192 decision), group bullets under short thematic headers so a reader can scan to the part they need. Every line must earn its place. Do not mimic the style of existing summaries in context; follow these rules.`;
3931
+ var TIER2_DISTILL_RULES2 = `TIER 2 COMPRESSION \u2014 DISTILLATION
3932
+
3933
+ You are compressing historical summaries (not raw conversation). These summaries have already captured the details. Your job is to DISTILL them: extract only what matters for future work, discard the process.
3934
+
3935
+ KEEP \u2014 these are the only things that survive distillation:
3936
+ - Decisions and their rationale ("chose X over Y because Z" \u2014 the "because" is load-bearing).
3937
+ - Final outcomes: version numbers shipped, PR numbers merged/closed, bugs fixed or deferred.
3938
+ - Key lessons: what failed and why ("tried X, failed because Y"). These prevent repeating mistakes.
3939
+ - Critical constraints discovered ("must support Node 22", "AGENTS.md forbids as any").
3940
+ - Design decisions with architectural impact ("chose compress-as-anchor over synthetic messages because prefix cache").
3941
+ - Whether content is OBSOLETE or SUPERSEDED \u2014 mark with one line: "[SUPERSEDED by PR #NNN]" or "[OBSOLETE: deleted in vX.Y.Z]". Do NOT keep the obsolete content's details \u2014 just the marker and reason.
3942
+ - Function/class/type names and module paths that are the SUBJECT of the work \u2014 e.g., "fixed filterCompressedRanges in prune.ts", "added SessionStateRegistry in state.ts". Not exact line numbers or full signatures \u2014 just enough to LOCATE the code without searching.
3943
+ - Exploration findings: if a block was exploratory with no decision, keep the CONCLUSION in one line ("explored X, not viable because Y"). Do not keep the exploration process.
3944
+
3945
+ DROP \u2014 these were useful during the work but are no longer needed:
3946
+ - Exact line numbers, diffs, verbose function signatures, full code listings.
3947
+ - Build/deploy process details, test execution steps.
3948
+ - Review process details (who reviewed, what rounds, test counts).
3949
+ - Verbose logs, command output, intermediate debugging steps.
3950
+
3951
+ FORMAT:
3952
+ - Start each distilled block with a source header line:
3953
+ \`Source: bN+bM+... (XK\u2192YK tok, Zx). [original topic]\`
3954
+ Example: \`Source: b5+b7 (56K+44K\u2192268 tok, 375x). [Tool-result recap + publish]\`
3955
+ - 3-5 bullet points per source block, each a self-contained fact.
3956
+ - Dense, scannable \u2014 no narrative prose.
3957
+ - Start with the outcome, not the process: "v1.13.0 shipped (7 PRs bundled)" not "implemented 7 PRs then reviewed then merged".
3958
+ - Cross-block synthesis: if multiple source blocks cover the same topic (same PR, same feature, same bug), MERGE them into a single group of bullets. Do not repeat the same fact from different blocks \u2014 keep it once under the most relevant source header.
3959
+
3960
+ SIZE TARGET: 50-150 tokens per source block (excluding the header). If you can't fit it in 150 tokens, you're keeping too much process. If a block has nothing worth keeping (pure noise), output just the header followed by "[no actionable content]."`;
3961
+ var TIER3_CONDENSE_RULES2 = `TIER 3 COMPRESSION \u2014 ULTRA-CONDENSATION
3962
+
3963
+ You are compressing distilled summaries (Tier 2) into ultra-condensed facts (Tier 3). The distilled summaries already contain only decisions and outcomes. Your job is to reduce them to bare factual references.
3964
+
3965
+ PRIORITY \u2014 when a source block has more facts than the size target allows, keep in this order:
3966
+ 1. Shipped outcomes (versions released, PRs merged) \u2014 these are permanent record.
3967
+ 2. Open work (PRs/issues still pending) \u2014 these may need follow-up.
3968
+ 3. Key decisions with architectural impact ("chose X over Y because Z").
3969
+ 4. Critical constraints ("must support Node 22").
3970
+ Drop everything else. Tier 3 is a lookup index, not a knowledge base.
3971
+
3972
+ FORMAT:
3973
+ - Start with a source header line:
3974
+ \`Source: bN+bM+... (XK\u2192YK tok, Zx). [original topic]\`
3975
+ - Output 1-3 facts per source block. Each fact is a single line: subject + outcome.
3976
+ - No explanations, no rationale, no process \u2014 just the fact.
3977
+ - Format: "[PR/Issue/Version] \u2014 [outcome in \u22648 words]"
3978
+ - Merge related facts from different source blocks if they concern the same topic.
3979
+
3980
+ EXAMPLES:
3981
+ - "v1.13.0 shipped \u2014 quality gate + GC fix (7 PRs)"
3982
+ - "PR #196 merged \u2014 preserve-first-user (supersedes #169)"
3983
+ - "Bug 1214 fixed \u2014 compress consumed all user messages"
3984
+ - "Chose compress-as-anchor \u2014 prefix cache benefit over synthetic injection"
3985
+ - "Constraint: AGENTS.md forbids as any \u2014 never suppress types"
3986
+
3987
+ DROP:
3988
+ - Multi-sentence context. If a fact needs >1 sentence, it's too detailed for Tier 3.
3989
+ - Lessons learned ("tried X, failed because Y") \u2014 drop UNLESS the failure is likely to recur and the block is <30 days old.
3990
+ - Design rationale details \u2014 keep the decision, drop the "because" unless it's a critical constraint.
3991
+ - Anything marked [OBSOLETE] or [SUPERSEDED] \u2014 drop entirely, note "[N blocks obsolete]" in the summary.
3992
+
3993
+ SIZE TARGET: 30-60 tokens per source block (including header). For a batch of N source blocks, total output \u2248 N \xD7 40 tokens. If a source block has only one trivial fact, output just the header + one line.`;
3994
+ var defaultPrompts2 = Object.freeze({
3995
+ compressPhilosophy: COMPRESS_PHILOSOPHY2,
3996
+ howToCompressRules: HOW_TO_COMPRESS_RULES2,
3997
+ tier2DistillRules: TIER2_DISTILL_RULES2,
3998
+ tier3CondenseRules: TIER3_CONDENSE_RULES2
3999
+ });
4000
+ function formatK2(n) {
4001
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
4002
+ return `${n}`;
4003
+ }
4004
+ function formatRanges2(compressible, protectedRanges) {
4005
+ if (compressible.length === 0 && protectedRanges.length === 0) {
4006
+ return "[No specific ranges detected \u2014 compress any consumed content.]";
4007
+ }
4008
+ const refNum2 = (ref) => {
4009
+ const m = ref.match(/\d+/);
4010
+ return m ? parseInt(m[0], 10) : 0;
4011
+ };
4012
+ const entries = [];
4013
+ for (const r of compressible) {
4014
+ entries.push({
4015
+ startRef: r.startRef,
4016
+ endRef: r.endRef,
4017
+ startNum: refNum2(r.startRef),
4018
+ endNum: refNum2(r.endRef),
4019
+ count: r.count,
4020
+ tokens: r.tokens,
4021
+ toolPct: r.toolPct,
4022
+ textPct: r.textPct,
4023
+ compressibleTokens: r.tokens,
4024
+ compressibleCount: r.count,
4025
+ protectedTokens: 0,
4026
+ protectedCount: 0,
4027
+ protectedTools: [],
4028
+ dangerous: r.dangerous ?? false
4029
+ });
4030
+ }
4031
+ for (const r of protectedRanges) {
4032
+ entries.push({
4033
+ startRef: r.startRef,
4034
+ endRef: r.endRef,
4035
+ startNum: refNum2(r.startRef),
4036
+ endNum: refNum2(r.endRef),
4037
+ count: r.count,
4038
+ tokens: r.tokens,
4039
+ toolPct: 0,
4040
+ textPct: 0,
4041
+ compressibleTokens: 0,
4042
+ compressibleCount: 0,
4043
+ protectedTokens: r.tokens,
4044
+ protectedCount: r.count,
4045
+ protectedTools: [...r.tools],
4046
+ dangerous: false
4047
+ });
4048
+ }
4049
+ entries.sort((a, b) => a.startNum - b.startNum);
4050
+ const merged = [];
4051
+ for (const e of entries) {
4052
+ const last = merged[merged.length - 1];
4053
+ if (last && e.startNum <= last.endNum + 1) {
4054
+ last.endRef = e.endRef;
4055
+ last.endNum = Math.max(last.endNum, e.endNum);
4056
+ last.count += e.count;
4057
+ last.tokens += e.tokens;
4058
+ last.compressibleTokens += e.compressibleTokens;
4059
+ last.compressibleCount += e.compressibleCount;
4060
+ last.protectedTokens += e.protectedTokens;
4061
+ last.protectedCount += e.protectedCount;
4062
+ if (e.dangerous) last.dangerous = true;
4063
+ for (const t of e.protectedTools) {
4064
+ if (!last.protectedTools.includes(t)) last.protectedTools.push(t);
4065
+ }
4066
+ } else {
4067
+ merged.push({ ...e });
4068
+ }
4069
+ }
4070
+ const lines = merged.map((e) => {
4071
+ const suffix = e.dangerous && e.compressibleTokens > 0 ? " \u26A0\uFE0F NOT recommended unless you are certain." : "";
4072
+ if (e.protectedTokens > 0 && e.compressibleTokens === 0) {
4073
+ return ` ${e.startRef}\u2013${e.endRef} ${e.count} msgs ${formatK2(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} \u2014 not compressible]${suffix}`;
4074
+ }
4075
+ if (e.protectedTokens > 0 && e.compressibleTokens > 0) {
4076
+ return ` ${e.startRef}\u2013${e.endRef} ${e.count} msgs ${formatK2(e.tokens)} [${formatK2(e.compressibleTokens)} compressible | ${formatK2(e.protectedTokens)} protected: ${e.protectedTools.join(", ")}]${suffix}`;
4077
+ }
4078
+ return ` ${e.startRef}\u2013${e.endRef} ${e.count} msgs ${formatK2(e.tokens)} [tool ${e.toolPct}% | text ${e.textPct}%]${suffix}`;
4079
+ });
4080
+ return `Compressible ranges (${merged.length}, oldest first):
4081
+ ${lines.join("\n")}`;
4082
+ }
4083
+ var substringAlgorithm2 = {
4084
+ name: "substring",
4085
+ description: "Exact substring counting (original baseline). Predictable, no normalization.",
4086
+ score(docs, query) {
4087
+ const terms = query.toLowerCase().trim().split(/\s+/).filter((t) => t.length > 0);
4088
+ if (terms.length === 0) return docs.map((d) => ({ ref: d.ref, score: 0 }));
4089
+ return docs.map((d) => {
4090
+ const haystack = d.text.toLowerCase();
4091
+ let score = 0;
4092
+ for (const term of terms) score += countOccurrences22(haystack, term);
4093
+ return { ref: d.ref, score };
4094
+ });
4095
+ }
4096
+ };
4097
+ function countOccurrences22(haystack, needle) {
4098
+ if (!needle) return 0;
4099
+ return haystack.split(needle).length - 1;
4100
+ }
4101
+ function stem2(word) {
4102
+ let w = word;
4103
+ if (w.length <= 3) return w;
4104
+ if (w.endsWith("ies")) w = w.slice(0, -3) + "y";
4105
+ else if (w.endsWith("ses") || w.endsWith("xes") || w.endsWith("zes")) w = w.slice(0, -2);
4106
+ else if (w.endsWith("ches") || w.endsWith("shes")) w = w.slice(0, -2);
4107
+ else if (w.endsWith("s") && !w.endsWith("ss")) w = w.slice(0, -1);
4108
+ if (w.endsWith("ing") && w.length > 5) w = w.slice(0, -3);
4109
+ if (w.endsWith("ed") && w.length > 4) w = w.slice(0, -2);
4110
+ if (w.endsWith("ation") && w.length > 6) w = w.slice(0, -3);
4111
+ else if (w.endsWith("tion") && w.length > 5) w = w.slice(0, -4) + "t";
4112
+ else if (w.endsWith("ion") && w.length > 4) w = w.slice(0, -3);
4113
+ if (w.endsWith("ment") && w.length > 6) w = w.slice(0, -4);
4114
+ if (w.endsWith("ness") && w.length > 6) w = w.slice(0, -4);
4115
+ if (w.endsWith("ly") && w.length > 4) w = w.slice(0, -2);
4116
+ return w;
4117
+ }
4118
+ var CJK2 = /[\u3400-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/;
4119
+ var CJK_RUN2 = new RegExp(`${CJK2.source}+`, "g");
4120
+ var LATIN_WORD2 = /[a-z][a-z0-9_]*[a-z0-9]|[a-z0-9]/g;
4121
+ function tokenize2(text, opts = {}) {
4122
+ const lower = text.toLowerCase();
4123
+ const tokens = [];
4124
+ const latin = lower.match(LATIN_WORD2) ?? [];
4125
+ for (let w of latin) {
4126
+ if (w.length >= 2) {
4127
+ if (opts.stem) w = stem2(w);
4128
+ tokens.push(w);
4129
+ }
4130
+ }
4131
+ const cjkRuns = lower.match(CJK_RUN2) ?? [];
4132
+ for (const run of cjkRuns) {
4133
+ if (run.length === 1) {
4134
+ tokens.push(run);
4135
+ } else {
4136
+ for (let i = 0; i < run.length - 1; i++) tokens.push(run.slice(i, i + 2));
4137
+ for (const ch of run) tokens.push(ch);
4138
+ }
4139
+ }
4140
+ return tokens;
4141
+ }
4142
+ function charBigrams2(text) {
4143
+ const grams = [];
4144
+ for (let i = 0; i < text.length - 1; i++) {
4145
+ const pair = text.slice(i, i + 2);
4146
+ if (pair.trim().length === pair.length) grams.push(pair);
4147
+ }
4148
+ return grams;
4149
+ }
4150
+ function tfMap2(text, stem22) {
4151
+ const m = /* @__PURE__ */ new Map();
4152
+ for (const t of tokenize2(text, { stem: stem22 })) m.set(t, (m.get(t) ?? 0) + 1);
4153
+ return m;
4154
+ }
4155
+ var bm25Algorithm2 = {
4156
+ name: "bm25",
4157
+ description: "BM25 with stemming + CJK bigram tokenization. IR-standard relevance ranking.",
4158
+ score(docs, query) {
4159
+ const N = docs.length;
4160
+ const k1 = 1.2;
4161
+ const b = 0.75;
4162
+ const parsed = docs.map((d) => {
4163
+ const text = d.text;
4164
+ const tf = tfMap2(text, true);
4165
+ let len = 0;
4166
+ for (const v of tf.values()) len += v;
4167
+ return { id: d.ref, tf, len };
4168
+ });
4169
+ const avgdl = parsed.reduce((s, d) => s + d.len, 0) / (N || 1);
4170
+ const qTerms = tokenize2(query, { stem: true });
4171
+ if (qTerms.length === 0) return docs.map((d) => ({ ref: d.ref, score: 0 }));
4172
+ const idf = /* @__PURE__ */ new Map();
4173
+ for (const t of new Set(qTerms)) {
4174
+ let df = 0;
4175
+ for (const d of parsed) if (d.tf.has(t)) df++;
4176
+ idf.set(t, Math.log(1 + (N - df + 0.5) / (df + 0.5)));
4177
+ }
4178
+ return parsed.map((d) => {
4179
+ let score = 0;
4180
+ for (const t of qTerms) {
4181
+ const f = d.tf.get(t) ?? 0;
4182
+ if (f === 0) continue;
4183
+ const idfT = idf.get(t) ?? 0;
4184
+ score += idfT * (f * (k1 + 1)) / (f + k1 * (1 - b + b * d.len / (avgdl || 1)));
4185
+ }
4186
+ return { ref: d.id, score };
4187
+ });
4188
+ }
4189
+ };
4190
+ var fuzzyAlgorithm2 = {
4191
+ name: "fuzzy",
4192
+ description: "Character bigram overlap. Typo-tolerant, script-agnostic, high recall.",
4193
+ score(docs, query) {
4194
+ const qTokens = query.toLowerCase().split(/[\s,]+/).filter((t) => t.length >= 4);
4195
+ if (qTokens.length === 0) return docs.map((d) => ({ ref: d.ref, score: 0 }));
4196
+ const qGrams = /* @__PURE__ */ new Set();
4197
+ for (const t of qTokens) for (const g of charBigrams2(t)) qGrams.add(g);
4198
+ if (qGrams.size === 0) return docs.map((d) => ({ ref: d.ref, score: 0 }));
4199
+ return docs.map((d) => {
4200
+ const haystack = d.text.toLowerCase();
4201
+ const docGrams = new Set(charBigrams2(haystack));
4202
+ let hits = 0;
4203
+ for (const g of qGrams) if (docGrams.has(g)) hits++;
4204
+ return { ref: d.ref, score: hits / qGrams.size };
4205
+ });
4206
+ }
4207
+ };
4208
+ var W_BM252 = 0.7;
4209
+ var W_FUZZY2 = 0.3;
4210
+ var hybridAlgorithm2 = {
4211
+ name: "hybrid",
4212
+ description: "Weighted BM25(stem) + fuzzy n-gram. Default \u2014 best precision + recall.",
4213
+ score(docs, query) {
4214
+ const bm = bm25Algorithm2.score(docs, query);
4215
+ const fz = fuzzyAlgorithm2.score(docs, query);
4216
+ const maxBm = Math.max(...bm.map((r) => r.score), 1e-9);
4217
+ const maxFz = Math.max(...fz.map((r) => r.score), 1e-9);
4218
+ const bmMap = new Map(bm.map((r) => [r.ref, r.score / maxBm]));
4219
+ const fzMap = new Map(fz.map((r) => [r.ref, r.score / maxFz]));
4220
+ return docs.map((d) => ({
4221
+ ref: d.ref,
4222
+ score: W_BM252 * (bmMap.get(d.ref) ?? 0) + W_FUZZY2 * (fzMap.get(d.ref) ?? 0)
4223
+ }));
4224
+ }
4225
+ };
4226
+ var registry22 = /* @__PURE__ */ new Map();
4227
+ function registerSearchAlgorithm2(algo) {
4228
+ registry22.set(algo.name, algo);
4229
+ }
4230
+ registerSearchAlgorithm2(substringAlgorithm2);
4231
+ registerSearchAlgorithm2(bm25Algorithm2);
4232
+ registerSearchAlgorithm2(fuzzyAlgorithm2);
4233
+ registerSearchAlgorithm2(hybridAlgorithm2);
4234
+
3700
4235
  // node_modules/billion-context-kit/dist/index.js
3701
4236
  var VIABLE_RANGE_MIN_TOKENS = 200;
3702
4237
  function viableRanges(ranges) {
@@ -3779,14 +4314,14 @@ function buildStatusPanel(input) {
3779
4314
  const protectedRanges = nudge?.protectedRanges ?? [];
3780
4315
  if (ranges.length > 0 || protectedRanges.length > 0) {
3781
4316
  lines.push("");
3782
- lines.push(formatRanges(ranges, protectedRanges));
4317
+ lines.push(formatRanges2(ranges, protectedRanges));
3783
4318
  }
3784
4319
  if (activeBlocksList.length > 0) {
3785
4320
  lines.push("");
3786
4321
  lines.push(`Blocks: ${activeBlocksList.length} active / ${totalBlocksList.length} total (${fmt2(state.stats.tokensCompressed)} tokens compressed)`);
3787
4322
  for (const b of activeBlocksList) {
3788
4323
  const topic = b.topic ? `: ${b.topic}` : `: ${topicFallback(b.summary || "")}`;
3789
- const summaryTok = defaultCountTokens(b.summary || "");
4324
+ const summaryTok = defaultCountTokens2(b.summary || "");
3790
4325
  const origTok = b.compressedTokens > 0 ? b.compressedTokens : summaryTok;
3791
4326
  lines.push(` [${b.blockId}] T${b.tier} ${fmt2(origTok)}\u2192${fmt2(summaryTok)}${topic}`);
3792
4327
  }
@@ -3814,6 +4349,7 @@ function makeStatusTool(runtime) {
3814
4349
  return {
3815
4350
  name: "acp_status",
3816
4351
  label: "ACP Status",
4352
+ loadMode: "essential",
3817
4353
  description: "Context status: overview, compressed blocks, or uncompressed ranges/messages. No args = overview + totals + compressible ranges. scope:'uncompressed' + view:'messages' for per-message listing. scope:'compressed' for block drilldown.",
3818
4354
  parameters: StatusParams,
3819
4355
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -3956,7 +4492,7 @@ async function statusReport(runtime, ctx) {
3956
4492
  const coveredIds = collectCoveredMessageIds(state);
3957
4493
  const sentTokens = estimateTokens(coreMessages, coveredIds) + systemPromptTokens;
3958
4494
  const turn = runtime.core.processTurn({ messages: coreMessages, state, config, tokenCount: sentTokens });
3959
- const versionStr = "0.1.7" ? `billion-context-omp@${"0.1.7"}` : void 0;
4495
+ const versionStr = "0.1.9" ? `billion-context-omp@${"0.1.9"}` : void 0;
3960
4496
  return buildStatusPanel({
3961
4497
  version: versionStr,
3962
4498
  tokenCount: sessionTokens,
@@ -4062,7 +4598,7 @@ async function summarizeMessages(ctx, messages, prompts, configuredModel, opts)
4062
4598
  User instructions for this compaction: ${custom}`;
4063
4599
  const userText = `ENTIRE conversation to compress (${slice.length} messages, ~${tokens} tokens). Compress it:
4064
4600
 
4065
- ` + formatSlice(slice, createInitialState());
4601
+ ` + formatSlice(slice, opts?.messageRefs ? { ...createInitialState(), messageRefs: opts.messageRefs } : createInitialState());
4066
4602
  const response = await run(
4067
4603
  model,
4068
4604
  { systemPrompt: [instructions], messages: [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }] },
@@ -4108,7 +4644,7 @@ TOOLS
4108
4644
 
4109
4645
  You have four context-management tools:
4110
4646
 
4111
- - compress \u2014 Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Single range: compress({ content: [{ topic: "Session Opener", startId: "m00150", endId: "m00220", summary: "..." }] }) \u2014 topic is recommended but optional. Batch (multiple unrelated ranges, each with its own topic): compress({ content: [{ topic: "Auth", startId: "m00150", endId: "m00220", summary: "..." }, { topic: "Deploy", startId: "m00300", endId: "m00350", summary: "..." }] }). The JSON written to xd://compress must be strict \u2014 escape every double quote inside summaries.
4647
+ - compress \u2014 Replace a contiguous range of older conversation with a single detailed summary you write. Use when content is genuinely consumed (no longer needed for the current task step). Single range: compress({ content: [{ topic: "Session Opener", startId: "m00150", endId: "m00220", summary: "..." }] }) \u2014 topic is recommended but optional. Batch (multiple unrelated ranges, each with its own topic): compress({ content: [{ topic: "Auth", startId: "m00150", endId: "m00220", summary: "..." }, { topic: "Deploy", startId: "m00300", endId: "m00350", summary: "..." }] }). Call it as a normal tool \u2014 summaries are plain string arguments.
4112
4648
  - decompress \u2014 Restore a previously compressed block's content. The block stays compressed \u2014 context and cache prefix are not disrupted. By DEFAULT content is written to an auto-generated file (avoids context bloat); use the read tool to view it. Pass inline:true to return content in the tool result instead (appends to context). full:true recurses to original messages. Example: decompress({ blockId: "b5" }) or decompress({ blockId: "b5", full: true }) or decompress({ blockId: "b5", inline: true }).
4113
4649
  - search_context \u2014 Search compressed block summaries (and optionally visible messages) by keyword. Use BEFORE decompressing to find the right block. Example: search_context({ query: "auth token refresh" }).
4114
4650
  - acp_status \u2014 Context status with compressible ranges. No args = overview + totals. scope:"uncompressed" for range view; add view:"messages" for per-message listing. scope:"compressed" for block details.
@@ -4130,6 +4666,8 @@ WHEN NOT TO COMPRESS
4130
4666
  - Content the current task step is actively reading or reasoning about.
4131
4667
  - Important user messages \u2014 preserve their exact intent, constraints, and acceptance criteria. If a message in the range must stay verbatim, exclude it from the compress range instead of compressing it.
4132
4668
  - Protected tool outputs \u2014 hard-excluded from compression ranges, survive intact in visible context.
4669
+ - Nothing left worth compressing \u2014 if no compressible range can meet the minimum size, do NOT call compress at all. Compression is maintenance, never the task goal; when there is nothing to compress, do the task.
4670
+ - A rejected compress call \u2014 never retry a rejected range unchanged. Re-check acp_status first; after 3 rejections, stop compressing and continue the task.
4133
4671
 
4134
4672
  ${prompts.howToCompressRules}
4135
4673
 
@@ -4276,7 +4814,7 @@ var PACKAGE_NAME = "billion-context-omp";
4276
4814
  var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
4277
4815
  var SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z-.]+)?$/;
4278
4816
  var CHECK_INTERVAL_MS = 3 * 60 * 1e3;
4279
- var THROTTLE_FILE = join4(homeDir(), CONFIG_DIR_NAME3, "agent", ".billion-context-omp-update-check");
4817
+ var throttleFile = () => join4(homeDir(), CONFIG_DIR_NAME3, ".billion-context-omp-update-check");
4280
4818
  var updateInFlight = false;
4281
4819
  function parseVersion(v) {
4282
4820
  return v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
@@ -4292,7 +4830,7 @@ function isNewer(latest, current) {
4292
4830
  }
4293
4831
  async function readLastCheck() {
4294
4832
  try {
4295
- const data = await readFile(THROTTLE_FILE, "utf-8");
4833
+ const data = await readFile(throttleFile(), "utf-8");
4296
4834
  return parseInt(data.trim(), 10) || 0;
4297
4835
  } catch {
4298
4836
  return 0;
@@ -4300,8 +4838,8 @@ async function readLastCheck() {
4300
4838
  }
4301
4839
  async function writeLastCheck(timestamp) {
4302
4840
  try {
4303
- await mkdir2(dirname3(THROTTLE_FILE), { recursive: true });
4304
- await writeFile2(THROTTLE_FILE, String(timestamp), "utf-8");
4841
+ await mkdir2(dirname3(throttleFile()), { recursive: true });
4842
+ await writeFile2(throttleFile(), String(timestamp), "utf-8");
4305
4843
  } catch {
4306
4844
  }
4307
4845
  }
@@ -4383,12 +4921,12 @@ async function checkForUpdate(autoUpdate, notify) {
4383
4921
  const now = Date.now();
4384
4922
  const lastCheck = await readLastCheck();
4385
4923
  if (now - lastCheck < CHECK_INTERVAL_MS) return;
4386
- await writeLastCheck(now);
4387
4924
  const runtimeVersion = await getRuntimeVersion();
4388
4925
  const res = await fetch(REGISTRY_URL, {
4389
4926
  signal: AbortSignal.timeout(5e3),
4390
4927
  headers: { Accept: "application/json" }
4391
4928
  });
4929
+ await writeLastCheck(now);
4392
4930
  if (!res.ok) {
4393
4931
  logWarn("update", { event: "check-http", status: res.status });
4394
4932
  return;
@@ -4396,7 +4934,7 @@ async function checkForUpdate(autoUpdate, notify) {
4396
4934
  const data = await res.json();
4397
4935
  const latest = data.version;
4398
4936
  if (!latest) return;
4399
- const current = runtimeVersion ?? "0.1.7";
4937
+ const current = runtimeVersion ?? "0.1.9";
4400
4938
  const hasUpdate = isNewer(latest, current);
4401
4939
  debug.event("update-check", {
4402
4940
  current,
@@ -4431,10 +4969,27 @@ async function getRuntimeVersion() {
4431
4969
  }
4432
4970
 
4433
4971
  // src/dump.ts
4434
- import { mkdirSync as mkdirSync2, writeFileSync, readdirSync } from "fs";
4972
+ import { mkdirSync as mkdirSync2, writeFileSync, readdirSync, unlinkSync } from "fs";
4435
4973
  import * as path2 from "path";
4436
4974
  import { CONFIG_DIR_NAME as CONFIG_DIR_NAME4 } from "@oh-my-pi/pi-utils";
4437
4975
  var counters = {};
4976
+ var MAX_FILES_PER_PREFIX = 200;
4977
+ function pruneDumps(dir, prefixTest, seqOf) {
4978
+ try {
4979
+ const files = readdirSync(dir).filter(prefixTest).map((f) => ({ f, n: seqOf(f) })).filter((x) => !Number.isNaN(x.n));
4980
+ if (files.length <= MAX_FILES_PER_PREFIX) return;
4981
+ files.sort((a, b) => a.n - b.n);
4982
+ const excess = files.slice(0, files.length - MAX_FILES_PER_PREFIX);
4983
+ for (const { f } of excess) {
4984
+ try {
4985
+ unlinkSync(path2.join(dir, f));
4986
+ } catch {
4987
+ }
4988
+ }
4989
+ debug.event("dump-pruned", { dir, removed: excess.length });
4990
+ } catch {
4991
+ }
4992
+ }
4438
4993
  function dumpDir() {
4439
4994
  return path2.join(homeDir(), CONFIG_DIR_NAME4, "acp-omp-dumps");
4440
4995
  }
@@ -4469,6 +5024,7 @@ function dumpContextMessages(messages, meta) {
4469
5024
  })
4470
5025
  );
4471
5026
  debug.event("context-out-dump", { path: fullPath, msgs: messages.length });
5027
+ pruneDumps(dir, (f) => /^\d{4}\.json$/.test(f), (f) => parseInt(f, 10));
4472
5028
  return fullPath;
4473
5029
  } catch (e) {
4474
5030
  debug.event("context-out-dump-error", { error: e instanceof Error ? e.message : String(e) });
@@ -4552,6 +5108,7 @@ function dumpProviderRequest(payload, meta) {
4552
5108
  })
4553
5109
  );
4554
5110
  debug.event("provider-request-dump", { path: fullPath, ...summary });
5111
+ pruneDumps(dir, (f) => /^req_\d+\.json$/.test(f), (f) => parseInt(f.slice(4, -5), 10));
4555
5112
  return fullPath;
4556
5113
  } catch (e) {
4557
5114
  debug.event("provider-request-dump-error", { error: e instanceof Error ? e.message : String(e) });
@@ -4568,11 +5125,12 @@ async function loadUserConfig(cwd) {
4568
5125
  const merged = {};
4569
5126
  for (const base of [join7(home, CONFIG_DIR_NAME5), join7(cwd, CONFIG_DIR_NAME5)]) {
4570
5127
  const file = join7(base, "acp-omp.json");
5128
+ const allowPrompts = base.startsWith(home);
4571
5129
  try {
4572
5130
  const raw = await fs.readFile(file, "utf8");
4573
5131
  const parsed = JSON.parse(raw);
4574
5132
  if (parsed && typeof parsed === "object") {
4575
- Object.assign(merged, pickKnown(parsed));
5133
+ Object.assign(merged, pickKnown(parsed, allowPrompts));
4576
5134
  debug.event("config-loaded", { file });
4577
5135
  }
4578
5136
  } catch (e) {
@@ -4600,11 +5158,15 @@ var KNOWN = /* @__PURE__ */ new Set([
4600
5158
  "prompts",
4601
5159
  "acknowledgePromptsRisk"
4602
5160
  ]);
4603
- function pickKnown(parsed) {
5161
+ function pickKnown(parsed, allowPrompts) {
4604
5162
  const out = {};
4605
5163
  for (const [k, v] of Object.entries(parsed)) {
4606
5164
  if (KNOWN.has(k)) out[k] = v;
4607
5165
  }
5166
+ if (!allowPrompts) {
5167
+ delete out.prompts;
5168
+ delete out.acknowledgePromptsRisk;
5169
+ }
4608
5170
  return out;
4609
5171
  }
4610
5172
  function applyUserConfig(adapter, user) {
@@ -4649,11 +5211,13 @@ function wireCompactionDisable(pi, runtime) {
4649
5211
  const prep = event.preparation;
4650
5212
  const toSummarize = [...prep.messagesToSummarize ?? [], ...prep.turnPrefixMessages ?? []];
4651
5213
  if (toSummarize.length === 0) return void 0;
5214
+ const slot = await runtime.stateFor(ctx);
4652
5215
  ctx.ui?.notify?.(`ACP: compacting ${toSummarize.length} messages\u2026`, "info");
4653
5216
  const result = await summarizeMessages(ctx, toSummarize, runtime.prompts, runtime.adapter.compress?.compressModel, {
4654
5217
  previousSummary: prep.previousSummary,
4655
5218
  customInstructions: event.customInstructions,
4656
- signal: event.signal
5219
+ signal: event.signal,
5220
+ messageRefs: slot.state.messageRefs
4657
5221
  });
4658
5222
  if (!result) {
4659
5223
  ctx.ui?.notify?.("ACP: compression fell back to Pi native compaction", "warning");
@@ -4684,7 +5248,7 @@ function wireCompactionDisable(pi, runtime) {
4684
5248
  function wireSessionLifecycle(pi, runtime) {
4685
5249
  pi.on("session_start", async (_event, ctx) => {
4686
5250
  const sid = ctx.sessionManager.getSessionId();
4687
- logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.7" : null });
5251
+ logInfo("session", { event: "start", sid, cwd: ctx.cwd, debug: runtime.adapter.debug ?? null, version: true ? "0.1.9" : null });
4688
5252
  try {
4689
5253
  const user = await loadUserConfig(ctx.cwd);
4690
5254
  runtime.setAdapter(applyUserConfig(runtime.adapter, user));
@@ -4723,6 +5287,9 @@ function wireContextTransform(pi, runtime) {
4723
5287
  }
4724
5288
  debug.event("context-in-raw", { sid, msgs: input.length });
4725
5289
  const { state, coreMessages, originalById, streamLen } = runtime.foldStream(ctx, input);
5290
+ const preTurnNudgeBaseline = state.nudge.lastPerMessageNudgeTokens;
5291
+ const preTurnNudgeShownTokens = state.nudge.lastNudgeShownTokens;
5292
+ const preTurnNudgeShownByTier = state.nudge.lastShownByTier;
4726
5293
  const config = runtime.configFor(ctx);
4727
5294
  const coveredIds = collectCoveredMessageIds(state);
4728
5295
  const systemPromptTokens = estimateTextTokens2(getSystemPromptText(ctx) ?? "");
@@ -4778,29 +5345,49 @@ function wireContextTransform(pi, runtime) {
4778
5345
  rebuiltMsgs: rebuilt.length
4779
5346
  });
4780
5347
  const debugOn2 = debug.enabled;
5348
+ let nudgeInjected = false;
4781
5349
  if (turn.nudge?.shouldInject) {
4782
- const emergency = turn.nudge.breakdown?.emergencyOverride === 1;
4783
- {
4784
- turn.nudge.compressibleRanges = viableRanges(turn.nudge.compressibleRanges);
4785
- const rendered = renderNudgeText(turn.nudge, runtime.prompts);
4786
- const top = [...turn.nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0];
4787
- const example = top ? `
5350
+ const lastUser = [...input].reverse().find((m) => m.role === "user");
5351
+ const tailText = lastUser ? JSON.stringify(lastUser.content ?? "") : "";
5352
+ const isFeedbackView = tailText.includes("efficiency nudge to compress early") || tailText.includes("Context limit reached");
5353
+ if (isFeedbackView) {
5354
+ debug.event("nudge-feedback-skip", { sid: ctx.sessionManager.getSessionId(), msgs: input.length });
5355
+ } else {
5356
+ const emergency = turn.nudge.breakdown?.emergencyOverride === 1;
5357
+ const epochReset = turn.state.nudge.lastPerMessageNudgeTokens !== preTurnNudgeBaseline;
5358
+ const prevShown = epochReset ? 0 : preTurnNudgeShownTokens;
5359
+ const cadenceFloor = turn.nudge.breakdown?.growthFloor ?? 0;
5360
+ const suppressed = !emergency && prevShown > 0 && tokenCount - prevShown < cadenceFloor;
5361
+ if (suppressed) {
5362
+ turn.state.nudge.lastNudgeShownTokens = prevShown;
5363
+ turn.state.nudge.lastShownByTier = preTurnNudgeShownByTier;
5364
+ logInfo("nudge", { sid, event: "cadence-suppressed", growth: tokenCount - prevShown, floor: cadenceFloor, pct: Math.round(turn.nudge.contextUsage * 100), reason: turn.nudge.reason });
5365
+ debug.event("nudge-suppressed", { sid, growth: tokenCount - prevShown, floor: cadenceFloor, pct: Math.round(turn.nudge.contextUsage * 100), reason: turn.nudge.reason });
5366
+ } else {
5367
+ nudgeInjected = true;
5368
+ {
5369
+ turn.nudge.compressibleRanges = viableRanges(turn.nudge.compressibleRanges);
5370
+ const rendered = renderNudgeText(turn.nudge, runtime.prompts);
5371
+ const top = [...turn.nudge.compressibleRanges].sort((a, b) => b.tokens - a.tokens)[0];
5372
+ const example = top ? `
4788
5373
 
4789
5374
  Example: compress({ content: [{ startId: "${top.startRef}", endId: "${top.endRef}", summary: "..." }] })` : "";
4790
- rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts, example));
4791
- if (emergency) {
4792
- logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "emergency-inject", pct: Math.round(turn.nudge.contextUsage * 100), voice: rendered.voice, compressible: turn.nudge.compressibleRanges.length });
4793
- }
4794
- if (debugOn2 && ctx.hasUI) {
4795
- ctx.ui.notify(`[ACP nudge \u2192 context]${emergency ? " [EMERGENCY]" : ""}
5375
+ rebuilt.push(nudgeMessage(turn.nudge, turn.state.blocks.filter((b) => b.active), runtime.prompts, example));
5376
+ if (emergency) {
5377
+ logWarn("nudge", { sid: ctx.sessionManager.getSessionId(), event: "emergency-inject", pct: Math.round(turn.nudge.contextUsage * 100), voice: rendered.voice, compressible: turn.nudge.compressibleRanges.length });
5378
+ }
5379
+ if (debugOn2 && ctx.hasUI) {
5380
+ ctx.ui.notify(`[ACP nudge \u2192 context]${emergency ? " [EMERGENCY]" : ""}
4796
5381
  ${rendered.text}${example}`);
5382
+ }
5383
+ debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn2 ? "terminal" : null].filter(Boolean), emergency, text: rendered.text + example });
5384
+ }
4797
5385
  }
4798
- debug.event("nudge-injected", { sid: ctx.sessionManager.getSessionId(), voice: rendered.voice, channels: ["context", debugOn2 ? "terminal" : null].filter(Boolean), emergency, text: rendered.text + example });
4799
5386
  }
4800
5387
  }
4801
5388
  dumpContextMessages(rebuilt, {
4802
5389
  sid,
4803
- injected: turn.nudge?.shouldInject ?? false,
5390
+ injected: nudgeInjected,
4804
5391
  emergency: turn.nudge?.breakdown?.emergencyOverride === 1
4805
5392
  });
4806
5393
  await checkForUpdate(runtime.adapter.autoUpdate ?? true, (msg) => {