billion-context-dsh 0.1.5 → 0.1.7

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/README.en.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [English](./README.en.md) | [中文](./README.md)
4
4
 
5
5
  > **⚠️ Beta notice — not for production use**
6
- > This project (**v0.1.5**) is a work-in-progress beta. The [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) itself is also in **public beta**. **Do not use either in engineering / production environments** — expect breaking changes and rough edges.
6
+ > This project (**v0.1.7**) is a work-in-progress beta. The [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) itself is also in **public beta**. **Do not use either in engineering / production environments** — expect breaking changes and rough edges.
7
7
 
8
8
  <p align="center">
9
9
  <strong>Built with gratitude on top of these projects</strong> — please give them a ⭐:
@@ -73,7 +73,7 @@ That's it. Then add a composition row where a compaction backend is expected —
73
73
  - id: compaction-acp
74
74
  name: 'billion-context-dsh'
75
75
  config:
76
- modelContextLimit: 128000 # default; the pressure window
76
+ modelContextLimit: 128000 # optional; omit to auto-detect the model's real window (fallback 128000)
77
77
  ```
78
78
 
79
79
  **Per-mode — an agent preset's `compaction` realm.** First *disable (or delete) the realm's existing `dsh-compaction-basic` row*, then mount this engine — two backends cannot coexist in the same realm:
@@ -87,7 +87,7 @@ That's it. Then add a composition row where a compaction backend is expected —
87
87
  - id: compaction-acp
88
88
  name: 'billion-context-dsh'
89
89
  config:
90
- modelContextLimit: 128000 # default; the pressure window
90
+ modelContextLimit: 128000 # optional; omit to auto-detect the model's real window (fallback 128000)
91
91
  ```
92
92
 
93
93
  > **One context manager per agent.** Two backends providing `ctx.compaction` collide — never run both in the same realm. Full install & verification guide: [docs/INSTALL.md](docs/INSTALL.md).
@@ -142,10 +142,11 @@ This project reuses `acp-kernel`'s compression core and `billion-context-pi`'s d
142
142
 
143
143
  | Key | Default | Meaning |
144
144
  |---|---|---|
