opencode-acp 1.12.9 → 1.12.10-dev.1
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 +8 -0
- package/README.zh-CN.md +8 -0
- package/dist/index.js +225 -45
- package/dist/index.js.map +1 -1
- package/dist/lib/compress/decompress-logic.d.ts +9 -0
- package/dist/lib/compress/decompress-logic.d.ts.map +1 -1
- package/dist/lib/compress/decompress.d.ts.map +1 -1
- package/dist/lib/messages/inject/inject.d.ts.map +1 -1
- package/dist/lib/messages/inject/utils.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -422,6 +422,14 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
|
|
|
422
422
|
|
|
423
423
|
## Changelog
|
|
424
424
|
|
|
425
|
+
### v1.12.10-dev.1 — Decompress Range Mode + Token Classification + Protected Label Accuracy + Nudge Suppression + Discrete Intervals (PRs #73, #155, #157, #158, #159)
|
|
426
|
+
|
|
427
|
+
**Problem**: Five issues across decompress ergonomics, token accounting, and nudge quality. (1) `decompress` required a per-block `acp_status` → decompress-per-block loop to restore multiple compressed blocks. (2) Since v1.12.9 (compress-as-anchor), compress tool `summary` content was misclassified as `toolTokens` instead of `summaryTokens`, inflating tool% and deflating summary% in the context breakdown — misleading the model's compression decisions. (3) The `[PROTECTED: ...]` label listed every tool in a protected message (e.g. `[PROTECTED: grep, skill]`), misleading the model into thinking non-protected tools like `grep` were the reason for protection. (4) When all visible content was protected, the nudge still fired with an empty recommendation list — wasting context and confusing the model. (5) When a nudge was suppressed (filter removed all recommendations), the next-turn check re-evaluated every turn because the baseline never advanced, causing repeated wasted computation.
|
|
428
|
+
|
|
429
|
+
**Fix**: (1) **PR #73** — Added optional `startId`/`endId` to `decompress` schema; range mode batch-restores every active block whose `effectiveMessageIds` overlaps the resolved range in one call. New pure helper `findActiveBlocksOverlappingMessages` in `decompress-logic.ts`; backward compatible (`blockId` path unchanged). (2) **PR #155** — In `estimateContextComposition` (`lib/messages/inject/utils.ts`), when `toolName === "compress"`, extract `summary` text from `part.state.input.content[].summary` and classify it as `summaryTokens`. Structural overhead remains `toolTokens`. (3) **PR #157** — `buildCompressibleRanges` now only adds tools that actually trigger protection via `isToolNameProtected` or `isFilePathProtected`, mirroring `messageContainsProtectedTool` exactly — the label now shows `[PROTECTED: skill]` only. (4) **PR #158** — Added `allProtected = compressible.length === 0 && protected.length > 0` check; `nothingToCompress = filterSuppressed || allProtected` gates nudge injection so soft nudges are suppressed when there's genuinely nothing to compress (the `protected.length > 0` discriminator preserves the "no refs assigned yet" edge case). (5) **PR #159** — When a nudge is suppressed, advance `lastPerMessageNudgeTokens` to `currentTokens` and clear `lastNudgeShownTokens`, creating discrete 5% check intervals instead of every-turn re-evaluation.
|
|
430
|
+
|
|
431
|
+
Files: `lib/compress/decompress.ts`, `lib/compress/decompress-logic.ts`, `lib/messages/inject/utils.ts`, `lib/messages/inject/inject.ts`. Tests: 757 pass (11 new for decompress-logic, 5 new for token classification, 4 "all protected" tests fixed to actually exercise the `allProtected` branch, +1 new Scenario A test for compress-after-suppression).
|
|
432
|
+
|
|
425
433
|
### v1.12.9 — Compress-as-Anchor (PR #153)
|
|
426
434
|
|
|
427
435
|
**Problem**: Since v1.12.1, compression summaries were injected as synthetic tool-result messages via a registered `acp_context_recap` tool, while `stripStaleCompressCalls` removed past `compress` tool calls from the API context to avoid duplication. This doubled summary overhead: each block's summary existed both as a synthetic recap message AND in the original (now-stripped) compress call's `summary` parameter. For sessions with many compressions, this "recap overhead" consumed 10–20% of context with no additional information value. The `acp_context_recap` tool description also claimed it "automatically injects" summaries, which was misleading.
|
package/README.zh-CN.md
CHANGED
|
@@ -395,6 +395,14 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
|
|
|
395
395
|
|
|
396
396
|
## 更新日志
|
|
397
397
|
|
|
398
|
+
### v1.12.10-dev.1 — Decompress 范围模式 + Token 分类 + Protected 标签精确性 + Nudge 抑制 + 离散间隔(PR #73, #155, #157, #158, #159)
|
|
399
|
+
|
|
400
|
+
**问题**:五个问题,涉及 decompress 易用性、token 统计和 nudge 质量。(1)`decompress` 需要先 `acp_status` 再逐块 decompress 的循环才能恢复多个压缩块。(2)自 v1.12.9(compress-as-anchor)起,compress 工具的 `summary` 内容被错误分类为 `toolTokens` 而非 `summaryTokens`,导致上下文分布中 tool% 虚高、summary% 虚低 —— 误导模型的压缩决策。(3)`[PROTECTED: ...]` 标签列出受保护消息中的所有工具(例如 `[PROTECTED: grep, skill]`),让模型误以为 `grep` 等非保护工具也是保护原因。(4)当所有可见内容都是受保护内容时,nudge 仍然以空推荐列表注入 —— 浪费上下文且让模型困惑。(5)当 nudge 被抑制(过滤器移除所有推荐)时,下一轮检查每轮都重新评估,因为 baseline 从不前进,导致重复浪费计算。
|
|
401
|
+
|
|
402
|
+
**修复**:(1)**PR #73** —— 为 `decompress` schema 新增可选 `startId`/`endId`;范围模式批量恢复所有 `effectiveMessageIds` 与解析范围重叠的活跃块,一次调用完成。新纯函数 `findActiveBlocksOverlappingMessages` 在 `decompress-logic.ts`;向后兼容(`blockId` 路径不变)。(2)**PR #155** —— 在 `estimateContextComposition`(`lib/messages/inject/utils.ts`)中,当 `toolName === "compress"` 时,从 `part.state.input.content[].summary` 提取 `summary` 文本并分类为 `summaryTokens`。结构开销仍计为 `toolTokens`。(3)**PR #157** —— `buildCompressibleRanges` 现在只添加通过 `isToolNameProtected` 或 `isFilePathProtected` 实际触发保护的工具,与 `messageContainsProtectedTool` 逻辑完全一致 —— 标签现在只显示 `[PROTECTED: skill]`。(4)**PR #158** —— 新增 `allProtected = compressible.length === 0 && protected.length > 0` 检查;`nothingToCompress = filterSuppressed || allProtected` 门控 nudge 注入,当确实没有可压缩内容时抑制软 nudge(`protected.length > 0` 判别式保留了"尚未分配 ref"的边缘情况)。(5)**PR #159** —— 当 nudge 被抑制时,将 `lastPerMessageNudgeTokens` 前进到 `currentTokens` 并清除 `lastNudgeShownTokens`,创建离散 5% 检查间隔而非每轮重新评估。
|
|
403
|
+
|
|
404
|
+
文件:`lib/compress/decompress.ts`、`lib/compress/decompress-logic.ts`、`lib/messages/inject/utils.ts`、`lib/messages/inject/inject.ts`。测试:757 通过(decompress-logic 新增 11 个,token 分类新增 5 个,4 个 "all protected" 测试修复为真正触发 `allProtected` 分支,+1 个 Scenario A 测试覆盖抑制后压缩)。
|
|
405
|
+
|
|
398
406
|
### v1.12.9 — Compress-as-Anchor(PR #153)
|
|
399
407
|
|
|
400
408
|
**问题**:自 v1.12.1 起,压缩摘要通过注册的 `acp_context_recap` 工具以 synthetic tool-result 消息注入,同时 `stripStaleCompressCalls` 从 API 上下文中移除历史 `compress` 工具调用以避免重复。这导致摘要开销翻倍:每个块的摘要同时存在于 synthetic recap 消息和原始(已移除的)compress 调用的 `summary` 参数中。对于压缩频繁的会话,这个 "recap 开销" 占用 10–20% 上下文却没有额外信息价值。`acp_context_recap` 工具描述也声称它"自动注入"摘要,具有误导性。
|
package/dist/index.js
CHANGED
|
@@ -6765,10 +6765,23 @@ function estimateContextComposition(messages, state, protectedTools = [], protec
|
|
|
6765
6765
|
const raw = JSON.stringify(part);
|
|
6766
6766
|
const tokens = Math.round(raw.length / 4);
|
|
6767
6767
|
msgTotal += tokens;
|
|
6768
|
-
toolTokens += tokens;
|
|
6769
|
-
msgTool += tokens;
|
|
6770
6768
|
const toolName = part?.tool || "unknown";
|
|
6771
|
-
|
|
6769
|
+
let summaryPartTokens = 0;
|
|
6770
|
+
if (toolName === "compress") {
|
|
6771
|
+
const input = part?.state?.input;
|
|
6772
|
+
if (input?.content && Array.isArray(input.content)) {
|
|
6773
|
+
for (const entry of input.content) {
|
|
6774
|
+
if (typeof entry?.summary === "string") {
|
|
6775
|
+
summaryPartTokens += Math.round(entry.summary.length / 4);
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
}
|
|
6779
|
+
}
|
|
6780
|
+
const toolPartTokens = Math.max(0, tokens - summaryPartTokens);
|
|
6781
|
+
toolTokens += toolPartTokens;
|
|
6782
|
+
msgTool += toolPartTokens;
|
|
6783
|
+
summaryTokens += summaryPartTokens;
|
|
6784
|
+
toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + toolPartTokens);
|
|
6772
6785
|
if (!msgToolName) msgToolName = toolName;
|
|
6773
6786
|
}
|
|
6774
6787
|
}
|
|
@@ -6824,7 +6837,20 @@ function buildCompressibleRanges(messages, state, protectedTools = [], protected
|
|
|
6824
6837
|
} else if (part.type !== "text" && part.type !== "reasoning") {
|
|
6825
6838
|
tokens2 += Math.round(JSON.stringify(part).length / 4);
|
|
6826
6839
|
const toolName = part?.tool;
|
|
6827
|
-
|
|
6840
|
+
const callID = part?.callID;
|
|
6841
|
+
if (toolName && callID) {
|
|
6842
|
+
if (isToolNameProtected(toolName, protectedTools)) {
|
|
6843
|
+
tools.add(toolName);
|
|
6844
|
+
} else if (protectedFilePatterns.length > 0) {
|
|
6845
|
+
const filePaths = getFilePathsFromParameters(
|
|
6846
|
+
toolName,
|
|
6847
|
+
part?.state?.input
|
|
6848
|
+
);
|
|
6849
|
+
if (isFilePathProtected(filePaths, protectedFilePatterns)) {
|
|
6850
|
+
tools.add(toolName);
|
|
6851
|
+
}
|
|
6852
|
+
}
|
|
6853
|
+
}
|
|
6828
6854
|
}
|
|
6829
6855
|
}
|
|
6830
6856
|
protectedMsgInfo.push({ ref, refNum: rn, tokens: tokens2, tools: [...tools] });
|
|
@@ -7297,7 +7323,13 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
|
|
|
7297
7323
|
logger.debug(lines.join("\n"));
|
|
7298
7324
|
}
|
|
7299
7325
|
const filterSuppressed = contextRanges.compressible.length > 0 && !hasRecommendations;
|
|
7300
|
-
const
|
|
7326
|
+
const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0;
|
|
7327
|
+
const nothingToCompress = filterSuppressed || allProtected;
|
|
7328
|
+
const shouldInject = nudgeAllowed && (!nothingToCompress || emergencyOverride);
|
|
7329
|
+
if (nudgeAllowed && nothingToCompress && !emergencyOverride && currentTokens !== void 0) {
|
|
7330
|
+
state.nudges.lastPerMessageNudgeTokens = currentTokens;
|
|
7331
|
+
state.nudges.lastNudgeShownTokens = void 0;
|
|
7332
|
+
}
|
|
7301
7333
|
state.nudges.shouldInjectThisTurn = shouldInject;
|
|
7302
7334
|
let tipsText = null;
|
|
7303
7335
|
if (shouldInject) {
|
|
@@ -7656,6 +7688,37 @@ function parseBlockIdArg(arg) {
|
|
|
7656
7688
|
const parsed = Number.parseInt(normalized, 10);
|
|
7657
7689
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
7658
7690
|
}
|
|
7691
|
+
function resolveDecompressMode(args) {
|
|
7692
|
+
const hasBlockId = typeof args.blockId === "string" && args.blockId.trim() !== "";
|
|
7693
|
+
const hasStartId = typeof args.startId === "string" && args.startId.trim() !== "";
|
|
7694
|
+
const hasEndId = typeof args.endId === "string" && args.endId.trim() !== "";
|
|
7695
|
+
if (hasBlockId && (hasStartId || hasEndId)) {
|
|
7696
|
+
return { ok: false, error: "Cannot specify both blockId and startId/endId. Choose one mode." };
|
|
7697
|
+
}
|
|
7698
|
+
if (!hasBlockId && !(hasStartId && hasEndId)) {
|
|
7699
|
+
return { ok: false, error: "Must specify either blockId, or both startId and endId." };
|
|
7700
|
+
}
|
|
7701
|
+
return { ok: true, mode: hasBlockId ? "block" : "range" };
|
|
7702
|
+
}
|
|
7703
|
+
function findActiveBlocksOverlappingMessages(messagesState, messageIds) {
|
|
7704
|
+
if (messageIds.size === 0) {
|
|
7705
|
+
return [];
|
|
7706
|
+
}
|
|
7707
|
+
const matched = /* @__PURE__ */ new Map();
|
|
7708
|
+
for (const [blockId, block] of messagesState.blocksById) {
|
|
7709
|
+
if (!block.active) {
|
|
7710
|
+
continue;
|
|
7711
|
+
}
|
|
7712
|
+
const effectiveIds = block.effectiveMessageIds ?? [];
|
|
7713
|
+
for (const msgId of effectiveIds) {
|
|
7714
|
+
if (messageIds.has(msgId)) {
|
|
7715
|
+
matched.set(blockId, block);
|
|
7716
|
+
break;
|
|
7717
|
+
}
|
|
7718
|
+
}
|
|
7719
|
+
}
|
|
7720
|
+
return Array.from(matched.values()).sort((a, b) => a.blockId - b.blockId);
|
|
7721
|
+
}
|
|
7659
7722
|
function findActiveParentBlockId(messagesState, block) {
|
|
7660
7723
|
const queue = [...block.parentBlockIds];
|
|
7661
7724
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -7806,12 +7869,120 @@ async function prepareDecompressSession(ctx, toolCtx) {
|
|
|
7806
7869
|
async function finalizeDecompressSession(ctx) {
|
|
7807
7870
|
await saveSessionState(ctx.state, ctx.logger);
|
|
7808
7871
|
}
|
|
7809
|
-
|
|
7872
|
+
function resolveTargets(args, state, rawMessages, logger) {
|
|
7873
|
+
const mode = resolveDecompressMode(args);
|
|
7874
|
+
if (!mode.ok) {
|
|
7875
|
+
return { ok: false, error: `Error: ${mode.error}` };
|
|
7876
|
+
}
|
|
7877
|
+
const messagesState = state.prune.messages;
|
|
7878
|
+
if (mode.mode === "block") {
|
|
7879
|
+
return resolveSingleBlockTarget(messagesState, args.blockId);
|
|
7880
|
+
}
|
|
7881
|
+
return resolveRangeTarget(state, rawMessages, args.startId, args.endId, logger);
|
|
7882
|
+
}
|
|
7883
|
+
function resolveSingleBlockTarget(messagesState, blockIdArg) {
|
|
7884
|
+
const targetBlockId = parseBlockIdArg(blockIdArg);
|
|
7885
|
+
if (targetBlockId === null) {
|
|
7886
|
+
return {
|
|
7887
|
+
ok: false,
|
|
7888
|
+
error: `Error: Invalid block ID "${blockIdArg}". Use format "b0", "b1", etc.`
|
|
7889
|
+
};
|
|
7890
|
+
}
|
|
7891
|
+
const target = resolveCompressionTarget(messagesState, targetBlockId);
|
|
7892
|
+
if (!target) {
|
|
7893
|
+
return {
|
|
7894
|
+
ok: false,
|
|
7895
|
+
error: `Error: Block ${targetBlockId} does not exist. No compression found with that ID.`
|
|
7896
|
+
};
|
|
7897
|
+
}
|
|
7898
|
+
const activeBlocks = target.blocks.filter((block) => block.active);
|
|
7899
|
+
if (activeBlocks.length === 0) {
|
|
7900
|
+
const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target);
|
|
7901
|
+
if (activeAncestorBlockId !== null) {
|
|
7902
|
+
return {
|
|
7903
|
+
ok: false,
|
|
7904
|
+
error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`
|
|
7905
|
+
};
|
|
7906
|
+
}
|
|
7907
|
+
return {
|
|
7908
|
+
ok: false,
|
|
7909
|
+
error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`
|
|
7910
|
+
};
|
|
7911
|
+
}
|
|
7912
|
+
return { ok: true, targets: [target] };
|
|
7913
|
+
}
|
|
7914
|
+
function resolveRangeTarget(state, rawMessages, startId, endId, logger) {
|
|
7915
|
+
const searchContext = buildSearchContext(state, rawMessages);
|
|
7916
|
+
let startReference;
|
|
7917
|
+
let endReference;
|
|
7918
|
+
try {
|
|
7919
|
+
const resolved = resolveBoundaryIds(searchContext, state, startId, endId);
|
|
7920
|
+
startReference = resolved.startReference;
|
|
7921
|
+
endReference = resolved.endReference;
|
|
7922
|
+
} catch (err) {
|
|
7923
|
+
return { ok: false, error: `Error: ${err.message}` };
|
|
7924
|
+
}
|
|
7925
|
+
let selection;
|
|
7926
|
+
try {
|
|
7927
|
+
selection = resolveSelection(searchContext, startReference, endReference);
|
|
7928
|
+
} catch (err) {
|
|
7929
|
+
return { ok: false, error: `Error: ${err.message}` };
|
|
7930
|
+
}
|
|
7931
|
+
if (selection.messageIds.length === 0) {
|
|
7932
|
+
return {
|
|
7933
|
+
ok: false,
|
|
7934
|
+
error: `Error: No messages found in range ${startId}..${endId}.`
|
|
7935
|
+
};
|
|
7936
|
+
}
|
|
7937
|
+
const messageIdSet = new Set(selection.messageIds);
|
|
7938
|
+
const messagesState = state.prune.messages;
|
|
7939
|
+
const overlappingBlocks = findActiveBlocksOverlappingMessages(messagesState, messageIdSet);
|
|
7940
|
+
if (overlappingBlocks.length === 0) {
|
|
7941
|
+
return {
|
|
7942
|
+
ok: false,
|
|
7943
|
+
error: `Error: No active compression blocks overlap the range ${startId}..${endId}. The content may already be fully visible. Use acp_status to review block coverage.`
|
|
7944
|
+
};
|
|
7945
|
+
}
|
|
7946
|
+
const targetMap = /* @__PURE__ */ new Map();
|
|
7947
|
+
for (const block of overlappingBlocks) {
|
|
7948
|
+
const target = resolveCompressionTarget(messagesState, block.blockId);
|
|
7949
|
+
if (target) {
|
|
7950
|
+
targetMap.set(target.displayId, target);
|
|
7951
|
+
}
|
|
7952
|
+
}
|
|
7953
|
+
const targets = Array.from(targetMap.values());
|
|
7954
|
+
logger.info("range decompress resolved", {
|
|
7955
|
+
range: `${startId}..${endId}`,
|
|
7956
|
+
matchedBlocks: overlappingBlocks.map((b) => b.blockId),
|
|
7957
|
+
targets: targets.map((t) => t.displayId)
|
|
7958
|
+
});
|
|
7959
|
+
return { ok: true, targets };
|
|
7960
|
+
}
|
|
7961
|
+
var TOOL_DESCRIPTION = `Restores previously compressed content.
|
|
7810
7962
|
|
|
7811
|
-
Use this tool when you need exact details from
|
|
7963
|
+
Use this tool when you need exact details from compressed content that the summary cannot provide.
|
|
7812
7964
|
The tool returns a condensed preview of the restored content so you can reason about it immediately.
|
|
7813
7965
|
|
|
7814
|
-
|
|
7966
|
+
TWO MODES:
|
|
7967
|
+
|
|
7968
|
+
1. Block mode (default): decompress a single block by ID.
|
|
7969
|
+
- blockId: block reference to decompress (e.g., "b0", "b2")
|
|
7970
|
+
|
|
7971
|
+
2. Range mode: decompress ALL blocks overlapping a message range. Use this to restore
|
|
7972
|
+
content across multiple blocks without calling acp_status + decompress repeatedly.
|
|
7973
|
+
- startId: starting message or block ref (e.g., "m00150")
|
|
7974
|
+
- endId: ending message or block ref (e.g., "m00200")
|
|
7975
|
+
|
|
7976
|
+
Range mode finds every active block whose effectiveMessageIds touch the range and
|
|
7977
|
+
batch-restores them. Partial overlap decompresses the whole block (content cannot be
|
|
7978
|
+
partially restored). Nested blocks are handled automatically.
|
|
7979
|
+
|
|
7980
|
+
ARGUMENTS:
|
|
7981
|
+
- blockId?: string \u2014 use this OR startId+endId (mutually exclusive)
|
|
7982
|
+
- startId?: string \u2014 range start (message or block ref)
|
|
7983
|
+
- endId?: string \u2014 range end (message or block ref)
|
|
7984
|
+
- toFile?: string \u2014 if provided, writes restored content to this file path (must be under
|
|
7985
|
+
/tmp or ~/.cache/opencode/) instead of inflating context. Block(s) stay compressed.
|
|
7815
7986
|
|
|
7816
7987
|
IMPORTANT:
|
|
7817
7988
|
- Decompressing inflates context. Check context usage before decompressing.
|
|
@@ -7820,10 +7991,22 @@ IMPORTANT:
|
|
|
7820
7991
|
- Do NOT call this tool in parallel with compress \u2014 their state mutations may conflict.`;
|
|
7821
7992
|
function buildSchema3() {
|
|
7822
7993
|
return {
|
|
7823
|
-
blockId: tool4.schema.string().describe('Block reference to decompress (e.g., "b0", "b2")'),
|
|
7824
|
-
|
|
7994
|
+
blockId: tool4.schema.string().optional().describe('Block reference to decompress (e.g., "b0", "b2"). Mutually exclusive with startId/endId.'),
|
|
7995
|
+
startId: tool4.schema.string().optional().describe('Range start: message ref (e.g., "m00150") or block ref (e.g., "b2"). Used with endId.'),
|
|
7996
|
+
endId: tool4.schema.string().optional().describe('Range end: message ref (e.g., "m00200") or block ref (e.g., "b5"). Used with startId.'),
|
|
7997
|
+
toFile: tool4.schema.string().optional().describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Path must be under /tmp or ~/.cache/opencode/. Example: '/tmp/block52.txt'")
|
|
7825
7998
|
};
|
|
7826
7999
|
}
|
|
8000
|
+
function extractMessageId(m) {
|
|
8001
|
+
return m.id ?? m.messageId ?? "";
|
|
8002
|
+
}
|
|
8003
|
+
function extractMessageText(m) {
|
|
8004
|
+
const msg = m;
|
|
8005
|
+
const role = msg.role || msg.type || "unknown";
|
|
8006
|
+
const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
|
|
8007
|
+
return `[${role}]
|
|
8008
|
+
${content}`;
|
|
8009
|
+
}
|
|
7827
8010
|
function createDecompressTool(ctx) {
|
|
7828
8011
|
return tool4({
|
|
7829
8012
|
description: TOOL_DESCRIPTION,
|
|
@@ -7833,22 +8016,19 @@ function createDecompressTool(ctx) {
|
|
|
7833
8016
|
const contextUsageBefore = ctx.state.modelContextLimit ? Math.round(
|
|
7834
8017
|
getCurrentTokenUsage(ctx.state, rawMessages) / ctx.state.modelContextLimit * 100
|
|
7835
8018
|
) : void 0;
|
|
7836
|
-
const
|
|
7837
|
-
if (
|
|
7838
|
-
return
|
|
8019
|
+
const resolved = resolveTargets(args, ctx.state, rawMessages, ctx.logger);
|
|
8020
|
+
if (!resolved.ok) {
|
|
8021
|
+
return resolved.error;
|
|
7839
8022
|
}
|
|
8023
|
+
const targets = resolved.targets;
|
|
7840
8024
|
const messagesState = ctx.state.prune.messages;
|
|
7841
|
-
const
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target);
|
|
7848
|
-
if (activeAncestorBlockId !== null) {
|
|
7849
|
-
return `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`;
|
|
8025
|
+
const activeBlocks = [];
|
|
8026
|
+
for (const target of targets) {
|
|
8027
|
+
for (const block of target.blocks) {
|
|
8028
|
+
if (block.active) {
|
|
8029
|
+
activeBlocks.push(block);
|
|
8030
|
+
}
|
|
7850
8031
|
}
|
|
7851
|
-
return `Error: Block ${target.displayId} is not active. It may have already been decompressed.`;
|
|
7852
8032
|
}
|
|
7853
8033
|
if (args.toFile) {
|
|
7854
8034
|
const targetPath = args.toFile;
|
|
@@ -7858,35 +8038,33 @@ function createDecompressTool(ctx) {
|
|
|
7858
8038
|
os.tmpdir() + "/",
|
|
7859
8039
|
path.join(os.homedir(), ".cache", "opencode") + "/"
|
|
7860
8040
|
];
|
|
7861
|
-
const
|
|
8041
|
+
const resolvedPath = path.resolve(targetPath);
|
|
7862
8042
|
const isAllowed = allowedDirs.some((dir) => {
|
|
7863
|
-
const rel = path.relative(dir,
|
|
8043
|
+
const rel = path.relative(dir, resolvedPath);
|
|
7864
8044
|
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
7865
8045
|
});
|
|
7866
8046
|
if (!isAllowed) {
|
|
7867
8047
|
return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
|
|
7868
8048
|
}
|
|
7869
|
-
const
|
|
7870
|
-
const
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
}
|
|
7875
|
-
const
|
|
7876
|
-
|
|
7877
|
-
const role = msg.role || msg.type || "unknown";
|
|
7878
|
-
const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
|
|
7879
|
-
return `[${role}]
|
|
7880
|
-
${content}`;
|
|
7881
|
-
});
|
|
8049
|
+
const msgIdSet = /* @__PURE__ */ new Set();
|
|
8050
|
+
for (const block of activeBlocks) {
|
|
8051
|
+
for (const id of block.effectiveMessageIds ?? []) {
|
|
8052
|
+
msgIdSet.add(id);
|
|
8053
|
+
}
|
|
8054
|
+
}
|
|
8055
|
+
const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m)));
|
|
8056
|
+
const lines2 = blockMessages.map(extractMessageText);
|
|
7882
8057
|
const { writeFile: writeFile3 } = await import("fs/promises");
|
|
7883
|
-
const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") :
|
|
7884
|
-
await writeFile3(
|
|
7885
|
-
|
|
8058
|
+
const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : activeBlocks[0]?.summary ?? "(no content available)";
|
|
8059
|
+
await writeFile3(targetPath, fileContent, "utf-8");
|
|
8060
|
+
const displayIds2 = targets.map((t) => `b${t.displayId}`).join(", ");
|
|
8061
|
+
return `Block(s) ${displayIds2} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed \u2014 context unchanged. Use read tool to access specific parts.`;
|
|
7886
8062
|
}
|
|
7887
8063
|
const activeMessagesBefore = snapshotActiveMessages(messagesState);
|
|
7888
8064
|
const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
|
|
7889
|
-
|
|
8065
|
+
for (const target of targets) {
|
|
8066
|
+
deactivateCompressionTarget(messagesState, target);
|
|
8067
|
+
}
|
|
7890
8068
|
syncCompressionBlocks(ctx.state, ctx.logger, rawMessages);
|
|
7891
8069
|
const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
|
|
7892
8070
|
messagesState,
|
|
@@ -7909,9 +8087,11 @@ ${content}`;
|
|
|
7909
8087
|
activeMessagesBefore,
|
|
7910
8088
|
messagesState
|
|
7911
8089
|
);
|
|
8090
|
+
const displayIds = targets.map((t) => `b${t.displayId}`).join(", ");
|
|
7912
8091
|
const lines = [];
|
|
8092
|
+
const headerNoun = targets.length === 1 ? "block" : "blocks";
|
|
7913
8093
|
lines.push(
|
|
7914
|
-
`Decompressed
|
|
8094
|
+
`Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`
|
|
7915
8095
|
);
|
|
7916
8096
|
if (contextUsageBefore !== void 0 && contextUsageAfter !== void 0) {
|
|
7917
8097
|
lines.push(`Context usage: ${contextUsageBefore}% \u2192 ${contextUsageAfter}%.`);
|
|
@@ -7926,8 +8106,8 @@ ${content}`;
|
|
|
7926
8106
|
lines.push(restoredContentPreview);
|
|
7927
8107
|
}
|
|
7928
8108
|
ctx.logger.info("Decompress tool completed", {
|
|
7929
|
-
|
|
7930
|
-
|
|
8109
|
+
mode: typeof args.startId === "string" ? "range" : "block",
|
|
8110
|
+
targetBlockIds: targets.map((t) => t.displayId),
|
|
7931
8111
|
restoredMessageCount,
|
|
7932
8112
|
restoredTokens,
|
|
7933
8113
|
reactivatedBlockIds
|