opencode-acp 1.13.1 → 1.13.5

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.md CHANGED
@@ -235,7 +235,7 @@ Each level overrides the previous, so project settings take priority over global
235
235
  // Enable debug logging to ~/.config/opencode/logs/acp/
236
236
  "debug": false,
237
237
  // Notification display: "off", "minimal", or "detailed"
238
- "pruneNotification": "detailed",
238
+ "pruneNotification": "off",
239
239
  // Notification type: "chat" (deprecated, falls back to toast) or "toast" (system toast)
240
240
  "pruneNotificationType": "toast",
241
241
  // Slash commands configuration
@@ -309,8 +309,9 @@ Each level overrides the previous, so project settings take priority over global
309
309
  // Controls how likely compression is after user messages
310
310
  // ("strong" = more likely, "soft" = less likely)
311
311
  "nudgeForce": "soft",
312
- // Tool names whose completed outputs are appended to the compression
313
- "protectedTools": [],
312
+ // Hard-excluded tool names. The root default is ["skill", "compress"]; an explicit
313
+ // array replaces the inherited policy. Use [] to compress all tool outputs.
314
+ "protectedTools": ["skill", "compress"],
314
315
  // Preserve text wrapped in <protect>...</protect> when compressed
315
316
  "protectTags": false,
316
317
  // Preserve your messages during compression.
@@ -399,7 +400,7 @@ By default, these tools are always protected from pruning:
399
400
 
400
401
  The `protectedTools` arrays in `commands` and `strategies` add to this default list.
401
402
 
402
- For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are **hard-excluded** from compression ranges (v1.10.0+). When the model compresses a range that includes a protected tool message, that message survives intact in visible context — only the surrounding non-protected messages are compressed. By default `compress.protectedTools` includes only `skill` — this is sufficient in practice, as skill outputs are the one tool type whose content must never be lost to compression.
403
+ For the `compress` tool, `compress.protectedTools` ensures specific tool outputs are **hard-excluded** from compression ranges (v1.10.0+). When the model compresses a range that includes a protected tool message, that message survives intact in visible context — only the surrounding non-protected messages are compressed. The root default is `["skill", "compress"]` (the `compress` entry protects compress tool calls which carry summaries from being eaten by subsequent sequential compressions); an explicit array replaces the inherited policy. Use `[]` to allow all completed tool outputs to compress.
403
404
 
404
405
  ---
405
406
 
@@ -471,6 +472,36 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
471
472
 
472
473
  ## Changelog
473
474
 