145
- | `modelContextLimit` | `128000` | Context window used for the kernel's pressure decisions |
146
- | `nudgeMinContextLimitPct` | kernel default `0.45` | Nudge window lower bound (usage fraction) same default as billion-context-pi |
147
- | `nudgeMaxContextLimitPct` | kernel default `0.75` | Over-limit line: above this the nudge fires regardless of growth |
148
- | `nudgeEmergencyThresholdPct` | kernel default `0.95` | Emergency nudge (bypasses the per-turn dedup) |
145
+ | `modelContextLimit` | auto-detected (fallback `128000`) | Context window used for the kernel's pressure decisions; an explicit value wins and skips the probe |
146
+ | `autoModelContextLimit` | `true` | Probe the model's real window from the model API (`agent.ctx.llm.resolveModelInfo`); fall back to the default on failure, `acp_status` shows the window source |
147
+ | `nudgeMinContextLimitPct` | kernel default `0.45` | Nudge window lower bound (usage fraction) validation only; the growth-driven trigger has no percentage floor — same default as billion-context-pi |
148
+ | `nudgeMaxContextLimitPct` | engine default `0.70` (kernel/pi default `0.75`) | Over-limit line: above this the nudge fires regardless of growth — deliberately below the host compaction-basic 80% auto-compaction line so the forced nudge fires first; an explicit value wins |
149
+ | `nudgeEmergencyThresholdPct` | engine default `0.85` (kernel/pi default `0.95`) | Emergency nudge (bypasses the per-turn dedup) — lowered from `0.95`: at 95% the model has no room to act and the 80% auto-compaction line shadows it; an explicit value wins |
149
150
  | `coreOverrides` | — | Any other acp-kernel `Config` override (billion-context-pi's `coreOverrides` escape hatch) |
150
151
  | `autoTools` | `true` | Register the four model tools on `ctx.tools` |
151
152
  | `autoCommand` | `true` | Register the `/acp` command on `ctx.commands` |
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [中文](./README.md) | [English](./README.en.md)
4
4
 
5
5
  > **⚠️ 测试版声明——请勿用于生产环境**
6
- > 本项目(**v0.1.5**)仍处于开发中的测试版。[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 本身也处于**公开测试版**阶段。**请勿将两者用于工程化 / 生产环境**——预期会有破坏性变更与粗糙之处。
6
+ > 本项目(**v0.1.7**)仍处于开发中的测试版。[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 本身也处于**公开测试版**阶段。**请勿将两者用于工程化 / 生产环境**——预期会有破坏性变更与粗糙之处。
7
7
 
8
8
  <p align="center">
9
9
  <strong>衷心感谢以下项目——请给它们一个 ⭐:</strong>
@@ -75,7 +75,7 @@ npm install billion-context-dsh
75
75
  - id: compaction-acp
76
76
  name: 'billion-context-dsh'
77
77
  config:
78
- modelContextLimit: 128000 # 默认;压力窗口
78
+ modelContextLimit: 128000 # 可选;省略时自动探测模型真实窗口(回退 128000)
79
79
  ```
80
80
 
81
81
  **单模式生效(agent preset 的 `compaction` realm)**。先在该 realm 内*禁用(或删除)原有的 `dsh-compaction-basic` 行*,再插入本引擎——同一 realm 内两个后端不能并存:
@@ -89,7 +89,7 @@ npm install billion-context-dsh
89
89
  - id: compaction-acp
90
90
  name: 'billion-context-dsh'
91
91
  config:
92
- modelContextLimit: 128000 # 默认;压力窗口
92
+ modelContextLimit: 128000 # 可选;省略时自动探测模型真实窗口(回退 128000)
93
93
  ```
94
94
 
95
95
  > **每个 agent 只留一个上下文管理器。** 两个后端同时 provide `ctx.compaction` 会冲突——同一 realm 内切勿并存。完整安装与验证指南见 [docs/INSTALL.md](docs/INSTALL.md)。
@@ -144,10 +144,11 @@ DSH 的每个模型请求都派生自其 append-only 会话日志(*surface*)
144
144
 
145
145
  | 键 | 默认值 | 含义 |
146
146
  |---|---|---|
147
- | `modelContextLimit` | `128000` | 用于内核压力决策的上下文窗口 |
148
- | `nudgeMinContextLimitPct` | 内核默认 `0.45` | Nudge 窗口下界(用量占比)——与 billion-context-pi 相同的默认值 |
149
- | `nudgeMaxContextLimitPct` | 内核默认 `0.75` | 过限线:超过此值则无论增长与否都触发 nudge |
150
- | `nudgeEmergencyThresholdPct` | 内核默认 `0.95` | 紧急 nudge(绕过每轮去重) |
147
+ | `modelContextLimit` | 自动探测(回退 `128000`) | 用于内核压力决策的上下文窗口;显式配置时优先且跳过探测 |
148
+ | `autoModelContextLimit` | `true` | 从模型 API 自动探测真实窗口(`agent.ctx.llm.resolveModelInfo`);探测失败回退默认值,`acp_status` 展示窗口来源 |
149
+ | `nudgeMinContextLimitPct` | 内核默认 `0.45` | Nudge 窗口下界(用量占比)——仅作配置校验,增长路径的触发没有百分比下限——与 billion-context-pi 相同的默认值 |
150
+ | `nudgeMaxContextLimitPct` | engine 默认 `0.70`(内核/pi 默认 `0.75`) | 过限线:超过此值则无论增长与否都触发 nudge——刻意低于宿主 compaction-basic 的 80% 自动压缩线,保证强制 nudge 先触发;显式配置优先 |
151
+ | `nudgeEmergencyThresholdPct` | engine 默认 `0.85`(内核/pi 默认 `0.95`) | 紧急 nudge(绕过每轮去重)——从 `0.95` 下调:95% 时模型已无操作空间且会被 80% 自动压缩线遮蔽;显式配置优先 |
151
152
  | `coreOverrides` | — | 任何其他 acp-kernel `Config` 覆盖(billion-context-pi 的 `coreOverrides` 逃生口) |
152
153
  | `autoTools` | `true` | 在 `ctx.tools` 注册四个模型工具 |
153
154
  | `autoCommand` | `true` | 在 `ctx.commands` 注册 `/acp` 命令 |
package/dist/config.d.ts CHANGED
@@ -7,15 +7,19 @@
7
7
  * growth ratio 5%, protected last messages 5). Every knob is optional — an
8
8
  * omitted value keeps the kernel default, so the behavior matches the Pi
9
9
  * adapter exactly unless a deployment opts out.
10
+ *
11
+ * NOTE: `AcpCompactionEngine` (src/index.ts) ships its own engine-level
12
+ * defaults 0.70/0.85 for the two nudge thresholds on top of this layer, so an
13
+ * engine with no explicit config lands on 0.70/0.85, not 0.75/0.95.
10
14
  * @module billion-context-dsh/config
11
15
  */
12
16
  import { type Config } from 'acp-kernel';
13
17
  /** The kernel-facing knobs shared by the nudge path and the compress tool. */
14
18
  export interface KernelConfigInput {
15
19
  readonly modelContextLimit: number;
16
- /** Nudge window lower bound (usage fraction). Kernel default: 0.45. */
20
+ /** Nudge window lower bound (usage fraction; validation only — the growth-driven trigger has no percentage floor). Kernel default: 0.45. */
17
21
  readonly nudgeMinContextLimitPct?: number;
18
- /** Nudge window upper bound — over-limit. Kernel default: 0.75. */
22
+ /** Nudge window upper bound — over-limit guarantee line. Kernel default: 0.75. */
19
23
  readonly nudgeMaxContextLimitPct?: number;
20
24
  /** Emergency nudge threshold (bypasses per-turn dedup). Kernel default: 0.95. */
21
25
  readonly nudgeEmergencyThresholdPct?: number;
package/dist/index.d.ts CHANGED
@@ -28,23 +28,44 @@
28
28
  import type { Context } from '@deepseek-ai/cordis';
29
29
  import { CompactionEngine, type CompactionAgentContext, type CompactionResult, type CompactionTrigger, type ManualCompactAgentContext } from '@deepseek-ai/dsh-compaction';
30
30
  import { type CompressionCore } from 'acp-kernel';
31
+ import type { Agent } from '@deepseek-ai/dsh-agent';
31
32
  import { AcpStateStore } from './state.ts';
33
+ import { type AcpWindow } from './window.ts';
32
34
  export { AcpStateStore } from './state.ts';
33
35
  export { kernelConfigFor, type KernelConfigInput } from './config.ts';
34
36
  export { ACP_SYSTEM_PROMPT, ACP_SYSTEM_PROMPT_ORDER } from './system-prompt.ts';
35
37
  export { makeTools, type ToolEnvironment } from './tools.ts';
36
38
  export { acpCommand } from './commands.ts';
37
39
  export { buildNudge, type NudgeEnvironment, type NudgeOutcome } from './nudge.ts';
38
- export { rebuildBlockLedger, resolveSurfaceRange, runCompactionTransaction, shadowedSeqsOf, findOpenTurn, assertNoActiveCompaction, blockRegistry, blockRefForSummarySeq, compactionIdsOfKernelBlocks, summarySeqOfKernelBlock, expandShadowedSeqs, type AcpBlockLedgerEntry, type CompactionTransactionInput, } from './region.ts';
40
+ export { DEFAULT_CONTEXT_WINDOW, detectContextWindow, windowSourceLabel, type AcpWindow, } from './window.ts';
41
+ export { AlreadyCompressedRangeError, rebuildBlockLedger, resolveSurfaceRange, runCompactionTransaction, shadowedSeqsOf, findOpenTurn, assertNoActiveCompaction, blockRegistry, blockRefForSummarySeq, compactionIdsOfKernelBlocks, summarySeqOfKernelBlock, expandShadowedSeqs, type AcpBlockLedgerEntry, type CompactionTransactionInput, type ResolvedSurfaceRange, } from './region.ts';
39
42
  export { eventsToCoreMessages, projectEvent, surfaceEventsOf, extractEventText } from './messages.ts';
40
43
  export interface AcpConfig {
41
- /** The context window used for pressure decisions. Default 128000. */
42
- readonly modelContextLimit: number;
43
- /** Nudge window lower bound (usage fraction). Kernel default 0.45 same as billion-context-pi. */
44
+ /**
45
+ * The context window used for pressure decisions, in tokens. When omitted,
46
+ * `autoModelContextLimit` (default true) probes the model's real window via
47
+ * `agent.ctx.llm.resolveModelInfo(provider, model)`; an explicit value
48
+ * always wins and disables the probe.
49
+ */
50
+ readonly modelContextLimit?: number;
51
+ /** Probe the model's real context window from the LLM runtime. Default true. */
52
+ readonly autoModelContextLimit: boolean;
53
+ /** Nudge window lower bound (usage fraction; validation only — the growth-driven trigger has no percentage floor). Kernel default 0.45 — same as billion-context-pi. */
44
54
  readonly nudgeMinContextLimitPct?: number;
45
- /** Nudge window upper bound (over-limit). Kernel default 0.75 — same as billion-context-pi. */
55
+ /**
56
+ * Nudge window upper bound — over-limit guarantee line: above this the
57
+ * kernel injects a nudge regardless of growth or cadence. Engine default
58
+ * 0.70 (deliberately BELOW the kernel/billion-context-pi default 0.75 and
59
+ * the host compaction-basic auto-compaction line 0.80, so the forced nudge
60
+ * always fires first); an explicit value wins.
61
+ */
46
62
  readonly nudgeMaxContextLimitPct?: number;
47
- /** Emergency nudge threshold (bypasses per-turn dedup). Kernel default 0.95 — same as billion-context-pi. */
63
+ /**
64
+ * Emergency nudge threshold (bypasses the per-turn dedup). Engine default
65
+ * 0.85 (down from the kernel/billion-context-pi default 0.95: 95% leaves
66
+ * the model no room to act before the API rejects, and the host's 80%
67
+ * compaction-basic line shadows it in standard/code/cordis modes).
68
+ */
48
69
  readonly nudgeEmergencyThresholdPct?: number;
49
70
  /** Any other acp-kernel Config override (billion-context-pi's `coreOverrides` escape hatch). */
50
71
  readonly coreOverrides?: Partial<import('acp-kernel').Config>;
@@ -69,7 +90,17 @@ export declare class AcpCompactionEngine extends CompactionEngine {
69
90
  /** Resolved engine configuration. */
70
91
  readonly config: AcpConfig;
71
92
  private readonly lastNudgeTurn;
93
+ /** Per provider/model route the resolved window (probe failures cached too). */
94
+ private readonly windowCache;
72
95
  constructor(ctx: Context, config?: Partial<AcpConfig>);
96
+ /**
97
+ * Resolve the effective context window for an agent. An explicitly
98
+ * configured `modelContextLimit` always wins (no probe). Otherwise probe the
99
+ * model's real window via `agent.ctx.llm.resolveModelInfo` (cached per
100
+ * provider/model route, probe failures cached too) and fall back to
101
+ * DEFAULT_CONTEXT_WINDOW when auto-detection is disabled or unavailable.
102
+ */
103
+ windowFor(agent: Agent): Promise<AcpWindow>;
73
104
  /** ACP is model-driven: automatic pressure policy never summarizes by itself. */
74
105
  compactIfNeeded(_agent: CompactionAgentContext, _trigger: CompactionTrigger, signal: AbortSignal): Promise<CompactionResult | null>;
75
106
  /** Explicit idle-session compaction: ACP leaves the decision to the model. */
package/dist/index.js CHANGED
@@ -163,14 +163,61 @@ function hasPlainRef(session, seq) {
163
163
  return false;
164
164
  }
165
165
  }
166
+ var AlreadyCompressedRangeError = class extends Error {
167
+ constructor(start, end, coveringBlockIds) {
168
+ super(
169
+ `billion-context-dsh: seq ${start}..${end} already compressed \u2014 no live content remains in that span`
170
+ );
171
+ this.start = start;
172
+ this.end = end;
173
+ this.coveringBlockIds = coveringBlockIds;
174
+ this.name = "AlreadyCompressedRangeError";
175
+ }
176
+ start;
177
+ end;
178
+ coveringBlockIds;
179
+ };
180
+ function recoverStaleRange(session, start, end) {
181
+ if (session.events[start] === void 0 || session.events[end] === void 0) {
182
+ const failedEdge = session.events[start] === void 0 ? start : end;
183
+ return { kind: "unresolvable", failedEdge };
184
+ }
185
+ const liveInside = session.surface.nodes.filter((seq) => seq >= start && seq <= end).sort((a, b) => a - b);
186
+ const plain = liveInside.filter((seq) => !isCheckpointNode(session.events[seq]));
187
+ if (plain.length === 0) {
188
+ const coveringBlockIds = rebuildBlockLedger(session.events).filter((entry) => entry.shadowedSeqs.some((seq) => seq >= start && seq <= end)).map((entry) => entry.blockId);
189
+ return { kind: "already-compressed", coveringBlockIds };
190
+ }
191
+ return { kind: "ok", start: plain[0], end: plain[plain.length - 1] };
192
+ }
166
193
  function resolveSurfaceRange(session, start, end) {
167
194
  const nodes = session.surface.nodes;
168
- const requestedStartIdx = nodes.indexOf(start);
169
- const requestedEndIdx = nodes.indexOf(end);
195
+ if (start > end) {
196
+ throw new Error(`billion-context-dsh: reversed range ${start}..${end}`);
197
+ }
198
+ let requestedStartIdx = nodes.indexOf(start);
199
+ let requestedEndIdx = nodes.indexOf(end);
200
+ let recovered = false;
170
201
  if (requestedStartIdx < 0 || requestedEndIdx < 0) {
171
- throw new Error(
172
- `billion-context-dsh: seq ${start}..${end} not in the current surface \u2014 surface seqs are sparse message nodes (only user/message, assistant/message, tool/result events); consult acp_status for the current surface range`
173
- );
202
+ const stale = recoverStaleRange(session, start, end);
203
+ if (stale.kind === "unresolvable") {
204
+ throw new Error(
205
+ `billion-context-dsh: seq ${start}..${end} not in the current surface \u2014 edge seq ${stale.failedEdge} is not in this session's log. Surface seqs are sparse message nodes (only user/message, assistant/message, tool/result events); consult acp_status for the current surface range`
206
+ );
207
+ }
208
+ if (stale.kind === "already-compressed") {
209
+ throw new AlreadyCompressedRangeError(start, end, stale.coveringBlockIds);
210
+ }
211
+ start = stale.start;
212
+ end = stale.end;
213
+ recovered = true;
214
+ requestedStartIdx = nodes.indexOf(start);
215
+ requestedEndIdx = nodes.indexOf(end);
216
+ if (requestedStartIdx < 0 || requestedEndIdx < 0) {
217
+ throw new Error(
218
+ `billion-context-dsh: seq ${start}..${end} not in the current surface \u2014 consult acp_status for the current surface range`
219
+ );
220
+ }
174
221
  }
175
222
  if (requestedStartIdx > requestedEndIdx) {
176
223
  throw new Error(`billion-context-dsh: reversed range ${start}..${end}`);
@@ -189,7 +236,12 @@ function resolveSurfaceRange(session, start, end) {
189
236
  endIdx -= 1;
190
237
  }
191
238
  if (startIdx <= endIdx && nodes[startIdx] <= nodes[endIdx]) {
192
- return { start: nodes[startIdx], end: nodes[endIdx] };
239
+ return recovered ? { start: nodes[startIdx], end: nodes[endIdx], recovered: true } : { start: nodes[startIdx], end: nodes[endIdx] };
240
+ }
241
+ if (recovered) {
242
+ throw new Error(
243
+ `billion-context-dsh: no tool-pairing-balanced live remainder around seq ${start}..${end} \u2014 narrow the range or consult acp_status for the current surface`
244
+ );
193
245
  }
194
246
  startIdx = requestedStartIdx;
195
247
  endIdx = requestedEndIdx;
@@ -528,6 +580,28 @@ function kernelConfigFor(input) {
528
580
  return defaultConfig(input.modelContextLimit, overrides);
529
581
  }
530
582
 
583
+ // src/window.ts
584
+ var DEFAULT_CONTEXT_WINDOW = 128e3;
585
+ function windowSourceLabel(window) {
586
+ if (window.source === "explicit") return "configured";
587
+ if (window.source === "auto") {
588
+ return `auto-detected from ${window.provider ?? "?"}/${window.model ?? "?"}`;
589
+ }
590
+ return "default (auto-detection unavailable)";
591
+ }
592
+ async function detectContextWindow(agent, provider, model) {
593
+ const llm = agent.ctx?.get?.("llm");
594
+ if (llm?.resolveModelInfo === void 0) return null;
595
+ try {
596
+ const info = await llm.resolveModelInfo(provider, model);
597
+ const window = info?.context?.contextWindow;
598
+ if (typeof window === "number" && Number.isInteger(window) && window > 0) return window;
599
+ return null;
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
604
+
531
605
  // src/tools.ts
532
606
  function textOutput() {
533
607
  return {
@@ -592,30 +666,51 @@ async function handleCompress(env, args, exec) {
592
666
  const turn = env.kernel.processTurn({ messages: coreMessages, state, config, tokenCount });
593
667
  env.store.set(session, turn.state);
594
668
  const byRaw = turn.state.messageRefs.byRaw;
595
- const ranges = args.content.map((range) => {
669
+ const ranges = [];
670
+ const alreadyCompressedNotes = [];
671
+ for (const range of args.content) {
596
672
  const startSeq = parseSeq(range.startSeq);
597
673
  const endSeq = parseSeq(range.endSeq);
598
- const { start, end } = resolveSurfaceRange(session, startSeq, endSeq);
599
- const startBlockRef = blockRefForSummarySeq(session, start);
600
- const endBlockRef = blockRefForSummarySeq(session, end);
601
- const startRef = startBlockRef ?? byRaw[String(start)];
602
- const endRef = endBlockRef ?? byRaw[String(end)];
674
+ let resolved;
675
+ try {
676
+ resolved = resolveSurfaceRange(session, startSeq, endSeq);
677
+ } catch (error) {
678
+ if (error instanceof AlreadyCompressedRangeError) {
679
+ const covering = error.coveringBlockIds;
680
+ const blockNote = covering.length === 0 ? "" : ` (block ${covering[0].slice(0, 8)}${covering.length > 1 ? ` +${covering.length - 1} more` : ""})`;
681
+ alreadyCompressedNotes.push(
682
+ ` seqs ${error.start}..${error.end} already compressed${blockNote} \u2014 nothing to reclaim; decompress to recover the originals`
683
+ );
684
+ continue;
685
+ }
686
+ throw error;
687
+ }
688
+ const startBlockRef = blockRefForSummarySeq(session, resolved.start);
689
+ const endBlockRef = blockRefForSummarySeq(session, resolved.end);
690
+ const startRef = startBlockRef ?? byRaw[String(resolved.start)];
691
+ const endRef = endBlockRef ?? byRaw[String(resolved.end)];
603
692
  if (startRef === void 0 || endRef === void 0) {
604
693
  throw new Error(
605
- `billion-context-dsh: seq ${start}..${end} has no assigned ref \u2014 the range must be on the current surface (run acp_status for the live seq list)`
694
+ `billion-context-dsh: seq ${resolved.start}..${resolved.end} has no assigned ref \u2014 the range must be on the current surface (run acp_status for the live seq list)`
606
695
  );
607
696
  }
608
- return {
697
+ ranges.push({
698
+ ...resolved,
609
699
  startSeq,
610
700
  endSeq,
611
- start,
612
- end,
613
701
  startRef,
614
702
  endRef,
615
703
  summary: range.summary,
616
704
  ...(range.topic ?? args.topic) === void 0 ? {} : { topic: range.topic ?? args.topic }
617
- };
618
- });
705
+ });
706
+ }
707
+ if (ranges.length === 0) {
708
+ const text = ["Compressed 0 block(s), ~0 tokens reclaimed.", ...alreadyCompressedNotes];
709
+ if (alreadyCompressedNotes.length > 0) {
710
+ text.push(" (all requested ranges were already compressed \u2014 decompress a block to recover its originals)");
711
+ }
712
+ return { text: text.join("\n") };
713
+ }
619
714
  const applied = env.kernel.applyCompression({
620
715
  ranges: ranges.map(({ startRef, endRef, summary, topic }) => ({ startRef, endRef, summary, topic })),
621
716
  messages: coreMessages,
@@ -648,9 +743,7 @@ async function handleCompress(env, args, exec) {
648
743
  }
649
744
  const lines = [];
650
745
  let skippedRanges = 0;
651
- for (let index = 0; index < ranges.length; index += 1) {
652
- const range = ranges[index];
653
- const original = args.content[index];
746
+ for (const range of ranges) {
654
747
  const key = `${range.startRef}::${range.endRef}`;
655
748
  const block = blockByRangeKey.get(key);
656
749
  if (block === void 0) {
@@ -672,7 +765,7 @@ async function handleCompress(env, args, exec) {
672
765
  start,
673
766
  end,
674
767
  shadowedSeqs: shadowed,
675
- summary: [{ type: "text", text: original.summary }],
768
+ summary: [{ type: "text", text: range.summary }],
676
769
  shadowedTokenCount: shadowedTokens,
677
770
  provider: agent.options.provider ?? "",
678
771
  model: agent.options.model ?? "",
@@ -687,13 +780,15 @@ async function handleCompress(env, args, exec) {
687
780
  });
688
781
  const adjusted = start !== range.startSeq || end !== range.endSeq;
689
782
  const tierLabel = tier === 1 ? "" : `, tier ${tier}`;
783
+ const note = range.recovered === true ? ` (seqs ${range.startSeq}..${range.endSeq} were already shadowed \u2014 compressed the live remainder ${start}..${end})` : adjusted ? ` (adjusted from ${range.startSeq}..${range.endSeq} to balanced edges)` : "";
690
784
  lines.push(
691
- ` block ${compactionId.slice(0, 8)}: seqs ${start}..${end}, ${shadowed.length} messages shadowed${tierLabel}` + (adjusted ? ` (adjusted from ${range.startSeq}..${range.endSeq} to balanced edges)` : "")
785
+ ` block ${compactionId.slice(0, 8)}: seqs ${start}..${end}, ${shadowed.length} messages shadowed${tierLabel}${note}`
692
786
  );
693
787
  }
694
788
  const summaryLine = `Compressed ${applied.result.blocksCreated} block(s), ~${applied.result.tokensCompressed} tokens reclaimed.`;
695
- const warningLines = [...freeWarnings.map((warning) => ` ${warning}`), ...lines];
696
- const footer = skippedRanges > 0 ? ` (${skippedRanges} range(s) skipped \u2014 see warnings above)` : "";
789
+ const totalSkipped = skippedRanges + alreadyCompressedNotes.length;
790
+ const warningLines = [...freeWarnings.map((warning) => ` ${warning}`), ...alreadyCompressedNotes, ...lines];
791
+ const footer = totalSkipped > 0 ? ` (${totalSkipped} range(s) skipped \u2014 see warnings above)` : "";
697
792
  return { text: `${summaryLine}
698
793
  ${[...warningLines, footer].filter((line) => line !== "").join("\n")}` };
699
794
  }
@@ -746,18 +841,21 @@ ${original}`.toLowerCase();
746
841
  };
747
842
  }
748
843
  var statusParameters = {};
749
- function handleStatus(env, _args, exec) {
750
- const session = requireAgent(exec).session;
844
+ async function handleStatus(env, _args, exec) {
845
+ const agent = requireAgent(exec);
846
+ const session = agent.session;
751
847
  const ledger = rebuildBlockLedger(session.events);
752
848
  const totalTokens = ledger.reduce((sum, block) => sum + block.shadowedTokenCount, 0);
753
849
  const coreMessages = eventsToCoreMessages(surfaceEventsOf(session));
754
850
  const estimated = coreMessages.reduce((sum, message) => sum + defaultCountTokens2(message.text ?? ""), 0);
755
- const limit = env.modelContextLimit;
851
+ const window = env.windowFor === void 0 ? { limit: env.modelContextLimit, source: "explicit" } : await env.windowFor(agent);
852
+ const limit = window.limit;
756
853
  const lines = [
757
854
  `ACP status \u2014 session ${session.id}`,
758
855
  ` blocks: ${ledger.length}`,
759
856
  ` tokens compressed: ${totalTokens}`,
760
857
  ` estimated context: ${estimated} / ${limit} (${Math.round(estimated / limit * 100)}%)`,
858
+ ` context window: ${limit} (${windowSourceLabel(window)})`,
761
859
  ` surface: ${surfaceSummary(session)}`
762
860
  ];
763
861
  for (const block of ledger.slice(0, 10)) {
@@ -769,7 +867,7 @@ function makeTools(env) {
769
867
  return [
770
868
  defineTool({
771
869
  name: "compress",
772
- description: "Replace older conversation ranges with dense summaries you write. Each message seq is a surface reference. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated ranges in one call (each content entry becomes its own block); keep ranges disjoint. Never compress content the current step is actively using.",
870
+ description: "Replace older conversation ranges with dense summaries you write. Each message seq is a surface reference. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated ranges in one call (each content entry becomes its own block); keep ranges disjoint. Never compress content the current step is actively using. Seq refs must come from the CURRENT surface (acp_status or the latest nudge): a span whose edges were shadowed by an earlier compress is auto-remapped to its still-live content, a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.",
773
871
  parameters: compressParameters,
774
872
  output: textOutput(),
775
873
  async execute(args, exec) {
@@ -800,7 +898,7 @@ function makeTools(env) {
800
898
  parameters: statusParameters,
801
899
  output: textOutput(),
802
900
  execute(args, exec) {
803
- return Promise.resolve(handleStatus(env, args, exec));
901
+ return handleStatus(env, args, exec);
804
902
  }
805
903
  })
806
904
  ];
@@ -808,18 +906,20 @@ function makeTools(env) {
808
906
 
809
907
  // src/commands.ts
810
908
  import { defaultCountTokens as defaultCountTokens3 } from "acp-kernel";
811
- function statusText(env, agent) {
909
+ async function statusText(env, agent) {
812
910
  const session = agent.session;
813
911
  const ledger = rebuildBlockLedger(session.events);
814
912
  const totalTokens = ledger.reduce((sum, block) => sum + block.shadowedTokenCount, 0);
815
913
  const coreMessages = eventsToCoreMessages(surfaceEventsOf(session));
816
914
  const estimated = coreMessages.reduce((sum, message) => sum + defaultCountTokens3(message.text ?? ""), 0);
817
- const limit = env.modelContextLimit;
915
+ const window = env.windowFor === void 0 ? { limit: env.modelContextLimit, source: "explicit" } : await env.windowFor(agent);
916
+ const limit = window.limit;
818
917
  const lines = [
819
918
  `ACP status \u2014 session ${session.id}`,
820
919
  ` blocks: ${ledger.length}`,
821
920
  ` tokens compressed: ${totalTokens}`,
822
- ` estimated context: ${estimated} / ${limit} (${Math.round(estimated / limit * 100)}%)`
921
+ ` estimated context: ${estimated} / ${limit} (${Math.round(estimated / limit * 100)}%)`,
922
+ ` context window: ${limit} (${windowSourceLabel(window)})`
823
923
  ];
824
924
  for (const block of ledger.slice(0, 10)) {
825
925
  const tier = block.tier > 1 ? ` [T${block.tier}]` : "";
@@ -877,7 +977,7 @@ function acpCommand(env) {
877
977
  handler: async (invocation) => {
878
978
  const raw = invocation.rawInput.trim();
879
979
  if (raw === "" || raw === "status") {
880
- return { kind: "success", text: statusText(env, invocation.agent) };
980
+ return { kind: "success", text: await statusText(env, invocation.agent) };
881
981
  }
882
982
  if (raw.startsWith("compress")) {
883
983
  return { kind: "success", text: compressText(env, invocation.agent, raw.slice("compress".length).trim().split(/\s+/)) };
@@ -904,7 +1004,8 @@ function rangeTable(session) {
904
1004
  `Surface: ${surfaceSummary(session)}`,
905
1005
  "Compressible ranges (suggestions only \u2014 compress any consumed span; refs are surface seqs):",
906
1006
  ...lines,
907
- "Compress with: compress({ content: [{ startSeq, endSeq, summary }] }) \u2014 content is an array: batch multiple unrelated segments in one call, each entry its own block. Keep ranges disjoint."
1007
+ "Compress with: compress({ content: [{ startSeq, endSeq, summary }] }) \u2014 content is an array: batch multiple unrelated segments in one call, each entry its own block. Keep ranges disjoint.",
1008
+ "Snapshot taken at nudge time: the seqs go stale once the surface moves (a later compress shadows them), so re-run acp_status for fresh refs before compressing."
908
1009
  ].join("\n");
909
1010
  }
910
1011
  function measuredTokenCount(agent, coreMessages) {
@@ -963,10 +1064,10 @@ YOU decide whether and when to compress context. Nothing forces you: the injecte
963
1064
  ${COMPRESS_PHILOSOPHY}
964
1065
 
965
1066
  Compression tools (refs are SURFACE SEQS, not ids):
966
- - compress: replace one or more seq ranges, each with your own dense summary. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated segments in one call (each entry becomes its own block): compress({ content: [{ startSeq: 1, endSeq: 5, summary: '...' }, { startSeq: 12, endSeq: 18, summary: '...' }] }). Keep ranges disjoint \u2014 overlapping entries in one batch are skipped. Edges are auto-balanced to tool-call/result boundaries; a trailing #callId fragment in a seq is ignored. Ranges must be on the current surface \u2014 stale seqs fail with guidance.
1067
+ - compress: replace one or more seq ranges, each with your own dense summary. Single range: compress({ content: [{ startSeq, endSeq, summary }] }). Batch multiple unrelated segments in one call (each entry becomes its own block): compress({ content: [{ startSeq: 1, endSeq: 5, summary: '...' }, { startSeq: 12, endSeq: 18, summary: '...' }] }). Keep ranges disjoint \u2014 overlapping entries in one batch are skipped. Edges are auto-balanced to tool-call/result boundaries; a trailing #callId fragment in a seq is ignored. Seq refs must be on the current surface: seqs from older nudges or earlier compresses go stale as the surface moves, so a stale span is auto-remapped to its still-live remainder (the result reports the adjusted span), a fully compressed span is reported as already compressed, and invented/other-session seqs fail with guidance.
967
1068
  - decompress: recover a compressed block's original content, read-only. decompress({ blockId }).
968
1069
  - search_context: find information inside compressed blocks BEFORE decompressing. search_context({ query }).
969
- - acp_status: current context usage and the live compressible-range list. Run it before compressing when in doubt.
1070
+ - acp_status: current context usage and the live compressible-range list. Run it right before compressing \u2014 the only seqs that never go stale are the ones you just read.
970
1071
 
971
1072
  Tiered compression: each compressed block appears on the surface as one summary node. Compressing that node again DISTILLS the block (tier 2): the parent summary folds into your new summary and the original messages are freed. Distilling a tier-2 block yields tier 3. Distill when a summary itself is consumed \u2014 decompress on the tier-2 block recovers the full originals.
972
1073
 
@@ -975,10 +1076,17 @@ var ACP_SYSTEM_PROMPT_ORDER = 150;
975
1076
 
976
1077
  // src/index.ts
977
1078
  var DEFAULT_CONFIG = {
978
- modelContextLimit: 128e3,
1079
+ autoModelContextLimit: true,
979
1080
  autoTools: true,
980
1081
  autoCommand: true,
981
- autoNudge: true
1082
+ autoNudge: true,
1083
+ // Nudge thresholds: engine defaults 0.70/0.85 — deliberately below the
1084
+ // kernel/billion-context-pi 0.75/0.95. 0.95 leaves no room to act before
1085
+ // the API rejects, and the host's compaction-basic line (thresholdRatio
1086
+ // 0.80) shadows it in standard/code/cordis modes; 0.70 keeps the forced
1087
+ // over-limit nudge ahead of that 80% line. Explicit values always win.
1088
+ nudgeMaxContextLimitPct: 0.7,
1089
+ nudgeEmergencyThresholdPct: 0.85
982
1090
  };
983
1091
  function resolveAcpConfig(config = {}) {
984
1092
  return { ...DEFAULT_CONFIG, ...config };
@@ -991,6 +1099,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
991
1099
  /** Resolved engine configuration. */
992
1100
  config;
993
1101
  lastNudgeTurn = /* @__PURE__ */ new Map();
1102
+ /** Per provider/model route the resolved window (probe failures cached too). */
1103
+ windowCache = /* @__PURE__ */ new Map();
994
1104
  constructor(ctx, config = {}) {
995
1105
  super(ctx);
996
1106
  this.config = resolveAcpConfig(config);
@@ -999,11 +1109,13 @@ var AcpCompactionEngine = class extends CompactionEngine {
999
1109
  const env = {
1000
1110
  kernel: this.kernel,
1001
1111
  store: this.store,
1002
- modelContextLimit: this.config.modelContextLimit,
1112
+ // Initial value before any probe; windowFor() replaces it per pre-step.
1113
+ modelContextLimit: this.config.modelContextLimit ?? DEFAULT_CONTEXT_WINDOW,
1003
1114
  nudgeMinContextLimitPct: this.config.nudgeMinContextLimitPct,
1004
1115
  nudgeMaxContextLimitPct: this.config.nudgeMaxContextLimitPct,
1005
1116
  nudgeEmergencyThresholdPct: this.config.nudgeEmergencyThresholdPct,
1006
- coreOverrides: this.config.coreOverrides
1117
+ coreOverrides: this.config.coreOverrides,
1118
+ windowFor: (agent) => this.windowFor(agent)
1007
1119
  };
1008
1120
  const tools = ctx.get("tools");
1009
1121
  if (tools !== void 0) {
@@ -1041,7 +1153,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
1041
1153
  ctx.on("agent/pre-step", async (payload, next) => {
1042
1154
  const decision = await next();
1043
1155
  if (decision.kind === "reject") return decision;
1044
- const outcome = buildNudge(payload.agent, env, this.lastNudgeTurn);
1156
+ const window = await this.windowFor(payload.agent);
1157
+ const outcome = buildNudge(payload.agent, { ...env, modelContextLimit: window.limit }, this.lastNudgeTurn);
1045
1158
  if (outcome === null) return decision;
1046
1159
  return { kind: "enter", messages: [...decision.messages, outcome.message] };
1047
1160
  });
@@ -1055,6 +1168,32 @@ var AcpCompactionEngine = class extends CompactionEngine {
1055
1168
  });
1056
1169
  }
1057
1170
  }
1171
+ /**
1172
+ * Resolve the effective context window for an agent. An explicitly
1173
+ * configured `modelContextLimit` always wins (no probe). Otherwise probe the
1174
+ * model's real window via `agent.ctx.llm.resolveModelInfo` (cached per
1175
+ * provider/model route, probe failures cached too) and fall back to
1176
+ * DEFAULT_CONTEXT_WINDOW when auto-detection is disabled or unavailable.
1177
+ */
1178
+ async windowFor(agent) {
1179
+ if (this.config.modelContextLimit !== void 0) {
1180
+ return { limit: this.config.modelContextLimit, source: "explicit" };
1181
+ }
1182
+ const provider = agent.options.provider ?? "";
1183
+ const model = agent.options.model ?? "";
1184
+ const key = `${provider}\0${model}`;
1185
+ const cached = this.windowCache.get(key);
1186
+ if (cached !== void 0) return cached;
1187
+ let window;
1188
+ if (!this.config.autoModelContextLimit) {
1189
+ window = { limit: DEFAULT_CONTEXT_WINDOW, source: "default", provider, model };
1190
+ } else {
1191
+ const detected = await detectContextWindow(agent, provider, model);
1192
+ window = detected === null ? { limit: DEFAULT_CONTEXT_WINDOW, source: "default", provider, model } : { limit: detected, source: "auto", provider, model };
1193
+ }
1194
+ this.windowCache.set(key, window);
1195
+ return window;
1196
+ }
1058
1197
  /** ACP is model-driven: automatic pressure policy never summarizes by itself. */
1059
1198
  async compactIfNeeded(_agent, _trigger, signal) {
1060
1199
  signal.throwIfAborted();
@@ -1084,6 +1223,8 @@ export {
1084
1223
  ACP_SYSTEM_PROMPT_ORDER,
1085
1224
  AcpCompactionEngine,
1086
1225
  AcpStateStore,
1226
+ AlreadyCompressedRangeError,
1227
+ DEFAULT_CONTEXT_WINDOW,
1087
1228
  acpCommand,
1088
1229
  assertNoActiveCompaction,
1089
1230
  blockRefForSummarySeq,
@@ -1091,6 +1232,7 @@ export {
1091
1232
  buildNudge,
1092
1233
  compactionIdsOfKernelBlocks,
1093
1234
  index_default as default,
1235
+ detectContextWindow,
1094
1236
  eventsToCoreMessages,
1095
1237
  expandShadowedSeqs,
1096
1238
  extractEventText,
@@ -1104,6 +1246,7 @@ export {
1104
1246
  runCompactionTransaction,
1105
1247
  shadowedSeqsOf,
1106
1248
  summarySeqOfKernelBlock,
1107
- surfaceEventsOf
1249
+ surfaceEventsOf,
1250
+ windowSourceLabel
1108
1251
  };
1109
1252
  //# sourceMappingURL=index.js.map