opencode-acp 1.7.0 → 1.8.0
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 +28 -0
- package/README.zh-CN.md +28 -0
- package/dist/index.js +116 -174
- package/dist/index.js.map +1 -1
- package/dist/lib/compress/decompress.d.ts.map +1 -1
- package/dist/lib/compress/message.d.ts.map +1 -1
- package/dist/lib/compress/range.d.ts.map +1 -1
- package/dist/lib/config-validation.d.ts.map +1 -1
- package/dist/lib/config.d.ts +1 -2
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/messages/inject/inject.d.ts.map +1 -1
- package/dist/lib/messages/inject/utils.d.ts +1 -1
- package/dist/lib/messages/inject/utils.d.ts.map +1 -1
- package/dist/lib/prompts/system.d.ts +1 -1
- package/dist/lib/prompts/system.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -419,6 +419,34 @@ For the complete list with root cause analysis, see the [bug tracker](https://gi
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
+
## Changelog
|
|
423
|
+
|
|
424
|
+
### v1.7.0 — Principle-Driven Prompts
|
|
425
|
+
|
|
426
|
+
**Philosophy**: Replaced verbose context-management guidance with 4 concise principles injected every turn. The model now sees *what matters* (principles) instead of *what to do* (rigid rules).
|
|
427
|
+
|
|
428
|
+
**Prompt changes**:
|
|
429
|
+
- 4 principles replace CONTEXT PRESSURE LEVELS, 7-item priority list, DO NOT RE-COMPRESS rules
|
|
430
|
+
- Context display simplified: absolute token count only, no percentage
|
|
431
|
+
- `<acp-context>` tag wrapping (backward compatible with `<dcp-context>`)
|
|
432
|
+
|
|
433
|
+
**Hybrid Tips frequency**:
|
|
434
|
+
- 💡 Light Tips (15-45%): Every turn — non-disruptive reminder
|
|
435
|
+
- ⚠️ Warning Tips (45%+): Key nodes only — first crossing or 10pp growth, prevents over-compression
|
|
436
|
+
|
|
437
|
+
**Config simplification**:
|
|
438
|
+
- Removed `hardNudgeContextPercent` — merged into `minContextLimit`/`maxContextLimit`
|
|
439
|
+
- Removed `perMessageNudgeGrowthPercent` — light Tips show every turn
|
|
440
|
+
- `maxSummaryLength` default: 200 → 2000
|
|
441
|
+
- `maxSummaryLengthHard` default: 3000 → 4000
|
|
442
|
+
|
|
443
|
+
**Bug fixes**:
|
|
444
|
+
- Windows path validation: `os.tmpdir()` + `path.relative()` (was hardcoded `/tmp/`)
|
|
445
|
+
- Compress after-detection: reset warning tracking
|
|
446
|
+
- Dead code cleanup: `shouldInjectPerMessageNudge`, no-op template
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
422
450
|
## License
|
|
423
451
|
|
|
424
452
|
AGPL-3.0-or-later -- This project is a fork of [@tarquinen/opencode-dcp](https://github.com/Tarquinen/opencode-dynamic-context-pruning). Original copyright belongs to the original author. Modifications and bug fixes by ranxianglei.
|
package/README.zh-CN.md
CHANGED
|
@@ -391,6 +391,34 @@ ACP 在首次启动时自动将配置从 `dcp.jsonc` 迁移到 `acp.jsonc`,将
|
|
|
391
391
|
|
|
392
392
|
---
|
|
393
393
|
|
|
394
|
+
## 更新日志
|
|
395
|
+
|
|
396
|
+
### v1.7.0 — 原则驱动提示
|
|
397
|
+
|
|
398
|
+
**理念**:用 4 条简洁原则替代冗长的上下文管理指导。模型现在看到的是*重要原则*而非*死板规则*。
|
|
399
|
+
|
|
400
|
+
**提示变更**:
|
|
401
|
+
- 4 条原则替代 CONTEXT PRESSURE LEVELS、7 项优先级列表、DO NOT RE-COMPRESS 规则
|
|
402
|
+
- 上下文显示简化:仅显示绝对 token 数,不显示百分比
|
|
403
|
+
- `<acp-context>` 标签包裹(向后兼容 `<dcp-context>`)
|
|
404
|
+
|
|
405
|
+
**混合 Tips 频率**:
|
|
406
|
+
- 💡 轻量提示(15-45%):每轮显示 — 不打扰
|
|
407
|
+
- ⚠️ 警告提示(45%+):仅关键节点 — 首次跨越或增长 10pp,防止过度压缩
|
|
408
|
+
|
|
409
|
+
**配置简化**:
|
|
410
|
+
- 移除 `hardNudgeContextPercent` — 合并到 `minContextLimit`/`maxContextLimit`
|
|
411
|
+
- 移除 `perMessageNudgeGrowthPercent` — 轻量提示每轮显示
|
|
412
|
+
- `maxSummaryLength` 默认值:200 → 2000
|
|
413
|
+
- `maxSummaryLengthHard` 默认值:3000 → 4000
|
|
414
|
+
|
|
415
|
+
**Bug 修复**:
|
|
416
|
+
- Windows 路径校验:`os.tmpdir()` + `path.relative()`(原硬编码 `/tmp/`)
|
|
417
|
+
- 压缩检测后:重置警告追踪
|
|
418
|
+
- 死代码清理:`shouldInjectPerMessageNudge`、空操作模板
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
394
422
|
## 许可证
|
|
395
423
|
|
|
396
424
|
AGPL-3.0-or-later — 本项目是 [@tarquinen/opencode-dcp](https://github.com/Tarquinen/opencode-dynamic-context-pruning) 的分支。原始版权归原始作者所有。修改和错误修复由 ranxianglei 完成。
|
package/dist/index.js
CHANGED
|
@@ -894,13 +894,12 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
|
894
894
|
"compress.modelMaxLimits",
|
|
895
895
|
"compress.modelMinLimits",
|
|
896
896
|
"compress.nudgeFrequency",
|
|
897
|
-
"compress.
|
|
897
|
+
"compress.minNudgeContextPercent",
|
|
898
898
|
"compress.iterationNudgeThreshold",
|
|
899
899
|
"compress.nudgeForce",
|
|
900
900
|
"compress.protectedTools",
|
|
901
901
|
"compress.protectTags",
|
|
902
902
|
"compress.protectUserMessages",
|
|
903
|
-
"compress.maxSummaryLength",
|
|
904
903
|
"compress.maxSummaryLengthHard",
|
|
905
904
|
"compress.minCompressRange",
|
|
906
905
|
"gc",
|
|
@@ -1121,13 +1120,6 @@ function validateConfigTypes(config) {
|
|
|
1121
1120
|
actual: `${compress.nudgeFrequency} (will be clamped to 1)`
|
|
1122
1121
|
});
|
|
1123
1122
|
}
|
|
1124
|
-
if (compress.perMessageNudgeGrowthPercent !== void 0 && typeof compress.perMessageNudgeGrowthPercent !== "number") {
|
|
1125
|
-
errors.push({
|
|
1126
|
-
key: "compress.perMessageNudgeGrowthPercent",
|
|
1127
|
-
expected: "number",
|
|
1128
|
-
actual: typeof compress.perMessageNudgeGrowthPercent
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
1123
|
if (compress.iterationNudgeThreshold !== void 0 && typeof compress.iterationNudgeThreshold !== "number") {
|
|
1132
1124
|
errors.push({
|
|
1133
1125
|
key: "compress.iterationNudgeThreshold",
|
|
@@ -1163,20 +1155,6 @@ function validateConfigTypes(config) {
|
|
|
1163
1155
|
actual: typeof compress.protectUserMessages
|
|
1164
1156
|
});
|
|
1165
1157
|
}
|
|
1166
|
-
if (compress.maxSummaryLength !== void 0 && typeof compress.maxSummaryLength !== "number") {
|
|
1167
|
-
errors.push({
|
|
1168
|
-
key: "compress.maxSummaryLength",
|
|
1169
|
-
expected: "number",
|
|
1170
|
-
actual: typeof compress.maxSummaryLength
|
|
1171
|
-
});
|
|
1172
|
-
}
|
|
1173
|
-
if (typeof compress.maxSummaryLength === "number" && compress.maxSummaryLength < 1) {
|
|
1174
|
-
errors.push({
|
|
1175
|
-
key: "compress.maxSummaryLength",
|
|
1176
|
-
expected: "positive number (>= 1)",
|
|
1177
|
-
actual: `${compress.maxSummaryLength}`
|
|
1178
|
-
});
|
|
1179
|
-
}
|
|
1180
1158
|
if (compress.maxSummaryLengthHard !== void 0 && typeof compress.maxSummaryLengthHard !== "number") {
|
|
1181
1159
|
errors.push({
|
|
1182
1160
|
key: "compress.maxSummaryLengthHard",
|
|
@@ -1191,13 +1169,6 @@ function validateConfigTypes(config) {
|
|
|
1191
1169
|
actual: `${compress.maxSummaryLengthHard}`
|
|
1192
1170
|
});
|
|
1193
1171
|
}
|
|
1194
|
-
if (typeof compress.maxSummaryLength === "number" && typeof compress.maxSummaryLengthHard === "number" && compress.maxSummaryLengthHard < compress.maxSummaryLength) {
|
|
1195
|
-
errors.push({
|
|
1196
|
-
key: "compress.maxSummaryLengthHard",
|
|
1197
|
-
expected: `>= maxSummaryLength (${compress.maxSummaryLength})`,
|
|
1198
|
-
actual: `${compress.maxSummaryLengthHard}`
|
|
1199
|
-
});
|
|
1200
|
-
}
|
|
1201
1172
|
if (compress.minCompressRange !== void 0 && typeof compress.minCompressRange !== "number") {
|
|
1202
1173
|
errors.push({
|
|
1203
1174
|
key: "compress.minCompressRange",
|
|
@@ -1519,14 +1490,13 @@ var defaultConfig = {
|
|
|
1519
1490
|
maxContextLimit: "55%",
|
|
1520
1491
|
minContextLimit: "45%",
|
|
1521
1492
|
nudgeFrequency: 5,
|
|
1522
|
-
|
|
1493
|
+
minNudgeContextPercent: 15,
|
|
1523
1494
|
iterationNudgeThreshold: 15,
|
|
1524
1495
|
nudgeForce: "soft",
|
|
1525
1496
|
protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
|
|
1526
1497
|
protectTags: false,
|
|
1527
1498
|
protectUserMessages: false,
|
|
1528
|
-
|
|
1529
|
-
maxSummaryLengthHard: 3e3,
|
|
1499
|
+
maxSummaryLengthHard: 4e3,
|
|
1530
1500
|
minCompressRange: 2e3
|
|
1531
1501
|
},
|
|
1532
1502
|
strategies: {
|
|
@@ -1674,13 +1644,12 @@ function mergeCompress(base, override) {
|
|
|
1674
1644
|
modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits,
|
|
1675
1645
|
modelMinLimits: override.modelMinLimits ?? base.modelMinLimits,
|
|
1676
1646
|
nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency,
|
|
1677
|
-
|
|
1647
|
+
minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent,
|
|
1678
1648
|
iterationNudgeThreshold: override.iterationNudgeThreshold ?? base.iterationNudgeThreshold,
|
|
1679
1649
|
nudgeForce: override.nudgeForce ?? base.nudgeForce,
|
|
1680
1650
|
protectedTools: [.../* @__PURE__ */ new Set([...base.protectedTools, ...override.protectedTools ?? []])],
|
|
1681
1651
|
protectTags: override.protectTags ?? base.protectTags,
|
|
1682
1652
|
protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
|
|
1683
|
-
maxSummaryLength: override.maxSummaryLength ?? base.maxSummaryLength,
|
|
1684
1653
|
maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard,
|
|
1685
1654
|
minCompressRange: override.minCompressRange ?? base.minCompressRange
|
|
1686
1655
|
};
|
|
@@ -4596,7 +4565,7 @@ ${output}`);
|
|
|
4596
4565
|
}
|
|
4597
4566
|
|
|
4598
4567
|
// lib/compress/message.ts
|
|
4599
|
-
function buildSchema(
|
|
4568
|
+
function buildSchema() {
|
|
4600
4569
|
return {
|
|
4601
4570
|
topic: tool2.schema.string().describe(
|
|
4602
4571
|
"Short label (3-5 words) for the overall batch - e.g., 'Closed Research Notes'"
|
|
@@ -4606,10 +4575,11 @@ function buildSchema(maxSummaryLength) {
|
|
|
4606
4575
|
messageId: tool2.schema.string().describe("Raw message ID to compress (e.g. m00001)"),
|
|
4607
4576
|
topic: tool2.schema.string().describe("Short label (3-5 words) for this one message summary"),
|
|
4608
4577
|
summary: tool2.schema.string().describe(
|
|
4609
|
-
|
|
4578
|
+
"Complete technical summary replacing that one message. Keep only essential details (conclusions, file paths, decisions, exact values, etc.)."
|
|
4610
4579
|
)
|
|
4611
4580
|
})
|
|
4612
|
-
).describe("Batch of individual message summaries to create in one tool call")
|
|
4581
|
+
).describe("Batch of individual message summaries to create in one tool call"),
|
|
4582
|
+
summaryMaxChars: tool2.schema.number().optional().describe("Override max summary length (default max: 4000 chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.")
|
|
4613
4583
|
};
|
|
4614
4584
|
}
|
|
4615
4585
|
function createCompressMessageTool(ctx) {
|
|
@@ -4617,15 +4587,18 @@ function createCompressMessageTool(ctx) {
|
|
|
4617
4587
|
const runtimePrompts = ctx.prompts.getRuntimePrompts();
|
|
4618
4588
|
return tool2({
|
|
4619
4589
|
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
|
|
4620
|
-
args: buildSchema(
|
|
4590
|
+
args: buildSchema(),
|
|
4621
4591
|
async execute(args, toolCtx) {
|
|
4622
4592
|
const input = args;
|
|
4623
4593
|
validateArgs(input);
|
|
4624
|
-
const
|
|
4594
|
+
const maxLen = args.summaryMaxChars ?? ctx.config.compress.maxSummaryLengthHard;
|
|
4625
4595
|
for (const entry of input.content) {
|
|
4626
|
-
if (entry.summary.length >
|
|
4596
|
+
if (entry.summary.length > maxLen) {
|
|
4627
4597
|
throw new Error(
|
|
4628
|
-
`Summary too long (${entry.summary.length} chars
|
|
4598
|
+
`Summary too long (${entry.summary.length} chars, max ${maxLen}).
|
|
4599
|
+
1. If this summary is nearly the same size as the original content, it may not be worth compressing \u2014 skip it.
|
|
4600
|
+
2. Strip noise (failed attempts, verbose outputs) but keep project-critical details (file paths, decisions, exact values).
|
|
4601
|
+
3. For important content needing detail, pass summaryMaxChars to increase the limit \u2014 don't lose critical info just to fit. Example: add "summaryMaxChars": 6000 to the tool call args.`
|
|
4629
4602
|
);
|
|
4630
4603
|
}
|
|
4631
4604
|
}
|
|
@@ -4870,7 +4843,7 @@ function appendMissingBlockSummaries(summary, _missingBlockIds, _summaryByBlockI
|
|
|
4870
4843
|
}
|
|
4871
4844
|
|
|
4872
4845
|
// lib/compress/range.ts
|
|
4873
|
-
function buildSchema2(
|
|
4846
|
+
function buildSchema2() {
|
|
4874
4847
|
return {
|
|
4875
4848
|
topic: tool3.schema.string().describe("Short label (3-5 words) for display - e.g., 'Auth System Exploration'"),
|
|
4876
4849
|
content: tool3.schema.array(
|
|
@@ -4880,12 +4853,13 @@ function buildSchema2(maxSummaryLength) {
|
|
|
4880
4853
|
),
|
|
4881
4854
|
endId: tool3.schema.string().describe("Message or block ID marking the end of range (e.g. m00012, b5)"),
|
|
4882
4855
|
summary: tool3.schema.string().describe(
|
|
4883
|
-
|
|
4856
|
+
"Complete technical summary replacing all content in range. Keep only essential details (conclusions, file paths, decisions, exact values, etc.)."
|
|
4884
4857
|
)
|
|
4885
4858
|
})
|
|
4886
4859
|
).describe(
|
|
4887
4860
|
"One or more ranges to compress, each with start/end boundaries and a summary"
|
|
4888
|
-
)
|
|
4861
|
+
),
|
|
4862
|
+
summaryMaxChars: tool3.schema.number().optional().describe("Override max summary length (default max: 4000 chars). Use when content is important and needs more detail \u2014 don't lose critical info just to fit the limit.")
|
|
4889
4863
|
};
|
|
4890
4864
|
}
|
|
4891
4865
|
function createCompressRangeTool(ctx) {
|
|
@@ -4893,15 +4867,18 @@ function createCompressRangeTool(ctx) {
|
|
|
4893
4867
|
const runtimePrompts = ctx.prompts.getRuntimePrompts();
|
|
4894
4868
|
return tool3({
|
|
4895
4869
|
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
|
|
4896
|
-
args: buildSchema2(
|
|
4870
|
+
args: buildSchema2(),
|
|
4897
4871
|
async execute(args, toolCtx) {
|
|
4898
4872
|
const input = args;
|
|
4899
4873
|
validateArgs2(input);
|
|
4900
|
-
const
|
|
4874
|
+
const maxLen = args.summaryMaxChars ?? ctx.config.compress.maxSummaryLengthHard;
|
|
4901
4875
|
for (const entry of input.content) {
|
|
4902
|
-
if (entry.summary.length >
|
|
4876
|
+
if (entry.summary.length > maxLen) {
|
|
4903
4877
|
throw new Error(
|
|
4904
|
-
`Summary too long (${entry.summary.length} chars
|
|
4878
|
+
`Summary too long (${entry.summary.length} chars, max ${maxLen}).
|
|
4879
|
+
1. If this summary is nearly the same size as the original content, it may not be worth compressing \u2014 skip it.
|
|
4880
|
+
2. Strip noise (failed attempts, verbose outputs) but keep project-critical details (file paths, decisions, exact values).
|
|
4881
|
+
3. For important content needing detail, pass summaryMaxChars to increase the limit \u2014 don't lose critical info just to fit. Example: add "summaryMaxChars": 6000 to the tool call args.`
|
|
4905
4882
|
);
|
|
4906
4883
|
}
|
|
4907
4884
|
}
|
|
@@ -5067,10 +5044,10 @@ var MERGED_SUMMARY_FOOTER = `
|
|
|
5067
5044
|
[End ACP compressed context summary]
|
|
5068
5045
|
|
|
5069
5046
|
`;
|
|
5070
|
-
var DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/dcp-message-id>)/g;
|
|
5071
|
-
var DCP_MESSAGE_REF_TAG_REGEX = /<dcp-message-id>m\d+<\/dcp-message-id>/g;
|
|
5072
|
-
var DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi;
|
|
5073
|
-
var DCP_UNPAIRED_TAG_REGEX = /<\/?dcp[^>]*>/gi;
|
|
5047
|
+
var DCP_BLOCK_ID_TAG_REGEX = /(<dcp-message-id(?=[\s>])[^>]*>)b\d+(<\/(?:dcp|acp)-message-id>)/g;
|
|
5048
|
+
var DCP_MESSAGE_REF_TAG_REGEX = /<dcp-message-id>m\d+<\/(?:dcp|acp)-message-id>/g;
|
|
5049
|
+
var DCP_PAIRED_TAG_REGEX = /<dcp[^>]*>[\s\S]*?<\/(?:dcp|acp)[^>]*>/gi;
|
|
5050
|
+
var DCP_UNPAIRED_TAG_REGEX = /<\/?(?:dcp|acp)[^>]*>/gi;
|
|
5074
5051
|
var generateStableId = (prefix, seed) => {
|
|
5075
5052
|
const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH);
|
|
5076
5053
|
return `${prefix}_${hash}`;
|
|
@@ -5528,7 +5505,11 @@ var syncCompressPermissionState = (state, config, hostPermissions, messages) =>
|
|
|
5528
5505
|
// lib/prompts/extensions/nudge.ts
|
|
5529
5506
|
function buildCompressedBlockGuidance(state, gcConfig, context) {
|
|
5530
5507
|
const activeBlockIds = Array.from(state.prune.messages.activeBlockIds).filter((id) => Number.isInteger(id) && id > 0).sort((a, b) => a - b);
|
|
5531
|
-
const refs = activeBlockIds.map((id) =>
|
|
5508
|
+
const refs = activeBlockIds.map((id) => {
|
|
5509
|
+
const block = state.prune.messages.blocksById.get(id);
|
|
5510
|
+
const tokens = block?.summaryTokens ?? 0;
|
|
5511
|
+
return `b${id}${tokens > 0 ? ` (${tokens}t)` : ""}`;
|
|
5512
|
+
});
|
|
5532
5513
|
const blockCount = refs.length;
|
|
5533
5514
|
let blockList;
|
|
5534
5515
|
if (blockCount <= 20) {
|
|
@@ -5539,12 +5520,10 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
|
|
|
5539
5520
|
}
|
|
5540
5521
|
const includeHint = context?.includeHint ?? true;
|
|
5541
5522
|
const lines = [
|
|
5542
|
-
|
|
5543
|
-
`- Active compressed blocks: ${blockCount} (${blockList})`,
|
|
5544
|
-
"- System auto-detects blocks in range \u2014 no need to manually list (bN) placeholders. Just write a short prose summary."
|
|
5523
|
+
`- Compressed blocks: ${blockCount} (${blockList})`
|
|
5545
5524
|
];
|
|
5546
5525
|
if (includeHint) {
|
|
5547
|
-
lines.push("- \u{1F4A1}
|
|
5526
|
+
lines.push("- \u{1F4A1} Tools: compress, decompress, search_context.");
|
|
5548
5527
|
}
|
|
5549
5528
|
if (blockCount > 50) {
|
|
5550
5529
|
const oldBlockIds = activeBlockIds.slice(0, Math.max(0, blockCount - 20));
|
|
@@ -5574,8 +5553,6 @@ function buildCompressedBlockGuidance(state, gcConfig, context) {
|
|
|
5574
5553
|
lines.push(...targets);
|
|
5575
5554
|
lines.push(` System auto-detects blocks in range \u2014 no need to manually list (bN) placeholders. Just write a short prose summary.`);
|
|
5576
5555
|
}
|
|
5577
|
-
} else {
|
|
5578
|
-
lines.push(`- \u{1F500} You have ${blockCount} blocks \u2014 use compress to consolidate adjacent same-topic blocks.`);
|
|
5579
5556
|
}
|
|
5580
5557
|
}
|
|
5581
5558
|
const usageRatio = context?.currentTokens && context?.modelContextLimit ? context.currentTokens / context.modelContextLimit : 0;
|
|
@@ -5911,41 +5888,15 @@ function applyMessageModeAnchoredNudge(anchorMessageIds, messages, baseNudgeText
|
|
|
5911
5888
|
injectAnchoredNudge(message, nudgeText);
|
|
5912
5889
|
}
|
|
5913
5890
|
}
|
|
5914
|
-
function
|
|
5915
|
-
if (threshold === void 0) return void 0;
|
|
5916
|
-
if (typeof threshold === "number") {
|
|
5917
|
-
if (!modelContextLimit) return void 0;
|
|
5918
|
-
return threshold / modelContextLimit * 100;
|
|
5919
|
-
}
|
|
5920
|
-
const parsed = parseFloat(threshold);
|
|
5921
|
-
return isNaN(parsed) ? void 0 : parsed;
|
|
5922
|
-
}
|
|
5923
|
-
function buildContextUsageGuidance(config, currentTokens, modelContextLimit, minimal = false) {
|
|
5891
|
+
function buildContextUsageGuidance(config, currentTokens, modelContextLimit) {
|
|
5924
5892
|
if (currentTokens === void 0 || modelContextLimit === void 0 || modelContextLimit === 0) {
|
|
5925
5893
|
return "";
|
|
5926
5894
|
}
|
|
5927
|
-
const pct = currentTokens / modelContextLimit * 100;
|
|
5928
|
-
const percentage = pct.toFixed(1);
|
|
5929
5895
|
const formatK = (n) => n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n);
|
|
5930
|
-
const minPct = resolveThresholdPercent(config.compress.minContextLimit, modelContextLimit) ?? 45;
|
|
5931
|
-
const maxPct = resolveThresholdPercent(config.compress.maxContextLimit, modelContextLimit) ?? 55;
|
|
5932
|
-
const base = `Context usage: ${formatK(currentTokens)} / ${formatK(modelContextLimit)} tokens (${percentage}%).`;
|
|
5933
|
-
if (minimal) {
|
|
5934
|
-
return `
|
|
5935
|
-
|
|
5936
|
-
${base}`;
|
|
5937
|
-
}
|
|
5938
|
-
let guidance;
|
|
5939
|
-
if (pct < minPct) {
|
|
5940
|
-
guidance = " \u{1F4A1} Be frugal with context. If any visible tool output exceeds 5000 characters and you've finished reading it, compress it into a summary now \u2014 don't keep large outputs 'just in case'. You can decompress later if needed.";
|
|
5941
|
-
} else if (pct < maxPct) {
|
|
5942
|
-
guidance = " \u26A0\uFE0F Context is growing \u2014 compress completed sections and high-token waste now.";
|
|
5943
|
-
} else {
|
|
5944
|
-
guidance = " \u{1F525} Context is high \u2014 compress aggressively, preserve only what is essential.";
|
|
5945
|
-
}
|
|
5946
5896
|
return `
|
|
5947
5897
|
|
|
5948
|
-
${
|
|
5898
|
+
Context: ${formatK(currentTokens)} tokens.
|
|
5899
|
+
All compression serves the primary task, but be frugal. Context capacity is precious \u2014 compress waste promptly. Save context by compressing consumed outputs, not by avoiding tools. Compress by need, not by percentage.`;
|
|
5949
5900
|
}
|
|
5950
5901
|
function applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage) {
|
|
5951
5902
|
const contextUsageInfo = buildContextUsageGuidance(config, currentTokens, modelContextLimit);
|
|
@@ -6039,18 +5990,6 @@ function createSuffixMessage(messages) {
|
|
|
6039
5990
|
messages.push(synthetic);
|
|
6040
5991
|
return synthetic;
|
|
6041
5992
|
}
|
|
6042
|
-
function shouldInjectPerMessageNudge(state, config, currentTokens, modelContextLimit) {
|
|
6043
|
-
const turn = state.currentTurn ?? 0;
|
|
6044
|
-
const lastTurn = state.nudges.lastPerMessageNudgeTurn ?? 0;
|
|
6045
|
-
const turnsSinceLast = turn - lastTurn;
|
|
6046
|
-
const tokens = currentTokens ?? 0;
|
|
6047
|
-
const lastTokens = state.nudges.lastPerMessageNudgeTokens ?? 0;
|
|
6048
|
-
const tokenGrowth = tokens - lastTokens;
|
|
6049
|
-
const tokenGrowthPercent = modelContextLimit ? tokenGrowth / modelContextLimit * 100 : 0;
|
|
6050
|
-
const frequency = config.compress.nudgeFrequency ?? 5;
|
|
6051
|
-
const growthThreshold = config.compress.perMessageNudgeGrowthPercent ?? 3;
|
|
6052
|
-
return turnsSinceLast >= frequency || tokenGrowthPercent >= growthThreshold;
|
|
6053
|
-
}
|
|
6054
5993
|
var injectCompressNudges = (state, config, logger, messages, prompts, compressionPriorities) => {
|
|
6055
5994
|
if (compressPermission(state, config) === "deny") {
|
|
6056
5995
|
return;
|
|
@@ -6064,6 +6003,7 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
|
|
|
6064
6003
|
state.nudges.contextLimitAnchors.clear();
|
|
6065
6004
|
state.nudges.turnNudgeAnchors.clear();
|
|
6066
6005
|
state.nudges.iterationNudgeAnchors.clear();
|
|
6006
|
+
state.nudges.lastPerMessageNudgeTokens = 0;
|
|
6067
6007
|
void saveSessionState(state, logger);
|
|
6068
6008
|
return;
|
|
6069
6009
|
}
|
|
@@ -6135,8 +6075,24 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
|
|
|
6135
6075
|
}
|
|
6136
6076
|
const suffixMessage = createSuffixMessage(messages);
|
|
6137
6077
|
applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage);
|
|
6138
|
-
const
|
|
6139
|
-
|
|
6078
|
+
const contextPct = modelContextLimit && currentTokens ? currentTokens / modelContextLimit * 100 : 0;
|
|
6079
|
+
const minPercent = config.compress?.minNudgeContextPercent ?? 15;
|
|
6080
|
+
injectContextUsage(suffixMessage, config, currentTokens, modelContextLimit);
|
|
6081
|
+
let tipsText = null;
|
|
6082
|
+
if (overMaxLimit || overMinLimit) {
|
|
6083
|
+
const lastWarnPct = state.nudges.lastPerMessageNudgeTokens && modelContextLimit ? state.nudges.lastPerMessageNudgeTokens / modelContextLimit * 100 : 0;
|
|
6084
|
+
const growthSinceWarn = contextPct - lastWarnPct;
|
|
6085
|
+
if (lastWarnPct === 0 || growthSinceWarn >= 10) {
|
|
6086
|
+
tipsText = overMaxLimit ? '\n\n\u26A0\uFE0F Context limit reached \u2014 compress now. Prioritize consumed tool outputs.\n\n{ "topic": "...", "content": [{ "startId": "<ID>", "endId": "<ID>", "summary": "..." }] }\n\nOnly use IDs from visible messages above. Compress older work first.' : "\n\n\u26A0\uFE0F Context is growing \u2014 consider compressing older work. Tools: compress, decompress, search_context.";
|
|
6087
|
+
state.nudges.lastPerMessageNudgeTokens = currentTokens ?? 0;
|
|
6088
|
+
state.nudges.lastPerMessageNudgeTurn = state.currentTurn ?? 0;
|
|
6089
|
+
}
|
|
6090
|
+
} else if (contextPct >= minPercent) {
|
|
6091
|
+
tipsText = "\n\n\u{1F4A1} Tools: compress, decompress, search_context.";
|
|
6092
|
+
if (state.nudges.lastPerMessageNudgeTokens) {
|
|
6093
|
+
state.nudges.lastPerMessageNudgeTokens = 0;
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6140
6096
|
if (config.compress.mode !== "message") {
|
|
6141
6097
|
const visibleMessageIds = new Set(
|
|
6142
6098
|
messages.map((message) => message.info.id)
|
|
@@ -6144,26 +6100,29 @@ var injectCompressNudges = (state, config, logger, messages, prompts, compressio
|
|
|
6144
6100
|
const blockGuidance = buildCompressedBlockGuidance(state, config.gc, {
|
|
6145
6101
|
currentTokens,
|
|
6146
6102
|
modelContextLimit,
|
|
6147
|
-
includeHint:
|
|
6103
|
+
includeHint: tipsText !== null,
|
|
6148
6104
|
visibleMessageIds
|
|
6149
6105
|
});
|
|
6150
6106
|
if (blockGuidance.trim() && suffixMessage) {
|
|
6151
6107
|
appendToLastTextPart(suffixMessage, "\n\n" + blockGuidance);
|
|
6152
6108
|
}
|
|
6153
6109
|
}
|
|
6154
|
-
if (
|
|
6155
|
-
|
|
6156
|
-
state.nudges.lastPerMessageNudgeTokens = currentTokens ?? 0;
|
|
6110
|
+
if (tipsText && suffixMessage) {
|
|
6111
|
+
appendToLastTextPart(suffixMessage, tipsText);
|
|
6157
6112
|
}
|
|
6158
6113
|
injectVisibleIdRange(state, messages, suffixMessage);
|
|
6114
|
+
if (suffixMessage) {
|
|
6115
|
+
appendToLastTextPart(suffixMessage, "\n</acp-context>");
|
|
6116
|
+
}
|
|
6159
6117
|
if (anchorsChanged) {
|
|
6160
6118
|
void saveSessionState(state, logger);
|
|
6161
6119
|
}
|
|
6162
6120
|
};
|
|
6163
|
-
function injectContextUsage(target, config, currentTokens, modelContextLimit
|
|
6121
|
+
function injectContextUsage(target, config, currentTokens, modelContextLimit) {
|
|
6164
6122
|
if (!target) return;
|
|
6165
|
-
const
|
|
6166
|
-
if (!
|
|
6123
|
+
const rawUsage = buildContextUsageGuidance(config, currentTokens, modelContextLimit);
|
|
6124
|
+
if (!rawUsage) return;
|
|
6125
|
+
const usageTag = rawUsage;
|
|
6167
6126
|
for (const part of target.parts) {
|
|
6168
6127
|
if (part.type === "text") {
|
|
6169
6128
|
appendToTextPart(part, usageTag);
|
|
@@ -6187,7 +6146,7 @@ function injectVisibleIdRange(state, messages, target) {
|
|
|
6187
6146
|
const last = visibleRefs[visibleRefs.length - 1];
|
|
6188
6147
|
const rangeTag = `
|
|
6189
6148
|
|
|
6190
|
-
[Visible
|
|
6149
|
+
[Visible messages: ${first} to ${last} (${visibleRefs.length} messages)]`;
|
|
6191
6150
|
for (const part of target.parts) {
|
|
6192
6151
|
if (part.type === "text") {
|
|
6193
6152
|
appendToTextPart(part, rangeTag);
|
|
@@ -6611,7 +6570,8 @@ IMPORTANT:
|
|
|
6611
6570
|
- Do NOT call this tool in parallel with compress \u2014 their state mutations may conflict.`;
|
|
6612
6571
|
function buildSchema3() {
|
|
6613
6572
|
return {
|
|
6614
|
-
blockId: tool4.schema.string().describe('Block reference to decompress (e.g., "b0", "b2")')
|
|
6573
|
+
blockId: tool4.schema.string().describe('Block reference to decompress (e.g., "b0", "b2")'),
|
|
6574
|
+
toFile: tool4.schema.string().optional().describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Use read tool to access specific parts. Example: '/tmp/block52.txt'")
|
|
6615
6575
|
};
|
|
6616
6576
|
}
|
|
6617
6577
|
function createDecompressTool(ctx) {
|
|
@@ -6640,6 +6600,40 @@ function createDecompressTool(ctx) {
|
|
|
6640
6600
|
}
|
|
6641
6601
|
return `Error: Block ${target.displayId} is not active. It may have already been decompressed.`;
|
|
6642
6602
|
}
|
|
6603
|
+
if (args.toFile) {
|
|
6604
|
+
const targetPath = args.toFile;
|
|
6605
|
+
const os = await import("os");
|
|
6606
|
+
const path = await import("path");
|
|
6607
|
+
const allowedDirs = [
|
|
6608
|
+
os.tmpdir() + "/",
|
|
6609
|
+
path.join(os.homedir(), ".cache", "opencode") + "/"
|
|
6610
|
+
];
|
|
6611
|
+
const resolved = path.resolve(targetPath);
|
|
6612
|
+
const isAllowed = allowedDirs.some((dir) => {
|
|
6613
|
+
const rel = path.relative(dir, resolved);
|
|
6614
|
+
return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
6615
|
+
});
|
|
6616
|
+
if (!isAllowed) {
|
|
6617
|
+
return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}`;
|
|
6618
|
+
}
|
|
6619
|
+
const block = activeBlocks[0];
|
|
6620
|
+
const msgIds = new Set(block.effectiveMessageIds ?? []);
|
|
6621
|
+
const blockMessages = rawMessages.filter((m) => {
|
|
6622
|
+
const id = m.id ?? m.messageId ?? "";
|
|
6623
|
+
return msgIds.has(id);
|
|
6624
|
+
});
|
|
6625
|
+
const lines2 = blockMessages.map((m) => {
|
|
6626
|
+
const msg = m;
|
|
6627
|
+
const role = msg.role || msg.type || "unknown";
|
|
6628
|
+
const content = typeof msg.content === "string" ? msg.content : typeof msg.text === "string" ? msg.text : JSON.stringify(msg.content || msg.text || "");
|
|
6629
|
+
return `[${role}]
|
|
6630
|
+
${content}`;
|
|
6631
|
+
});
|
|
6632
|
+
const { writeFile: writeFile3 } = await import("fs/promises");
|
|
6633
|
+
const fileContent = lines2.length > 0 ? lines2.join("\n\n---\n\n") : block.summary ?? "(no content available)";
|
|
6634
|
+
await writeFile3(args.toFile, fileContent, "utf-8");
|
|
6635
|
+
return `Block b${target.displayId} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${args.toFile}. Block stays compressed \u2014 context unchanged. Use read tool to access specific parts.`;
|
|
6636
|
+
}
|
|
6643
6637
|
const activeMessagesBefore = snapshotActiveMessages(messagesState);
|
|
6644
6638
|
const activeBlockIdsBefore = new Set(messagesState.activeBlockIds);
|
|
6645
6639
|
deactivateCompressionTarget(messagesState, target);
|
|
@@ -6903,71 +6897,19 @@ You operate in a context-constrained environment. Context management helps prese
|
|
|
6903
6897
|
|
|
6904
6898
|
The tools you have for context management are \`compress\`, \`decompress\`, and \`search_context\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`search_context\` searches compressed block summaries (and visible messages) to locate relevant content before you decompress.
|
|
6905
6899
|
|
|
6906
|
-
\`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are
|
|
6900
|
+
\`<acp-context>\` tags wrap ACP (Agent Context Pruning) system metadata \u2014 context management information injected each turn. This is system data, not user input. You may also see \`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags \u2014 these are equivalent (DCP was the previous name for ACP).
|
|
6907
6901
|
|
|
6908
6902
|
COMPRESSION PHILOSOPHY
|
|
6909
6903
|
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
Target the largest UNCOMPRESSED content first. Savings scale with original size \u2014 compressing a 5000-token tool output frees far more than re-shrinking an already-summarized 300-token block.
|
|
6915
|
-
|
|
6916
|
-
CONTEXT PRESSURE LEVELS
|
|
6917
|
-
|
|
6918
|
-
- Normal: After completing a task or sub-task, compress its tool outputs (agent results, verbose commands, large tool outputs) into summaries. Do NOT compress content you're actively using for an ongoing task \u2014 wait until the task is complete. You can decompress later if needed.
|
|
6919
|
-
- Elevated: Context is growing \u2014 compress completed sections and high-token waste now.
|
|
6920
|
-
- Critical: Compress aggressively now \u2014 preserve only what is essential for the current task.
|
|
6921
|
-
|
|
6922
|
-
WHAT TO COMPRESS FIRST (high value, low risk)
|
|
6923
|
-
|
|
6924
|
-
- Agent/subagent review and consultation results: Prime compression targets when context pressure rises \u2014 the surrounding reasoning and tool-call chatter is typically the largest block of uncompressed content. Note: if the agent tool is in your protected list, its output is auto-preserved in the summary, so the savings come from the surrounding conversation, not the agent output itself. Compress once you have fully consumed the results (all recommended actions applied or recorded in files). Recover via \`decompress\` while the block is still active. Re-invoking the agent is a last resort \u2014 it is a fresh run, not a cache hit.
|
|
6925
|
-
- Verbose command output (build/test runs, git diff/log/status, publish logs, directory listings): Once you have read the result, compress. Keep only the verdict \u2014 pass/fail status, commit hash, version number, or count. For failures, keep the specific error messages and file/line references needed to act on them. The full output is reproducible by re-running the command.
|
|
6926
|
-
- Exploration that led nowhere (failed approaches, dead-end searches): Compress to a one-line note about what was tried and why it failed.
|
|
6927
|
-
- Redundant tool results (reading the same file multiple times, repeated status checks, exhausted search results): Keep only the most recent result.
|
|
6928
|
-
- Intermediate steps of completed multi-step tasks: Once the task is done, compress the process. Keep only the final outcome.
|
|
6929
|
-
- Resolved discussion threads (clarification rounds, negotiated requirements, design debate that reached a decision): Once a conclusion is recorded, compress the back-and-forth. Keep the decision and its rationale.
|
|
6930
|
-
- Large file contents that have already been used and are no longer needed: Compress to a summary of key functions, types, or patterns.
|
|
6931
|
-
|
|
6932
|
-
DO NOT RE-COMPRESS (low value, diminishing returns)
|
|
6933
|
-
|
|
6934
|
-
- Already-compressed block summaries: Re-compressing a summary into a shorter summary saves negligible tokens. If a block needs better detail, use \`decompress\` to restore it, then compress the original content properly. Exception: if a block-aging warning flags specific block IDs as facing GC truncation, re-summarize exactly those flagged blocks into a fresh range \u2014 this preserves detail that GC would otherwise destroy.
|
|
6935
|
-
- Short messages (1-3 sentences): The compression overhead (block metadata, summary structure) may exceed the tokens saved.
|
|
6936
|
-
- Content whose immediate use is complete \u2014 the task it supported is done and no open todo/plan references it. If still in active use, let it stay.
|
|
6937
|
-
- User instructions and requirements: These must remain visible until the task is complete.
|
|
6938
|
-
- Tool calls that are still pending or in-progress: Wait until the result is returned and consumed.
|
|
6939
|
-
|
|
6940
|
-
WHAT TO COMPRESS CAREFULLY (high risk - verify before compressing)
|
|
6941
|
-
|
|
6942
|
-
- Temporary secrets/keys/tokens needed later: Do NOT compress unless recorded elsewhere
|
|
6943
|
-
- File paths and directory structures: Keep in summary - losing these wastes tokens rediscovering them
|
|
6944
|
-
- Key function/method signatures and APIs: Summarize with exact names and signatures
|
|
6945
|
-
- Critical error messages and stack traces: Keep the error type and key detail in summary
|
|
6946
|
-
- User preferences and requirements: These must survive compression intact
|
|
6947
|
-
- Architectural decisions and rationale: Summarize the decision, not just the conclusion
|
|
6948
|
-
|
|
6949
|
-
BEFORE COMPRESSING IMPORTANT CONTENT
|
|
6950
|
-
|
|
6951
|
-
Verify the information is persisted in one of:
|
|
6952
|
-
- A file you have written or edited
|
|
6953
|
-
- An issue, PR, or devlog entry
|
|
6954
|
-
- The compression summary itself (include the critical bits explicitly)
|
|
6955
|
-
|
|
6956
|
-
If it is not persisted anywhere, either persist it first or include it explicitly in your compression summary.
|
|
6957
|
-
|
|
6958
|
-
AFTER COMPRESSING
|
|
6959
|
-
|
|
6960
|
-
Generate recovery breadcrumbs in your summary so future-you can reconstruct the context:
|
|
6961
|
-
- Reference specific files by path
|
|
6962
|
-
- Include key variable names, function signatures, or configuration values
|
|
6963
|
-
- Note what was decided and why, not just what was done
|
|
6964
|
-
- Example: "Implemented auth check in src/middleware.ts using validateToken() from auth.ts - user table is users not user"
|
|
6904
|
+
All compression serves the primary task, but be frugal. Two failure modes to avoid:
|
|
6905
|
+
- Over-compression: Compressing too aggressively loses critical details, decisions, and state needed for your task. This directly harms task quality.
|
|
6906
|
+
- Under-compression: Failing to compress verbose outputs causes context overflow, reducing accuracy and eventually blocking your work.
|
|
6965
6907
|
|
|
6966
|
-
|
|
6908
|
+
Balance is key. Compress selectively to keep context lean. But never compress content you're actively using for an ongoing task. Use \`search_context\` to find compressed content when needed, and \`decompress\` to restore details.
|
|
6967
6909
|
|
|
6968
|
-
|
|
6910
|
+
BE FRUGAL
|
|
6969
6911
|
|
|
6970
|
-
|
|
6912
|
+
Be frugal with context \u2014 compress obvious waste promptly. Examples include verbose command output (build/test logs, git diff/status, npm install), sub-agent results once consumed, experiment/training logs (keep final metrics only), duplicate file reads, and failed explorations. Any content that is finished serving the task and would not be needed in upcoming turns should be compressed \u2014 not just these examples.
|
|
6971
6913
|
`;
|
|
6972
6914
|
|
|
6973
6915
|
// lib/prompts/compress-range.ts
|
|
@@ -7230,7 +7172,7 @@ var PROMPT_DEFINITIONS = [
|
|
|
7230
7172
|
];
|
|
7231
7173
|
var HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
|
|
7232
7174
|
var LEGACY_INLINE_COMMENT_LINE_REGEX = /^[ \t]*\/\/.*?\/\/[ \t]*$/gm;
|
|
7233
|
-
var DCP_SYSTEM_REMINDER_TAG_REGEX = /^\s*<dcp-system-reminder\b[^>]*>[\s\S]*<\/dcp-system-reminder>\s*$/i;
|
|
7175
|
+
var DCP_SYSTEM_REMINDER_TAG_REGEX = /^\s*<dcp-system-reminder\b[^>]*>[\s\S]*<\/(?:dcp|acp)-system-reminder>\s*$/i;
|
|
7234
7176
|
var DEFAULTS_README_FILE = "README.md";
|
|
7235
7177
|
var BUNDLED_EDITABLE_PROMPTS = {
|
|
7236
7178
|
system: SYSTEM,
|
|
@@ -7309,7 +7251,7 @@ function stripConditionalTag(content, tagName) {
|
|
|
7309
7251
|
function unwrapDcpTagIfWrapped(content) {
|
|
7310
7252
|
const trimmed = content.trim();
|
|
7311
7253
|
if (DCP_SYSTEM_REMINDER_TAG_REGEX.test(trimmed)) {
|
|
7312
|
-
return trimmed.replace(/^\s*<dcp-system-reminder\b[^>]*>\s*/i, "").replace(/\s*<\/dcp-system-reminder>\s*$/i, "").trim();
|
|
7254
|
+
return trimmed.replace(/^\s*<dcp-system-reminder\b[^>]*>\s*/i, "").replace(/\s*<\/(?:dcp|acp)-system-reminder>\s*$/i, "").trim();
|
|
7313
7255
|
}
|
|
7314
7256
|
return trimmed;
|
|
7315
7257
|
}
|
|
@@ -7319,7 +7261,7 @@ function normalizeReminderPromptContent(content) {
|
|
|
7319
7261
|
return "";
|
|
7320
7262
|
}
|
|
7321
7263
|
const startsWrapped = /^\s*<dcp-system-reminder\b[^>]*>/i.test(normalized);
|
|
7322
|
-
const endsWrapped = /<\/dcp-system-reminder>\s*$/i.test(normalized);
|
|
7264
|
+
const endsWrapped = /<\/(?:dcp|acp)-system-reminder>\s*$/i.test(normalized);
|
|
7323
7265
|
if (startsWrapped !== endsWrapped) {
|
|
7324
7266
|
return "";
|
|
7325
7267
|
}
|