475
+ ### v1.13.5 — Fix Release CI for Squash Merges (PR #187)
476
+
477
+ **Problem**: The release detection regex in `.github/workflows/release.yml` only matched standard merge commits (`Merge pull request #N from .../YYYY-MM-DD_release-v...`), not squash merges. PRs #182 (v1.13.3) and #186 (v1.13.4) were squash-merged, so the release workflow silently skipped — no tag, no npm publish, no GitHub Release. npm was stuck at 1.13.2 while master had already moved to 1.13.4.
478
+
479
+ **Fix**: Added a second pattern to the detection logic: `^release: v[0-9]+\.[0-9]+\.[0-9]+` matches squash merge commit titles that start with the release PR title convention (`release: vVERSION ...`). Both standard and squash merges are now detected. Also bumps version to 1.13.5 to publish all accumulated changes (v1.13.3 quality gate + v1.13.4 compress protection + this CI fix).
480
+
481
+ Files: `.github/workflows/release.yml`, `package.json`, `README.md`, `README.zh-CN.md`. 843 tests pass (no source code changes).
482
+
483
+ ### v1.13.4 — Protect Compress Tool Calls from Being Compressed (PR #185)
484
+
485
+ **Problem**: Sequential compressions ate previous summaries. Each compress tool call (which carries the summary in its `summary` parameter) lives a few messages after the range it compressed. When the model issued a new compress whose range started right after the previous one's end, the previous compress call fell inside the new range and was pruned — destroying the accumulated summary chain. Evidence from `ses_07562b88`: 113 messages → 6 messages in one compress call because all previous compress call anchors (b5–b10) were inside the new range.
486
+
487
+ **Fix**: Added `"compress"` to `COMPRESS_DEFAULT_PROTECTED_TOOLS` in `lib/config.ts`. This makes `filterProtectedToolMessages` hard-exclude compress tool call messages from compression ranges (Bug 39 mechanism). The compress call survives intact in visible context; only surrounding non-protected messages are compressed. Also synced stale `["skill"]` defaults in `dcp.schema.json`, `README.md`, and `README.zh-CN.md` to `["skill", "compress"]`. Users can opt out with `compress.protectedTools: ["skill"]`.
488
+
489
+ Files: `lib/config.ts`, `dcp.schema.json`, `README.md`, `README.zh-CN.md`. Tests: `tests/protect-compress-calls.test.ts` (6 new tests). 843 pass.
490
+
491
+ ### v1.13.3 — Quality Gate Enforcement + E2E Test Framework + protectedTools Fix (PRs #173, #174, #175, #177, #179)
492
+
493
+ **Problem**: (1) Compressions with extremely low retention (<1%) or near-zero keyword recall passed silently, causing severe context loss. (2) No end-to-end test infrastructure existed to verify ACP compression through the real opencode→LLM pipeline. (3) `compress.protectedTools` merged with inherited defaults instead of replacing them — an explicit `[]` still protected the inherited set.
494
+
495
+ **Fix**: (1) PR #173 — New opt-in `qualityGate` config (`enabled: false` by default). Pre-commit evaluation via ROUGE-1 recall + L1 length floor. Rejected compressions return a structured error with recovery guidance (split range or write denser summary). `qualityGateRetryPending` flag tracks rejection state. (2) PR #174 — `scripts/e2e/` framework: fake LLM server (OpenAI-compatible SSE), scripted JSON scenarios, state verifier. 4 baseline scenarios. (3) PR #175 — 18 new proportional baseline adjustment tests. (4) PR #177 — `compress.protectedTools` now replaces inherited defaults; explicit `[]` protects nothing. (5) PR #179 — AGENTS.md §5.1.1.2: absolute prohibition on Agent merging PRs.
496
+
497
+ Files: `lib/compress/quality-gate/`, `lib/compress/{range,message}.ts`, `lib/config.ts`, `scripts/e2e/`, `tests/proportional-baseline.test.ts`, `tests/quality-gate-enforcement.test.ts`, `AGENTS.md`. Tests: 837 pass.
498
+
499
+ ### v1.13.2 — Preserve Last User Msg + Config Defaults Tuning (PR #169)
500
+
501
+ **Problem**: Two issues remained after v1.13.1's notification freeze fix. (1) When the model compressed a range that covered all visible user messages, the next API call had zero user-role messages — zhipuai-lb rejected this with the same HTTP 400 code 1214 (`isRetryable: false`), freezing the session. This was the second path to the same freeze that v1.13.1's empty-notification fix addressed. (2) The default `pruneNotification: "detailed"` fired a toast on every compress call (10–30 per session is typical), which was over-intrusive for a routine background operation. Additionally, `compress.maxSummaryLengthHard: 10000` rejected ~25% of information-dense useful summaries in real sessions.
502
+
503
+ **Fix**: (1) `lib/messages/prune.ts` — `filterCompressedRanges` rewritten as a two-pass filter: pass 1 computes survivors, pass 2 builds the result; if no user-role message would survive, the most recent pruned user message is restored to keep the API request shape valid. The restore is transform-time only — `byMessageId` still records the message as compressed. (2) `lib/config.ts` — default `pruneNotification` changed `"detailed"` → `"off"`; compression events still log to `~/.config/opencode/logs/acp/` via a new always-log path in `lib/ui/notification.ts` (lossless observability without UI noise). (3) `lib/config.ts` — default `compress.maxSummaryLengthHard` raised `10000` → `20000` (aligns with observed good-summary lengths). (4) `dcp.schema.json` — 4 stale defaults synced. Files: `lib/messages/prune.ts`, `lib/config.ts`, `lib/ui/notification.ts`, `dcp.schema.json`, `README.md`. Tests: 803 pass (5 new regression tests for the preserve-last-user fix).
504
+
474
505
  ### v1.13.1 — cc-alg Extraction + Compress Notification Freeze Fix (PRs #167, #168)
475
506
 
476
507
  **Problem (compress notification freeze, #167)**: After every successful `compress` tool call, ACP injected a user-role notification message with a single `ignored: true` text part. opencode strips `ignored` parts before sending to the LLM, leaving an empty user message. The provider (zhipuai-lb / glm-5.2) rejects this with HTTP 400 code 1214 (`"messages 参数非法"`), `isRetryable: false` — opencode does not retry, and the session freezes until external recovery. 113 total occurrences across active sessions (8 in a single 3,156-message session).
package/README.zh-CN.md CHANGED
@@ -278,8 +278,9 @@ ACP 使用自己的配置文件,按以下顺序搜索:
278
278
  // Controls how likely compression is after user messages
279
279
  // ("strong" = more likely, "soft" = less likely)
280
280
  "nudgeForce": "soft",
281
- // Tool names whose completed outputs are appended to the compression
282
- "protectedTools": [],
281
+ // Hard-excluded tool names. The root default is ["skill", "compress"]; an explicit
282
+ // array replaces the inherited policy. Use [] to compress all tool outputs.
283
+ "protectedTools": ["skill", "compress"],
283
284
  // Preserve text wrapped in <protect>...</protect> when compressed
284
285
  "protectTags": false,
285
286
  // Preserve your messages during compression.
@@ -367,7 +368,7 @@ ACP 暴露六个可编辑的 prompt:
367
368
 
368
369
  `commands` 和 `strategies` 中的 `protectedTools` 数组会添加到此默认列表。
369
370
 
370
- 对于 `compress` 工具,`compress.protectedTools` 确保特定工具的输出被**硬排除**在压缩范围之外(v1.10.0+)。当模型压缩包含受保护工具消息的范围时,该消息完整保留在可见上下文中 — 只有周围的非受保护消息被压缩。默认仅包含 `skill` —— 实践中这一个就够了,因为 skill 输出是唯一绝不能被压缩丢失的工具类型。
371
+ 对于 `compress` 工具,`compress.protectedTools` 确保特定工具的输出被**硬排除**在压缩范围之外(v1.10.0+)。当模型压缩包含受保护工具消息的范围时,该消息完整保留在可见上下文中 — 只有周围的非受保护消息被压缩。根默认值为 `["skill", "compress"]`(`compress` 条目保护携带 summary compress 工具调用,防止被后续顺序压缩吞噬);显式数组会替换继承的策略。使用 `[]` 可允许所有已完成工具的输出被压缩。
371
372
 
372
373
  ---
373
374
 
@@ -439,6 +440,36 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
439
440
 
440
441
  ## 更新日志
441
442
 
443
+ ### v1.13.5 — 修复 Release CI 对 Squash Merge 的检测(PR #187)
444
+
445
+ **问题**:`.github/workflows/release.yml` 的发布检测正则只认标准 merge commit(`Merge pull request #N from .../YYYY-MM-DD_release-v...`),不认 squash merge。PR #182(v1.13.3)和 #186(v1.13.4)都是 squash 合并,导致 release workflow 静默跳过 — 没有 tag、没有 npm 发布、没有 GitHub Release。npm 卡在 1.13.2,而 master 已经到了 1.13.4。
446
+
447
+ **修复**:在检测逻辑中添加第二个模式:`^release: v[0-9]+\.[0-9]+\.[0-9]+` 匹配以 release PR 标题开头的 squash merge commit(`release: vVERSION ...`)。现在标准 merge 和 squash merge 都能被检测到。同时将版本号升到 1.13.5,以发布所有累积的变更(v1.13.3 质量门禁 + v1.13.4 compress 保护 + 本次 CI 修复)。
448
+
449
+ 文件:`.github/workflows/release.yml`、`package.json`、`README.md`、`README.zh-CN.md`。843 测试通过(无源码变更)。
450
+
451
+ ### v1.13.4 — 保护 compress 工具调用不被压缩(PR #185)
452
+
453
+ **问题**:顺序压缩会吞噬之前的 summary。每个 compress 工具调用(在其 `summary` 参数中携带摘要)位于其压缩范围之后几条消息处。当模型发出新的 compress,其范围紧接前一个范围的结尾开始时,前一个 compress 调用落入新范围内并被裁剪——摧毁累积的摘要链。`ses_07562b88` 的证据:113 条消息在一次 compress 调用中变为 6 条,因为所有之前的 compress 调用锚点(b5–b10)都在新范围内。
454
+
455
+ **修复**:在 `lib/config.ts` 的 `COMPRESS_DEFAULT_PROTECTED_TOOLS` 中添加 `"compress"`。这使得 `filterProtectedToolMessages` 硬排除 compress 工具调用消息不进入压缩范围(Bug 39 机制)。compress 调用完整保留在可见上下文中;只有周围的非受保护消息被压缩。同时将 `dcp.schema.json`、`README.md` 和 `README.zh-CN.md` 中过时的 `["skill"]` 默认值同步为 `["skill", "compress"]`。用户可通过 `compress.protectedTools: ["skill"]` 退出。
456
+
457
+ 文件:`lib/config.ts`、`dcp.schema.json`、`README.md`、`README.zh-CN.md`。测试:`tests/protect-compress-calls.test.ts`(6 个新测试)。843 pass。
458
+
459
+ ### v1.13.3 — 质量门禁 + E2E 测试框架 + protectedTools 修复(PR #173, #174, #175, #177, #179)
460
+
461
+ **问题**:(1)极低保留率(<1%)或接近零关键词召回的压缩会静默通过,导致严重的上下文丢失。(2)缺少端到端测试基础设施来验证 ACP 通过真实 opencode→LLM 管道的压缩行为。(3)`compress.protectedTools` 与继承的默认值合并而非替换——显式 `[]` 仍会保护继承的集合。
462
+
463
+ **修复**:(1)PR #173——新增可选 `qualityGate` 配置(默认 `enabled: false`)。提交前通过 ROUGE-1 召回率 + L1 长度下限评估。被拒绝的压缩返回结构化错误并附带恢复指引(拆分范围或写更密的摘要)。`qualityGateRetryPending` 标志跟踪拒绝状态。(2)PR #174——`scripts/e2e/` 框架:fake LLM 服务器(OpenAI 兼容 SSE)、脚本化 JSON 场景、状态验证器。4 个基线场景。(3)PR #175——18 个新的比例基线调整测试。(4)PR #177——`compress.protectedTools` 现在替换继承的默认值;显式 `[]` 不保护任何工具。(5)PR #179——AGENTS.md §5.1.1.2:绝对禁止 Agent 合并 PR。
464
+
465
+ 文件:`lib/compress/quality-gate/`、`lib/compress/{range,message}.ts`、`lib/config.ts`、`scripts/e2e/`、`tests/proportional-baseline.test.ts`、`tests/quality-gate-enforcement.test.ts`、`AGENTS.md`。测试:837 pass。
466
+
467
+ ### v1.13.2 — 保留最近用户消息 + 配置默认值调优(PR #169)
468
+
469
+ **问题**:v1.13.1 的通知冻结修复之后还剩两个问题。(1)当模型压缩的范围覆盖了所有可见的 user 消息时,下一次 API 调用中 user 角色消息数量为零——zhipuai-lb 以同样的 HTTP 400 code 1214(`isRetryable: false`)拒绝,会话冻结。这是 v1.13.1 修复的空通知路径之外,通往同一冻结 bug 的第二条路径。(2)默认 `pruneNotification: "detailed"` 每次压缩都弹 toast(典型会话 10–30 次),对例行后台操作来说过于打扰。另外 `compress.maxSummaryLengthHard: 10000` 在真实会话中拒绝了约 25% 信息密度高的有用摘要。
470
+
471
+ **修复**:(1)`lib/messages/prune.ts`——`filterCompressedRanges` 重写为两段过滤:第一段计算存活消息,第二段构建结果;如果没有 user 角色消息存活,恢复最近一条被压缩的 user 消息以保证 API 请求格式合法。恢复仅发生在 transform 阶段——`byMessageId` 仍记录该消息为已压缩。(2)`lib/config.ts`——默认 `pruneNotification` 改为 `"off"`;压缩事件仍通过 `lib/ui/notification.ts` 新增的 always-log 路径记录到 `~/.config/opencode/logs/acp/`(无损失可观测性,无 UI 噪音)。(3)`lib/config.ts`——默认 `compress.maxSummaryLengthHard` 从 `10000` 提升到 `20000`(与真实会话中观察到的优质摘要长度对齐)。(4)`dcp.schema.json`——同步 4 个过时默认值。文件:`lib/messages/prune.ts`、`lib/config.ts`、`lib/ui/notification.ts`、`dcp.schema.json`、`README.md`。测试:803 pass(5 个新的 preserve-last-user 回归测试)。
472
+
442
473
  ### v1.13.1 — cc-alg 抽取 + 压缩通知冻结修复(PR #167, #168)
443
474
 
444
475
  **问题(压缩通知冻结,#167)**:每次 `compress` 工具调用成功后,ACP 会注入一条 user 角色通知消息,其中只包含一个带 `ignored: true` 标记的 text part。opencode 在发送给 LLM 前会剥离 ignored parts,于是这条消息变成空 user 消息。Provider(zhipuai-lb / glm-5.2)会以 HTTP 400 code 1214(`"messages 参数非法"`)拒绝,且 `isRetryable: false`——opencode 不会重试,会话冻结,直到外部恢复。所有活跃会话累计发生 113 次(单个 3,156 条消息的会话出现 8 次)。
package/dist/index.js CHANGED
@@ -1516,7 +1516,7 @@ var DEFAULT_PROTECTED_TOOLS = [
1516
1516
  "write",
1517
1517
  "edit"
1518
1518
  ];
1519
- var COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill"];
1519
+ var COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "compress"];
1520
1520
  function showConfigWarnings(ctx, configPath, configData, isProject) {
1521
1521
  const invalidKeys = getInvalidConfigKeys(configData);
1522
1522
  const typeErrors = validateConfigTypes(configData);
@@ -1557,7 +1557,7 @@ var defaultConfig = {
1557
1557
  enabled: true,
1558
1558
  autoUpdate: true,
1559
1559
  debug: false,
1560
- pruneNotification: "detailed",
1560
+ pruneNotification: "off",
1561
1561
  // [FIX #20] Default to toast — chat-mode notifications inject an empty
1562
1562
  // user message that freezes the session on providers that reject empty
1563
1563
  // messages (zhipuai-lb code 1214). See lib/ui/notification.ts.
@@ -1593,7 +1593,7 @@ var defaultConfig = {
1593
1593
  protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
1594
1594
  protectTags: false,
1595
1595
  protectUserMessages: false,
1596
- maxSummaryLengthHard: 1e4,
1596
+ maxSummaryLengthHard: 2e4,
1597
1597
  minCompressRange: 5e3,
1598
1598
  minNudgeGrowthRatio: 0.45,
1599
1599
  minNudgeGrowthFloor: 5e3,
@@ -1632,7 +1632,7 @@ var defaultConfig = {
1632
1632
  algorithms: {
1633
1633
  "rouge-recall-v1": {
1634
1634
  layer1MinChars: 200,
1635
- layer1MinRetentionPct: 1,
1635
+ layer1MinRetentionPct: 5,
1636
1636
  layer2MaxRougeF1: 0.05,
1637
1637
  layer2MaxTop20Recall: 0.2
1638
1638
  }
@@ -1765,7 +1765,7 @@ function mergeCompress(base, override) {
1765
1765
  toolOutputNudgeThreshold: override.toolOutputNudgeThreshold,
1766
1766
  iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
1767
1767
  nudgeForce: override.nudgeForce ?? base.nudgeForce,
1768
- protectedTools: [.../* @__PURE__ */ new Set([...base.protectedTools, ...override.protectedTools ?? []])],
1768
+ protectedTools: Array.isArray(override.protectedTools) ? [...new Set(override.protectedTools)] : base.protectedTools,
1769
1769
  protectTags: override.protectTags ?? base.protectTags,
1770
1770
  protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
1771
1771
  maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard,
@@ -4445,7 +4445,8 @@ function createSessionState() {
4445
4445
  lastCompaction: 0,
4446
4446
  currentTurn: 0,
4447
4447
  modelContextLimit: void 0,
4448
- systemPromptTokens: void 0
4448
+ systemPromptTokens: void 0,
4449
+ qualityGateRetryPending: false
4449
4450
  };
4450
4451
  }
4451
4452
  function resetSessionState(state) {
@@ -4485,6 +4486,7 @@ function resetSessionState(state) {
4485
4486
  state.currentTurn = 0;
4486
4487
  state.modelContextLimit = void 0;
4487
4488
  state.systemPromptTokens = void 0;
4489
+ state.qualityGateRetryPending = false;
4488
4490
  }
4489
4491
  async function ensureSessionInitialized(client, state, sessionId, logger, messages, manualModeEnabled, config) {
4490
4492
  if (state.sessionId === sessionId) {
@@ -5078,10 +5080,25 @@ function formatContextTransition(tokensBefore, tokensAfter) {
5078
5080
  return `Context ${beforeStr} \u2192 ${afterStr}`;
5079
5081
  }
5080
5082
  async function sendCompressNotification(client, logger, config, state, sessionId, entries, batchTopic, sessionMessageIds, params, contextTokensBefore) {
5081
- if (config.pruneNotification === "off") {
5083
+ if (entries.length === 0) {
5082
5084
  return false;
5083
5085
  }
5084
- if (entries.length === 0) {
5086
+ const logBlockIds = entries.map((e) => e.blockId);
5087
+ const logTopics = entries.map((e) => state.prune.messages.blocksById.get(e.blockId)?.topic ?? "?");
5088
+ const logCompressedTokens = entries.reduce((sum, e) => {
5089
+ const block = state.prune.messages.blocksById.get(e.blockId);
5090
+ return sum + (block?.compressedTokens ?? 0);
5091
+ }, 0);
5092
+ const logSummaryTokens = entries.reduce((sum, e) => sum + e.summaryTokens, 0);
5093
+ logger.info("Compression completed", {
5094
+ sessionId,
5095
+ blockIds: logBlockIds,
5096
+ topics: logTopics,
5097
+ compressedTokens: logCompressedTokens,
5098
+ summaryTokens: logSummaryTokens,
5099
+ contextTokensBefore
5100
+ });
5101
+ if (config.pruneNotification === "off") {
5085
5102
  return false;
5086
5103
  }
5087
5104
  let message;
@@ -5959,6 +5976,164 @@ function evaluateBatchQuality(state, rawMessages, entries, config, logger) {
5959
5976
  failures
5960
5977
  };
5961
5978
  }
5979
+ function evaluatePreCommitQuality(rawMessages, messageIds, messageTokenById, summary, config, logger) {
5980
+ const qg = config.qualityGate;
5981
+ if (!qg || qg.enabled !== true) return null;
5982
+ ensureBuiltinGatesRegistered();
5983
+ const algoName = qg.algorithm;
5984
+ if (!algoName) {
5985
+ logger.warn("Quality gate enabled but no algorithm specified", {});
5986
+ return null;
5987
+ }
5988
+ const gate = getQualityGate(algoName);
5989
+ if (!gate) {
5990
+ logger.warn("Quality gate algorithm not found in registry", { algorithm: algoName });
5991
+ return null;
5992
+ }
5993
+ if (messageIds.length === 0) return null;
5994
+ const idToMsg = /* @__PURE__ */ new Map();
5995
+ for (const m of rawMessages) {
5996
+ const id = m?.info?.id;
5997
+ if (typeof id === "string") idToMsg.set(id, m);
5998
+ }
5999
+ const chunks = [];
6000
+ let compressedTokens = 0;
6001
+ for (const id of messageIds) {
6002
+ const m = idToMsg.get(id);
6003
+ if (m) chunks.push(extractMessageText(m.parts));
6004
+ compressedTokens += messageTokenById.get(id) || 0;
6005
+ }
6006
+ if (chunks.length === 0) return null;
6007
+ const originalText = chunks.join("\n");
6008
+ const pseudoBlock = {
6009
+ blockId: -1,
6010
+ summary,
6011
+ compressedTokens,
6012
+ directMessageIds: messageIds,
6013
+ effectiveMessageIds: messageIds
6014
+ };
6015
+ const ctx = {
6016
+ block: pseudoBlock,
6017
+ summary,
6018
+ originalChunks: chunks,
6019
+ originalText,
6020
+ originalTokens: Math.ceil(originalText.length / CHARS_PER_TOKEN_ESTIMATE)
6021
+ };
6022
+ const algoConfig = (qg.algorithms && qg.algorithms[algoName]) ?? {};
6023
+ try {
6024
+ return gate.evaluate(ctx, algoConfig);
6025
+ } catch (err) {
6026
+ logger.warn("Pre-commit quality gate threw \u2014 treating as pass", {
6027
+ gate: gate.name,
6028
+ error: err instanceof Error ? err.message : String(err)
6029
+ });
6030
+ return { passed: true, metrics: [] };
6031
+ }
6032
+ }
6033
+
6034
+ // node_modules/context-compress-algorithms/dist/chunk-ZRHPFN6B.js
6035
+ var COMPRESS_PHILOSOPHY = `Compression Philosophy:
6036
+ - All compression serves the primary task, but be frugal.
6037
+ - Context capacity is precious. Save context by compressing consumed outputs, not by avoiding tools.
6038
+ - Compress by need, not by percentage.
6039
+ - 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.
6040
+ - Curate summaries like a well-structured document. User prompts, compressed tool outputs, code, logs, or skill-call intermediate results that are critically important should be preserved \u2014 not by exempting them from compression, but by embedding them in the summary via [[KEEP:mNNNNN]] (auto-expanded verbatim) and [[REF:mNNNNN|description]] (compact link).`;
6041
+ var HOW_TO_COMPRESS_RULES = `HOW TO COMPRESS
6042
+
6043
+ 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.
6044
+
6045
+ KEEP VERBATIM \u2014 never paraphrase or abbreviate these:
6046
+ - 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.
6047
+ - 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").
6048
+ - Error messages and stack traces (exact text \u2014 you need the literal string to grep for it later).
6049
+ - 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").
6050
+ - Decisions and their rationale ("chose X over Y because Z" \u2014 the "because" is load-bearing; without it the decision looks arbitrary).
6051
+ - Constraints discovered ("must support Node 22", "no new dependencies", "AGENTS.md forbids \`as any\`").
6052
+ - Exact values: versions, config keys, thresholds, magic numbers.
6053
+ - 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.
6054
+ - 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.
6055
+ - 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.
6056
+ - Open questions and unresolved TODOs \u2014 losing these changes what work appears to remain.
6057
+ - Message refs of key anchors (\`m00420\`, \`m00510\u2013m00520\`) \u2014 they let you or a later reader jump back via decompress to the exact original.
6058
+
6059
+ DROP \u2014 extract the signal, discard the vessel:
6060
+ - Verbose logs (build/test/\`npm\` output) once you have captured the error line or the result.
6061
+ - Duplicate file reads once the needed content is recorded.
6062
+ - 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).
6063
+ - Dead-end exploration \u2014 but PRESERVE the lesson in one line: "tried X, failed because Y".
6064
+ - Back-and-forth discussion and self-corrections once the final position is captured (keep the outcome, drop the journey to it).
6065
+ - Repeated status checks (\`git status\`, \`ls\`) once state is known.
6066
+
6067
+ 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.
6068
+
6069
+ KEEP MARKERS: \`[[KEEP:mNNNNN]]\` expands original message content into the summary (truncated to a max length). Do NOT use KEEP for verbose command output, diagnostic scripts, log dumps, or any content whose value is in the conclusion rather than the raw output \u2014 summarize these or use \`[[REF:mNNNNN|desc]]\` instead.
6070
+
6071
+ PRIORITY \u2014 when the summary must be compact, preserve in this order:
6072
+ 1. User's overall goal, goal evolution, intent, and hard constraints (losing these changes the task).
6073
+ 2. Decisions and rationale.
6074
+ 3. Exact technical artifacts: paths, signatures, errors, values.
6075
+ 4. Conclusions and key findings.
6076
+ 5. Lessons learned: what failed and why.
6077
+
6078
+ 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.`;
6079
+
6080
+ // lib/compress/quality-gate/rejection.ts
6081
+ function formatMetric(result, name) {
6082
+ const m = result.metrics.find((x) => x.name === name);
6083
+ if (!m) return "?";
6084
+ switch (m.format) {
6085
+ case "percent":
6086
+ return `${m.value.toFixed(2)}%`;
6087
+ case "ratio":
6088
+ return m.value.toFixed(4);
6089
+ default:
6090
+ return String(m.value);
6091
+ }
6092
+ }
6093
+ function computeStats(plan) {
6094
+ let originalTokens = 0;
6095
+ for (const id of plan.messageIds) {
6096
+ originalTokens += plan.messageTokenById.get(id) || 0;
6097
+ }
6098
+ const summaryChars = plan.summary.length;
6099
+ const ratio = originalTokens > 0 ? (originalTokens / Math.max(summaryChars / 4, 1)).toFixed(1) : "?";
6100
+ const retentionPct = originalTokens > 0 ? (summaryChars / (originalTokens * 4) * 100).toFixed(2) : "?";
6101
+ return { originalTokens, summaryChars, ratio, retentionPct };
6102
+ }
6103
+ function buildQualityRejectionError(plan, result) {
6104
+ const stats = computeStats(plan);
6105
+ const metrics = [
6106
+ `Original: ~${stats.originalTokens} tokens`,
6107
+ `Summary: ${stats.summaryChars} chars`,
6108
+ `Ratio: ${stats.ratio}:1`,
6109
+ `Retention: ${stats.retentionPct}%`,
6110
+ `Gate layer: ${result.layer ?? "unknown"}`,
6111
+ `rougeF1: ${formatMetric(result, "rougeF1")}`,
6112
+ `top20Recall: ${formatMetric(result, "top20Recall")}`
6113
+ ];
6114
+ const message = `\u26A0\uFE0F COMPRESSION REJECTED \u2014 QUALITY GATE FAILURE
6115
+
6116
+ Range: ${plan.startId}\u2013${plan.endId}
6117
+ ${metrics.join("\n")}
6118
+
6119
+ \u26A0\uFE0F CRITICAL: Compression is the ONLY mechanism for preserving historical context in this session.
6120
+ Once a compression is accepted, the original messages are permanently removed from visible context.
6121
+ Your summary becomes the SOLE record. If it fails, subsequent work is built on a broken foundation \u2014
6122
+ memory loss \u2192 wrong assumptions \u2192 entire reasoning chain collapse.
6123
+ Treat every compression with maximum care.
6124
+
6125
+ ${HOW_TO_COMPRESS_RULES}
6126
+
6127
+ To retry: rewrite a more complete summary that preserves critical details (file paths, decisions,
6128
+ exact values, errors). Then add "acknowledgeRisk": true to the compress tool call parameters.
6129
+ Without acknowledgeRisk: true, the compression will be rejected again.`;
6130
+ return new Error(message);
6131
+ }
6132
+ function buildPreemptiveAcknowledgeError() {
6133
+ return new Error(
6134
+ 'Parameter "acknowledgeRisk": true was provided, but no quality gate rejection is pending. This parameter is only valid immediately after a compression was rejected by the quality gate. Remove it and try again.'
6135
+ );
6136
+ }
5962
6137
 
5963
6138
  // lib/compress/pipeline.ts
5964
6139
  function snapshotCompressionState(state) {
@@ -6227,7 +6402,8 @@ function buildSchema(maxSummaryLengthHard) {
6227
6402
  ),
6228
6403
  dangerous: tool2.schema.boolean().optional().describe(
6229
6404
  "Set to true ONLY when you are certain the most recent message(s) must be compressed. Required when a range includes the tail of the conversation."
6230
- )
6405
+ ),
6406
+ acknowledgeRisk: tool2.schema.boolean().optional()
6231
6407
  };
6232
6408
  }
6233
6409
  function createCompressMessageTool(ctx) {
@@ -6326,6 +6502,39 @@ function createCompressMessageTool(ctx) {
6326
6502
  }))
6327
6503
  );
6328
6504
  if (phantomError) throw phantomError;
6505
+ const acknowledgeRisk = args.acknowledgeRisk === true;
6506
+ const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
6507
+ if (acknowledgeRisk && !ctx.state.qualityGateRetryPending) {
6508
+ throw buildPreemptiveAcknowledgeError();
6509
+ }
6510
+ if (acknowledgeRisk) {
6511
+ ctx.state.qualityGateRetryPending = false;
6512
+ } else {
6513
+ ctx.state.qualityGateRetryPending = false;
6514
+ for (const { plan, summaryWithTools } of preparedPlans) {
6515
+ const result = evaluatePreCommitQuality(
6516
+ rawMessages,
6517
+ plan.selection.messageIds,
6518
+ plan.selection.messageTokenById,
6519
+ summaryWithTools,
6520
+ ctx.config,
6521
+ ctx.logger
6522
+ );
6523
+ if (result && !result.passed) {
6524
+ ctx.state.qualityGateRetryPending = true;
6525
+ throw buildQualityRejectionError(
6526
+ {
6527
+ startId: plan.entry.messageId,
6528
+ endId: plan.entry.messageId,
6529
+ summary: summaryWithTools,
6530
+ messageIds: plan.selection.messageIds,
6531
+ messageTokenById: plan.selection.messageTokenById
6532
+ },
6533
+ result
6534
+ );
6535
+ }
6536
+ }
6537
+ }
6329
6538
  const snapshot = snapshotCompressionState(ctx.state);
6330
6539
  const runId = allocateRunId(ctx.state);
6331
6540
  try {
@@ -6370,6 +6579,7 @@ function createCompressMessageTool(ctx) {
6370
6579
  await finalizeSession(ctx, toolCtx, rawMessages, notifications, input.topic);
6371
6580
  } catch (error) {
6372
6581
  restoreCompressionState(ctx.state, snapshot);
6582
+ ctx.state.qualityGateRetryPending = qualityGateRetryPendingBefore;
6373
6583
  throw error;
6374
6584
  }
6375
6585
  return formatResult(plans.length, skippedIssues, skippedCount);
@@ -6405,7 +6615,8 @@ function buildSchema2(maxSummaryLengthHard) {
6405
6615
  ),
6406
6616
  dangerous: tool3.schema.boolean().optional().describe(
6407
6617
  "Set to true ONLY when you are certain the most recent message(s) must be compressed. Required when a range includes the tail of the conversation."
6408
- )
6618
+ ),
6619
+ acknowledgeRisk: tool3.schema.boolean().optional()
6409
6620
  };
6410
6621
  }
6411
6622
  function createCompressRangeTool(ctx) {
@@ -6557,6 +6768,39 @@ function createCompressRangeTool(ctx) {
6557
6768
  }))
6558
6769
  );
6559
6770
  if (phantomError) throw phantomError;
6771
+ const acknowledgeRisk = args.acknowledgeRisk === true;
6772
+ const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending;
6773
+ if (acknowledgeRisk && !ctx.state.qualityGateRetryPending) {
6774
+ throw buildPreemptiveAcknowledgeError();
6775
+ }
6776
+ if (acknowledgeRisk) {
6777
+ ctx.state.qualityGateRetryPending = false;
6778
+ } else {
6779
+ ctx.state.qualityGateRetryPending = false;
6780
+ for (const plan of preparedPlans) {
6781
+ const result = evaluatePreCommitQuality(
6782
+ rawMessages,
6783
+ plan.selection.messageIds,
6784
+ plan.selection.messageTokenById,
6785
+ plan.finalSummary,
6786
+ ctx.config,
6787
+ ctx.logger
6788
+ );
6789
+ if (result && !result.passed) {
6790
+ ctx.state.qualityGateRetryPending = true;
6791
+ throw buildQualityRejectionError(
6792
+ {
6793
+ startId: plan.entry.startId,
6794
+ endId: plan.entry.endId,
6795
+ summary: plan.finalSummary,
6796
+ messageIds: plan.selection.messageIds,
6797
+ messageTokenById: plan.selection.messageTokenById
6798
+ },
6799
+ result
6800
+ );
6801
+ }
6802
+ }
6803
+ }
6560
6804
  const snapshot = snapshotCompressionState(ctx.state);
6561
6805
  const runId = allocateRunId(ctx.state);
6562
6806
  try {
@@ -6608,6 +6852,7 @@ function createCompressRangeTool(ctx) {
6608
6852
  );
6609
6853
  } catch (error) {
6610
6854
  restoreCompressionState(ctx.state, snapshot);
6855
+ ctx.state.qualityGateRetryPending = qualityGateRetryPendingBefore;
6611
6856
  throw error;
6612
6857
  }
6613
6858
  return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.
@@ -6669,15 +6914,29 @@ var filterCompressedRanges = (state, messages) => {
6669
6914
  if (state.prune.messages.byMessageId.size === 0) {
6670
6915
  return;
6671
6916
  }
6917
+ const survive = messages.map((msg) => {
6918
+ const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id);
6919
+ if (!pruneEntry || pruneEntry.activeBlockIds.length === 0) {
6920
+ return true;
6921
+ }
6922
+ return false;
6923
+ });
6924
+ const anyUserSurvives = messages.some(
6925
+ (msg, i) => survive[i] && msg.info.role === "user"
6926
+ );
6927
+ if (!anyUserSurvives) {
6928
+ for (let i = messages.length - 1; i >= 0; i--) {
6929
+ if (messages[i].info.role === "user" && !survive[i]) {
6930
+ survive[i] = true;
6931
+ break;
6932
+ }
6933
+ }
6934
+ }
6672
6935
  const result = [];
6673
6936
  for (let i = 0; i < messages.length; i++) {
6674
- const msg = messages[i];
6675
- const msgId = msg.info.id;
6676
- const pruneEntry = state.prune.messages.byMessageId.get(msgId);
6677
- if (pruneEntry && pruneEntry.activeBlockIds.length > 0) {
6678
- continue;
6937
+ if (survive[i]) {
6938
+ result.push(messages[i]);
6679
6939
  }
6680
- result.push(msg);
6681
6940
  }
6682
6941
  messages.length = 0;
6683
6942
  messages.push(...result);
@@ -7934,52 +8193,6 @@ ${lines2.join("\n")}`;
7934
8193
  ${lines.join("\n")}`;
7935
8194
  }
7936
8195
 
7937
- // node_modules/context-compress-algorithms/dist/chunk-ZRHPFN6B.js
7938
- var COMPRESS_PHILOSOPHY = `Compression Philosophy:
7939
- - All compression serves the primary task, but be frugal.
7940
- - Context capacity is precious. Save context by compressing consumed outputs, not by avoiding tools.
7941
- - Compress by need, not by percentage.
7942
- - 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.
7943
- - Curate summaries like a well-structured document. User prompts, compressed tool outputs, code, logs, or skill-call intermediate results that are critically important should be preserved \u2014 not by exempting them from compression, but by embedding them in the summary via [[KEEP:mNNNNN]] (auto-expanded verbatim) and [[REF:mNNNNN|description]] (compact link).`;
7944
- var HOW_TO_COMPRESS_RULES = `HOW TO COMPRESS
7945
-
7946
- 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.
7947
-
7948
- KEEP VERBATIM \u2014 never paraphrase or abbreviate these:
7949
- - 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.
7950
- - 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").
7951
- - Error messages and stack traces (exact text \u2014 you need the literal string to grep for it later).
7952
- - 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").
7953
- - Decisions and their rationale ("chose X over Y because Z" \u2014 the "because" is load-bearing; without it the decision looks arbitrary).
7954
- - Constraints discovered ("must support Node 22", "no new dependencies", "AGENTS.md forbids \`as any\`").
7955
- - Exact values: versions, config keys, thresholds, magic numbers.
7956
- - 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.
7957
- - 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.
7958
- - 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.
7959
- - Open questions and unresolved TODOs \u2014 losing these changes what work appears to remain.
7960
- - Message refs of key anchors (\`m00420\`, \`m00510\u2013m00520\`) \u2014 they let you or a later reader jump back via decompress to the exact original.
7961
-
7962
- DROP \u2014 extract the signal, discard the vessel:
7963
- - Verbose logs (build/test/\`npm\` output) once you have captured the error line or the result.
7964
- - Duplicate file reads once the needed content is recorded.
7965
- - 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).
7966
- - Dead-end exploration \u2014 but PRESERVE the lesson in one line: "tried X, failed because Y".
7967
- - Back-and-forth discussion and self-corrections once the final position is captured (keep the outcome, drop the journey to it).
7968
- - Repeated status checks (\`git status\`, \`ls\`) once state is known.
7969
-
7970
- 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.
7971
-
7972
- KEEP MARKERS: \`[[KEEP:mNNNNN]]\` expands original message content into the summary (truncated to a max length). Do NOT use KEEP for verbose command output, diagnostic scripts, log dumps, or any content whose value is in the conclusion rather than the raw output \u2014 summarize these or use \`[[REF:mNNNNN|desc]]\` instead.
7973
-
7974
- PRIORITY \u2014 when the summary must be compact, preserve in this order:
7975
- 1. User's overall goal, goal evolution, intent, and hard constraints (losing these changes the task).
7976
- 2. Decisions and rationale.
7977
- 3. Exact technical artifacts: paths, signatures, errors, values.
7978
- 4. Conclusions and key findings.
7979
- 5. Lessons learned: what failed and why.
7980
-
7981
- 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.`;
7982
-
7983
8196
  // lib/messages/inject/inject.ts
7984
8197
  var ACP_SUFFIX_SEED = "acp-dynamic-guidance";
7985
8198
  function createSuffixMessage(messages) {