dsh-ebbing-context 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
Binary file
package/README.en.md ADDED
@@ -0,0 +1,100 @@
1
+ # Ebbing Context
2
+
3
+ [![test](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml/badge.svg)](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ [中文](README.md) | **English**
7
+
8
+ **A layered context-compaction plugin for DSH that simulates memory decay.**
9
+
10
+ The model decides where to compress and writes the summaries itself; the plugin only writes session events the host already understands, degrading old content step by step into coarser representations:
11
+
12
+ ```
13
+ raw text (verbatim) → L1 block (conclusions and data of a span) → L2 block → L3 block (keywords and sentences only)
14
+ ```
15
+
16
+ The older it is, the coarser it gets — but **nothing is ever deleted**. The raw text stays in the session log, and with the "session search" plugin installed it can be retrieved by event number.
17
+
18
+ `ebbing` means "fading away": whether to compress, and how far, is the model's own decision under context pressure — not a scheduled job.
19
+
20
+ > **Status**: feature-complete, all tests green, and compaction has run on a real machine. What is missing is a **reproducible verification record** — the maps, logs and before/after comparisons were not archived. Target host: DSH 0.2.1-rc.3 ~ 0.2.2-alpha.2. Back up your profile first and try it in a fresh session.
21
+ > Per-version changes: [CHANGELOG](CHANGELOG.md).
22
+
23
+ ---
24
+
25
+ ## How it works
26
+
27
+ - **Two tools + one command**: `context_map` (layer structure and usage), `compress` (perform one compaction), `/context-status` (read-only, same map).
28
+ - **Key name = the layer to compress**: `l0` compresses raw text → L1 blocks; `l1` compresses L1 → L2; `l2` compresses L2 → L3; `l3` folds the whole L3 region into one block (the only key without `to`).
29
+ - **`to` must be copied from the map**: `l0` takes a turn number (`T` + the real turn from the map); `l1`/`l2` take the 1-based block index within that region. A wrong value is rejected, and the rejection carries a refreshed map.
30
+ - **Cascade**: when compressing a deeper layer, every shallower layer that can still be compressed must be given in the same batch, otherwise the whole batch is rejected (the prefix cache is invalidated only once).
31
+ - **Zero ledger**: no database, no state files; layers are recomputed from the session surface every turn, and a block's identity is its first line `〔L1|T16–T19|ev 4121–4390〕title`.
32
+ - **Reminds, never acts**: when usage hits the line, grows a lot, or a layer exceeds its quota, it wakes the model at the end of the turn with a fresh map. It never compresses by itself and never pushes you toward `/compact`.
33
+
34
+ The full design (layer model, cascade, protection boundaries, configuration, host contracts, limitations) is in [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md) (currently in Chinese).
35
+
36
+ ---
37
+
38
+ ## Install
39
+
40
+ > Published on npm: `dsh plugin add dsh-ebbing-context` (or `npm install dsh-ebbing-context`). The two methods below still work.
41
+
42
+ **Method 1: from npm (recommended)**
43
+
44
+ ```bash
45
+ dsh plugin add dsh-ebbing-context
46
+ ```
47
+
48
+ **Method 2: from a source checkout**
49
+
50
+ ```bash
51
+ git clone https://github.com/Light-EI/dsh-ebbing-context.git
52
+ dsh plugin add ./dsh-ebbing-context
53
+ ```
54
+
55
+ **Method 3: mount into the profile manually**
56
+
57
+ 1. Put the repository contents into the profile's `node_modules/dsh-ebbing-context/`
58
+ 2. Add a `dsh-ebbing-context:` line to the profile's `cordis.patch.yml` (path rules are in [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md), section "directory and development")
59
+ 3. Restart DSH
60
+
61
+ After installing, confirm three things: the plugin card shows **running**, both `context_map` / `compress` tools are present, and `/context-status` renders the map.
62
+
63
+ **Uninstall** (`dsh plugin remove dsh-ebbing-context` leaves two more steps):
64
+
65
+ 1. Open the profile's `cordis.patch.yml` and delete the `dsh-ebbing-context` section (including any `config` you changed in the panel). If you leave it, every start prints a warning on stderr about a patch pointing at a missing entry — harmless, but it keeps printing.
66
+ 2. The log lives at `$DSH_HOME/logs/ebbing-context.log` (plus `.1` / `.2` when rotated). Uninstalling does not delete it; remove it manually once you no longer need it for troubleshooting.
67
+ **Recommended: turn off the host's built-in auto-compaction** (`compaction-basic`'s `auto`, triggered at 80% by default). Once it fires it re-flattens the layered summaries and detailed content is permanently lost. The plugin still compresses, just with a discount. Where to turn it off depends on your install method and preset.
68
+
69
+ ---
70
+
71
+ ## Configuration
72
+
73
+ 17 items (`triggerPct`, `keepTurns`, `pruneFirst`, `foldCallRecord`, `requireSessionQuery`, `reservedTokens`, `budget.*`, `segment.*`, `title.maxTokens`, `notice.*`), edited in the plugin panel; changes take effect only after you click "Save".
74
+
75
+ Defaults, meanings and rejection rules are in [docs/SPEC.md section 6](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md#6-配置17-项) (Chinese).
76
+
77
+ ---
78
+
79
+ ## Dependencies and boundaries
80
+
81
+ - Requires the host to provide `agent/pre-step`, `agent/turn-stopping`, `agent/followup` and the session-surface API.
82
+ - By default it requires a "session search" tool (`session_event_read` / `session_event_search`); if the raw text cannot be retrieved, compression is disabled.
83
+ - It only performs paired replacements or edits the body of a `tool/result`; it never deletes a single node.
84
+ - No network, no file reads, no telemetry.
85
+
86
+ ## Known limitations
87
+
88
+ - Parallel tool calls are not folded (and their results are not rewritten); the summary body stays in the context as an argument (the plugin may suggest sending it separately later).
89
+ - Folding happens only before the next request goes out; a call that misses that window is not folded, and its record stays as is.
90
+ - Images cannot be retrieved; they are counted by number only.
91
+ - If the turn outline is corrupt (the log is missing a `turn/start` or out of order), that turn reports a red error and is disabled.
92
+ - Region usage is self-estimated by the plugin (about 0.6 token per Chinese character, 0.3 per English character); it tracks the host's meter closely but can differ by a few percent.
93
+
94
+ ## License
95
+
96
+ MIT.
97
+
98
+ ## Contributing
99
+
100
+ Issues / PRs are welcome. Before you start, read the "host contracts" section of [docs/SPEC.md](https://github.com/Light-EI/dsh-ebbing-context/blob/main/docs/SPEC.md); those contracts are the precondition for correctness.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  [![test](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml/badge.svg)](https://github.com/Light-EI/dsh-ebbing-context/actions/workflows/test.yml)
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
 
6
+ **中文** | [English](README.en.md)
7
+
6
8
  **模拟记忆衰退的 DSH 分层上下文压缩插件。**
7
9
 
8
10
  模型自己决定压哪里、自己写摘要;插件只写宿主本来就认识的会话事件,把旧内容一层层降成更粗的表示:
@@ -24,7 +26,7 @@
24
26
 
25
27
  - **两个工具 + 一条命令**:`context_map`(分层结构与占用)、`compress`(执行一次压缩)、`/context-status`(只读,同地图)。
26
28
  - **键名 = 要压的那一层**:`l0` 压原文 → L1 块;`l1` 压 L1 → L2;`l2` 压 L2 → L3;`l3` 把整个 L3 区压成一块(唯一不写 `to` 的键)。
27
- - **`to` 只能从地图上抄**:`l0` 写轮号(`T19`),`l1`/`l2` 写该区从 1 起的块序号。写错就被拒,拒绝回执里带刷新后的地图。
29
+ - **`to` 只能从地图上抄**:`l0` 写轮号(`T` + 地图里的真实轮号),`l1`/`l2` 写该区从 1 起的块序号。写错就被拒,拒绝回执里带刷新后的地图。
28
30
  - **级联**:压深层时,比它浅且还能压的层必须同批给完,否则整批拒绝(只断一次前缀缓存)。
29
31
  - **零台账**:不建库、不写状态文件;层级每轮从会话表层现算,块的身份写在正文第一行 `〔L1|T16–T19|ev 4121–4390〕标题`。
30
32
  - **提醒但不代劳**:占用到线、又长了、某层超配额时,在本轮收尾把模型叫醒并附一张新地图。从不自动压缩,也不催 `/compact`。
package/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 浏览器侧:在「插件」面板的这个 bundle 页里画配置表单。
2
2
  // 保存走宿主给的 form.mutate(一次提交 = 一个 revision = 一次校验),写回 profile 的 cordis.patch.yml。
3
3
  // 这里不做任何读文件、不调用模型;只负责把可设置项画出来。
4
+ // 界面双语:中文为主、英文为辅,两种语言同时显示(不跟随宿主 locale)。
4
5
 
5
6
  window.__ModuleLoader__.load({
6
7
  id: 'dsh-ebbing-context',
@@ -10,101 +11,140 @@ window.__ModuleLoader__.load({
10
11
  const { useState, useEffect } = React
11
12
 
12
13
  const TEXT = {
13
- title: '上下文压缩',
14
- hint: '改完点最下面的「保存」;离开页面不保存。',
15
- loading: '正在读取配置…',
16
- unavailable: '当前客户端读不到这个插件的配置。',
17
- readonly: '当前 profile 不允许写入配置。',
18
- noForm: '这个插件没有声明可编辑的配置。',
19
- // 宿主侧插件没启动时,面板拿不到 form,页面会永远停在「正在读取配置…」。
20
- // 那种情况下必须有一句能指向原因的话,否则用户只看到一片空白。
21
- late: '一直停在这里,说明宿主侧的插件没有启动起来。DSH 日志里会有一条加载错误(搜 import),插件卡片的状态也会停在「未运行」而不是「运行中」。',
22
- save: '保存',
23
- saving: '保存中…',
24
- discard: '放弃修改',
25
- detail: '详情',
26
- hide: '收起',
27
- saved: '已保存。',
28
- nothing: '没有改动。',
29
- refused: '宿主拒绝了这次保存,改动没有生效。可能是值超出允许范围,或该字段不允许这样设置。',
30
- failed: '保存失败:',
31
- invalid: '这些值不合法,没有提交:',
32
- // 使用注意:宿主自带的自动压缩一旦触发,会把这里的分层摘要拍平。
33
- // 放在页面最上方、不折叠——它比任何一个配置项都更容易把人坑到。
34
- // 不写死"改哪个文件"(配置入口随安装方式与预设而变),但要说清"关的是什么",
35
- // 并直接给一句可以复制给助手/模型的话——用户不必自己去找配置文件。
36
- note: '使用注意:如果这套 DSH 开着自带的自动压缩(compaction-basic 的自动压缩,默认在上下文用到约 80% 时自己触发),建议关掉它。它一旦触发,会把这里的多层摘要重新拍平成一段总摘要,分层结构就没了——详细内容要从会话日志里按事件号翻回来,模型不会再看到这些块;插件本身不受影响、照常能压,但效果会打折扣。要关的是「自动压缩」这个行为(通常是一个 auto 之类的开关,或直接停用那个插件),不关也行、只是效果打折。具体在哪一行、怎么改取决于你的安装方式与预设,插件不会替你改宿主配置。不想自己找的话,可以把这句发给你的助手或模型:「帮我关掉 DeepSeek Harness 自带的自动压缩(compaction-basic 的 auto),改完告诉我你动了哪个文件、怎么改回去」,让它去查;也可以先问问它这个功能是做什么的再决定。',
14
+ zh: {
15
+ title: '上下文压缩',
16
+ hint: '改完点最下面的「保存」;离开页面不保存。',
17
+ loading: '正在读取配置…',
18
+ unavailable: '当前客户端读不到这个插件的配置。',
19
+ readonly: '当前 profile 不允许写入配置。',
20
+ noForm: '这个插件没有声明可编辑的配置。',
21
+ // 宿主侧插件没启动时,面板拿不到 form,页面会永远停在「正在读取配置…」。
22
+ // 那种情况下必须有一句能指向原因的话,否则用户只看到一片空白。
23
+ late: '一直停在这里,说明宿主侧的插件没有启动起来。DSH 日志里会有一条加载错误(搜 import),插件卡片的状态也会停在「未运行」而不是「运行中」。',
24
+ save: '保存',
25
+ saving: '保存中…',
26
+ discard: '放弃修改',
27
+ detail: '详情',
28
+ hide: '收起',
29
+ saved: '已保存。',
30
+ nothing: '没有改动。',
31
+ refused: '宿主拒绝了这次保存,改动没有生效。可能是值超出允许范围,或该字段不允许这样设置。',
32
+ failed: '保存失败:',
33
+ invalid: '这些值不合法,没有提交:',
34
+ // 使用注意:宿主自带的自动压缩一旦触发,会把这里的分层摘要拍平。
35
+ // 放在页面最上方、不折叠——它比任何一个配置项都更容易把人坑到。
36
+ // 不写死"改哪个文件"(配置入口随安装方式与预设而变),但要说清"关的是什么",
37
+ // 并直接给一句可以复制给助手/模型的话——用户不必自己去找配置文件。
38
+ note: '使用注意:如果这套 DSH 开着自带的自动压缩(compaction-basic 的自动压缩,默认在上下文用到约 80% 时自己触发),建议关掉它。它一旦触发,会把这里的多层摘要重新拍平成一段总摘要,分层结构就没了——详细内容要从会话日志里按事件号翻回来,模型不会再看到这些块;插件本身不受影响、照常能压,但效果会打折扣。要关的是「自动压缩」这个行为(通常是一个 auto 之类的开关,或直接停用那个插件),不关也行、只是效果打折。具体在哪一行、怎么改取决于你的安装方式与预设,插件不会替你改宿主配置。不想自己找的话,可以把这句发给你的助手或模型:「帮我关掉 DeepSeek Harness 自带的自动压缩(compaction-basic 的 auto),改完告诉我你动了哪个文件、怎么改回去」,让它去查;也可以先问问它这个功能是做什么的再决定。',
39
+ },
40
+ en: {
41
+ title: 'Context compaction',
42
+ hint: 'Click "Save" at the bottom when done; leaving this page discards changes.',
43
+ loading: 'Reading configuration…',
44
+ unavailable: 'This client cannot read this plugin\'s configuration.',
45
+ readonly: 'This profile does not allow configuration writes.',
46
+ noForm: 'This plugin declares no editable configuration.',
47
+ late: 'Stuck here means the host-side plugin did not start. The DSH log will contain a load error (search for "import"), and the plugin card will stay at "not running" instead of "running".',
48
+ save: 'Save',
49
+ saving: 'Saving…',
50
+ discard: 'Discard changes',
51
+ detail: 'Details',
52
+ hide: 'Hide',
53
+ saved: 'Saved.',
54
+ nothing: 'No changes.',
55
+ refused: 'The host rejected this save, so nothing was applied. A value may be out of the allowed range, or this field may not be settable this way.',
56
+ failed: 'Save failed: ',
57
+ invalid: 'These values were not submitted: ',
58
+ note: 'Note: if this DSH installation has the host\'s built-in auto-compaction enabled (compaction-basic\'s auto, triggered by default at about 80% of the context), turning it off is recommended. Once it fires it re-flattens these layered summaries into one big summary and the layered structure is gone — details would have to be read back from the session log by event number, and the model will not see these blocks any more. The plugin itself is unaffected and still compresses, just with a discount. What you want to turn off is the "auto-compaction" behavior (usually an auto switch, or simply disabling that plugin); leaving it on is fine too, just with the discount. Which line to change and how depends on your install method and preset — the plugin will not touch host configuration for you. If you would rather not hunt for it, send this to your assistant or model: "Turn off DeepSeek Harness\'s built-in auto-compaction (compaction-basic\'s auto), and tell me which file you changed and how to revert it", and let it look; you can also ask what the feature does first and then decide.',
59
+ },
37
60
  }
38
61
 
39
- // 每一项:分组、标签、路径、类型、范围、详情
62
+ // 每一项:分组、标签、路径、类型、范围、详情(中英各一份,行为完全一致)
40
63
  const FIELDS = [
41
64
  {
42
- group: '配额', label: 'L1 占比', path: ['budget', 'l1'], kind: 'number', step: 0.01, min: 0, max: 1,
65
+ group: '配额', groupEn: 'Quota', label: 'L1 占比', labelEn: 'L1 share', path: ['budget', 'l1'], kind: 'number', step: 0.01, min: 0, max: 1,
43
66
  detail: '第一层把「原文」折成块,整层合计最多占可用输入的比例。0.10 表示最多占 10%。三层合计默认 0.19(10% + 6% + 3%);调大后留给原文的空间会变小。',
67
+ detailEn: 'The first layer folds raw text into blocks; this caps the whole layer as a share of usable input. 0.10 means at most 10%. The three layers total 0.19 by default (10% + 6% + 3%); raising them leaves less room for raw text.',
44
68
  },
45
69
  {
46
- group: '配额', label: 'L2 占比', path: ['budget', 'l2'], kind: 'number', step: 0.01, min: 0, max: 1,
70
+ group: '配额', groupEn: 'Quota', label: 'L2 占比', labelEn: 'L2 share', path: ['budget', 'l2'], kind: 'number', step: 0.01, min: 0, max: 1,
47
71
  detail: '第二层把「L1 块」再折一次,整层合计最多占可用输入的比例。',
72
+ detailEn: 'The second layer folds L1 blocks again; this caps the whole layer as a share of usable input.',
48
73
  },
49
74
  {
50
- group: '配额', label: 'L3 占比', path: ['budget', 'l3'], kind: 'number', step: 0.01, min: 0, max: 1,
75
+ group: '配额', groupEn: 'Quota', label: 'L3 占比', labelEn: 'L3 share', path: ['budget', 'l3'], kind: 'number', step: 0.01, min: 0, max: 1,
51
76
  detail: '第三层只放关键词句,用来保住任务目标、约束和结论。整层合计最多占可用输入的比例。',
77
+ detailEn: 'The third layer keeps only keywords and key sentences, preserving task goals, constraints and conclusions; this caps the whole layer as a share of usable input.',
52
78
  },
53
79
  {
54
- group: '单块上限', label: 'L1 单块上限', path: ['segment', 'l1'], kind: 'number', step: 100, min: 64,
80
+ group: '单块上限', groupEn: 'Block limit', label: 'L1 单块上限', labelEn: 'L1 block limit', path: ['segment', 'l1'], kind: 'number', step: 100, min: 64,
55
81
  detail: '一个 L1 块正文最多多少 token(按等效 token 算:中文 1 字约 0.6、英文 1 字约 0.3)。超了这次调用会被整批拒绝,拒绝里给出实际值与上限;怎么调整由模型自己判断(换一段更小的范围再压),不要为了塞进上限删内容。',
82
+ detailEn: 'Maximum tokens of one L1 block body (equivalent-token basis: about 0.6 per Chinese character, 0.3 per English character). If exceeded, the whole call is rejected and the rejection reports the actual value and the limit; the model decides how to adjust (compress a smaller range), and must not delete content just to fit.',
56
83
  },
57
84
  {
58
- group: '单块上限', label: 'L2 单块上限', path: ['segment', 'l2'], kind: 'number', step: 100, min: 64,
85
+ group: '单块上限', groupEn: 'Block limit', label: 'L2 单块上限', labelEn: 'L2 block limit', path: ['segment', 'l2'], kind: 'number', step: 100, min: 64,
59
86
  detail: '一个 L2 块正文最多多少 token。超过同样整批拒绝,并给出实际值与上限。',
87
+ detailEn: 'Maximum tokens of one L2 block body. Exceeding it likewise rejects the whole batch and reports the actual value and the limit.',
60
88
  },
61
89
  {
62
- group: '单块上限', label: 'L3 单块上限', path: ['segment', 'l3'], kind: 'number', step: 100, min: 64,
90
+ group: '单块上限', groupEn: 'Block limit', label: 'L3 单块上限', labelEn: 'L3 block limit', path: ['segment', 'l3'], kind: 'number', step: 100, min: 64,
63
91
  detail: '一个 L3 块正文最多多少 token。压 l2 时新块追加在 L3 区末尾、旧 L3 块不动;压 l3 时整个 L3 区并成一块,旧块一起并进去——所以这一块的上限也决定了 L3 区最后一次压缩能留下多少。',
92
+ detailEn: 'Maximum tokens of one L3 block body. Compressing l2 appends a new block at the end of the L3 region (old L3 blocks stay); compressing l3 folds the whole L3 region into one block, old blocks included — so this limit also decides how much the final L3 compaction can keep.',
64
93
  },
65
94
  {
66
- group: '单块上限', label: '块标题上限', path: ['title', 'maxTokens'], kind: 'number', step: 5, min: 4,
95
+ group: '单块上限', groupEn: 'Block limit', label: '块标题上限', labelEn: 'Block title limit', path: ['title', 'maxTokens'], kind: 'number', step: 5, min: 4,
67
96
  detail: '每个块正文首行那个标题最多多少 token。标题是模型之后认块用的,太短会认错,太长浪费。',
97
+ detailEn: 'Maximum tokens of the title on the first line of a block. The model uses titles to recognize blocks later: too short and it misidentifies them, too long wastes tokens.',
68
98
  },
69
99
  {
70
- group: '提醒', label: '开启提醒', path: ['notice', 'enabled'], kind: 'boolean',
100
+ group: '提醒', groupEn: 'Reminders', label: '开启提醒', labelEn: 'Enable reminders', path: ['notice', 'enabled'], kind: 'boolean',
71
101
  detail: '提醒的总开关。开着时,插件在每一轮收尾(该叫醒时把模型叫醒)递一条提醒并附上当前地图,让模型自己决定压不压——它从不替模型压缩。关掉就完全不提醒,压缩本身不受影响。',
102
+ detailEn: 'Master switch for reminders. When on, at the end of each turn (when it is time) the plugin wakes the model with a reminder and the current map, and the model decides whether to compress — it never compresses on the model\'s behalf. Off means no reminders at all; compression itself is unaffected.',
72
103
  },
73
104
  {
74
- group: '提醒', label: '增长提醒阈值', path: ['notice', 'growthTokens'], kind: 'number', step: 1000, min: 0,
105
+ group: '提醒', groupEn: 'Reminders', label: '增长提醒阈值', labelEn: 'Growth reminder threshold', path: ['notice', 'growthTokens'], kind: 'number', step: 1000, min: 0,
75
106
  detail: '到线之前的另一条路:自上次提醒后又长了这么多 token 就提醒一次,即使总量还没到线。写 0 只关掉这一条(到线与某层超出设计占比仍会提醒)。长到这个数却一层都压不动时,门槛会自动砍到一半、基线前移,再长半个门槛就重新判一次。',
107
+ detailEn: 'A second path besides the usage line: remind once the session has grown by this many tokens since the last reminder, even before the total hits the line. 0 disables only this path (the usage line and per-layer quota still remind). If the threshold is reached but no layer can be compressed, the threshold is halved and the baseline moves forward, then another half-threshold of growth re-evaluates.',
76
108
  },
77
109
  {
78
- group: '提醒', label: '不压之后门槛重设到原额的百分比', path: ['notice', 'growthResetPct'], kind: 'number', step: 10, min: 0, max: 100,
110
+ group: '提醒', groupEn: 'Reminders', label: '不压之后门槛重设到原额的百分比', labelEn: 'Threshold reset after a declined reminder (%)', path: ['notice', 'growthResetPct'], kind: 'number', step: 10, min: 0, max: 100,
79
111
  detail: '增长提醒发出去之后,模型如果决定这一轮不压(内容还没做完、或者比较重要),下一个门槛就按原额的百分之多少重设。40(默认)= 原额 65000 的话,再长 26000 才重新触发;100 = 全额重攒;0 = 门槛降到 1 token,下一轮只要再长一点就重新触发。只影响「又长了」这条触发,不影响到线提醒。增长到量但一层都压不动时走另一条路:门槛固定降到一半、基线前移,再长半个门槛重新判一次,直到某次真压得动为止。',
112
+ detailEn: 'After a growth reminder goes out, if the model decides not to compress this turn (content unfinished or important), the next threshold is reset to this percentage of the original. 40 (default) = with an original threshold of 65000, another 26000 tokens are needed to trigger again; 100 = save the full amount again; 0 = the threshold drops to 1 token, so the next bit of growth triggers again. Only affects the "grew again" path, not the usage line. When growth has occurred but no layer can be compressed, another path applies: the threshold halves and the baseline moves forward, re-evaluated every half-threshold until a compaction actually succeeds.',
80
113
  },
81
114
  {
82
- group: '提醒', label: '子代理也参与', path: ['notice', 'notifySubagents'], kind: 'boolean',
115
+ group: '提醒', groupEn: 'Reminders', label: '子代理也参与', labelEn: 'Include subagents', path: ['notice', 'notifySubagents'], kind: 'boolean',
83
116
  detail: '开(默认):子代理按同一套规则被标记与叫醒。它也会碰到已压缩的表层,没有轮次标记就看不出「这块盖住了哪几轮」,压过的信息在它眼里等于丢了。关:子代理不叫醒、不写轮次标记、不报红字。',
117
+ detailEn: 'On (default): subagents are marked and woken by the same rules. They also see compressed surfaces; without turn markers they cannot tell which turns a block covers, so compressed information is effectively lost to them. Off: subagents are not woken, no turn markers are written, no red errors.',
84
118
  },
85
119
  {
86
- group: '提醒', label: '提醒触发占比', path: ['triggerPct'], kind: 'number', step: 0.01, min: 0.1, max: 0.99,
120
+ group: '提醒', groupEn: 'Reminders', label: '提醒触发占比', labelEn: 'Reminder trigger share', path: ['triggerPct'], kind: 'number', step: 0.01, min: 0.1, max: 0.99,
87
121
  detail: '那条线画在哪。0.75 表示用到「可用输入」的 75% 开始提醒。可用输入 = 上下文窗口 − 输出预留,不是裸窗口大小。只在越过这条线时提醒一次,停在线上不会每轮重复催;掉到线下(比如压完之后)再越线才会再提醒一次。',
122
+ detailEn: 'Where the line is drawn. 0.75 means start reminding at 75% of usable input. Usable input = context window − output reserve, not the raw window size. It reminds once when crossing the line; staying above it does not nag every turn; dropping below (e.g. after compaction) and crossing again reminds once more.',
88
123
  },
89
124
  {
90
- group: '行为', label: '原文区留住最后几轮', path: ['keepTurns'], kind: 'number', step: 1, min: 1, max: 50,
125
+ group: '行为', groupEn: 'Behavior', label: '原文区留住最后几轮', labelEn: 'Raw turns kept', path: ['keepTurns'], kind: 'number', step: 1, min: 1, max: 50,
91
126
  detail: '原文区必须留住的最后 N 轮:compress 的 l0 最多只写到「当前轮 − 1 − N」。当前轮本来就压不了;这条是给最近几轮留缓冲,免得刚压完就要按事件号取回原文。',
127
+ detailEn: 'The last N turns the raw region must keep: compress l0 can only write up to "current turn − 1 − N". The current turn can never be compressed; this buffer keeps the most recent turns intact so raw text does not have to be fetched back by event number right after a compaction.',
92
128
  },
93
129
  {
94
- group: '行为', label: '剪大工具返回', path: ['pruneFirst'], kind: 'boolean',
130
+ group: '行为', groupEn: 'Behavior', label: '剪大工具返回', labelEn: 'Prune large tool results', path: ['pruneFirst'], kind: 'boolean',
95
131
  detail: '把超大的工具返回剪短,别让它们白占上下文。宿主的 toolResultPruner 是全局服务:任何工具返回只要超过 8192 字符,就把中间挖掉、只留开头 4096 与结尾 1024,并插一句「[... tool result middle pruned ...]」,对所有工具一视同仁(本插件 compress 自己那条返回也一样)。它只动工具返回,不动模型写的摘要、也不动压缩块——块是 user/message,不是 tool/result,所以摘要剪不到;被剪的中间那段的原文仍在会话日志里,可按事件号取回。宿主原本把它当「压缩的前置步骤」:只有它的自动压缩要动手时才叫。关掉宿主自动压缩后,改由本插件在每次压缩前叫一次——不压缩就不剪,模型正在用的内容不会被提前改写。关掉这个开关就完全不剪,原文原样留着。',
132
+ detailEn: 'Shorten oversized tool results so they do not take up space for nothing. The host\'s toolResultPruner is a global service: any tool result over 8192 characters has its middle cut out, keeping the first 4096 and last 1024, with "[... tool result middle pruned ...]" inserted — for all tools alike (including this plugin\'s own compress return). It only touches tool results, never model-written summaries or compressed blocks (blocks are user/message, not tool/result, so summaries are never pruned); the pruned middle stays in the session log and is retrievable by event number. The host originally called it as a "pre-step of compaction", only when its own auto-compaction was about to act. With host auto-compaction off, this plugin calls it once before each compression — no compression, no pruning, so content the model is using is not rewritten early. Turn this switch off to disable pruning entirely; raw text stays as is.',
96
133
  },
97
134
  {
98
- group: '行为', label: '折叠压缩调用记录', path: ['foldCallRecord'], kind: 'boolean',
135
+ group: '行为', groupEn: 'Behavior', label: '折叠压缩调用记录', labelEn: 'Fold compaction call records', path: ['foldCallRecord'], kind: 'boolean',
99
136
  detail: '压缩成功后,把「这次调用 compress + 它的返回」一起折成一句短注(说明调过、成功了、压了什么)。摘要正文本来就已经写成顶部的块,留在调用参数里的那份是纯重复。宿主不允许改写助手消息本身,只删调用又会留下孤儿返回,所以这一对只能在下一次请求之前整体换成一句用户消息——代价是你看不到那次调用的完整返回。',
137
+ detailEn: 'After a successful compaction, fold "this compress call + its return" into one short note (saying it was called, succeeded, and what was compressed). The summary body is already at the top as a block, so the copy in the call arguments is pure duplication. The host does not allow rewriting assistant messages, and deleting only the call would leave an orphan result, so the pair is replaced as a whole with one user message before the next request — at the cost of not seeing that call\'s full return.',
100
138
  },
101
139
  {
102
- group: '行为', label: '要求可取回原文', path: ['requireSessionQuery'], kind: 'boolean',
140
+ group: '行为', groupEn: 'Behavior', label: '要求可取回原文', labelEn: 'Require raw-text retrieval', path: ['requireSessionQuery'], kind: 'boolean',
103
141
  detail: '它不改变压缩本身:开或关,compress 都照常工作。它只决定「没有取回工具时要不要停用」——开着时,一旦发现没装「会话搜索」,两个工具就停用并说明原因,提醒也不再叫醒,宁可上下文大着也不做取不回的事;关掉时照常压;能不能按事件号查回来,取决于这台机器装没装会话搜索。',
142
+ detailEn: 'Does not change compaction itself: on or off, compress works as usual. It only decides whether to disable when the retrieval tool is missing: when on, if "session search" is not installed, both tools are disabled with the reason, reminders stop, and the context is left large rather than compacting something that cannot be retrieved; when off, compaction proceeds as usual, and whether it can be looked up by event number depends on whether session search is installed on this machine.',
104
143
  },
105
144
  {
106
- group: '行为', label: '输出预留兜底', path: ['reservedTokens'], kind: 'number', step: 1000, min: 0,
145
+ group: '行为', groupEn: 'Behavior', label: '输出预留兜底', labelEn: 'Output reserve fallback', path: ['reservedTokens'], kind: 'number', step: 1000, min: 0,
107
146
  detail: '读不到宿主的输出预留值时,用它扣减可用窗口。0 表示不扣。注意:宿主能给出预留值时这一项不生效。',
147
+ detailEn: 'Used to subtract from the usable window when the host\'s output reserve value cannot be read. 0 means subtract nothing. Note: this has no effect when the host provides the reserve value.',
108
148
  },
109
149
  ]
110
150
 
@@ -128,7 +168,9 @@ window.__ModuleLoader__.load({
128
168
  borderTop: '1px solid color-mix(in srgb, currentColor 12%, transparent)',
129
169
  }
130
170
  const labelStyle = { fontSize: '13px', fontWeight: 600, lineHeight: '20px' }
171
+ const labelEnStyle = { fontSize: '12px', fontWeight: 400, opacity: 0.55 }
131
172
  const detailStyle = { fontSize: '12px', opacity: 0.7, marginTop: '6px', lineHeight: '18px', maxWidth: '640px' }
173
+ const detailEnStyle = { fontSize: '11px', opacity: 0.5, marginTop: '4px', lineHeight: '17px', maxWidth: '640px' }
132
174
  const inputStyle = {
133
175
  width: '104px',
134
176
  padding: '4px 8px',
@@ -141,6 +183,7 @@ window.__ModuleLoader__.load({
141
183
  outline: 'none',
142
184
  }
143
185
  const groupStyle = { fontSize: '12px', fontWeight: 600, opacity: 0.55, marginTop: '18px', marginBottom: '2px' }
186
+ const groupEnStyle = { fontWeight: 400, opacity: 0.75 }
144
187
  // 使用注意:常驻、不折叠、不和错误横幅抢位置——它讲的是"会让插件效果打折"的既有事实。
145
188
  const noteStyle = {
146
189
  fontSize: '12px',
@@ -153,6 +196,7 @@ window.__ModuleLoader__.load({
153
196
  background: 'color-mix(in srgb, currentColor 5%, transparent)',
154
197
  opacity: 0.85,
155
198
  }
199
+ const noteEnStyle = { fontSize: '11px', lineHeight: '17px', marginTop: '6px', opacity: 0.65 }
156
200
  const linkStyle = {
157
201
  fontSize: '12px', opacity: 0.7, background: 'none', border: 'none',
158
202
  padding: 0, marginLeft: '8px', cursor: 'pointer', color: 'inherit', textDecoration: 'underline',
@@ -177,6 +221,13 @@ window.__ModuleLoader__.load({
177
221
  border: '1px solid ' + (kind === 'error' ? 'color-mix(in srgb, #d33 45%, transparent)' : 'color-mix(in srgb, currentColor 18%, transparent)'),
178
222
  background: kind === 'error' ? 'color-mix(in srgb, #d33 10%, transparent)' : 'color-mix(in srgb, currentColor 5%, transparent)',
179
223
  })
224
+ const bannerEnStyle = { fontSize: '11px', lineHeight: '17px', marginTop: '3px', opacity: 0.7 }
225
+
226
+ // 两块文字(中文在上、英文在下)。用于长说明、按钮提示、错误横幅。
227
+ const biText = (zhText, enText, enStyle) => h('div', null,
228
+ h('div', null, zhText),
229
+ h('div', { style: { marginTop: '2px', ...(enStyle || { opacity: 0.6 }) } }, enText),
230
+ )
180
231
 
181
232
  function ConfigPage(props) {
182
233
  const { view, form } = props
@@ -202,14 +253,15 @@ window.__ModuleLoader__.load({
202
253
  // form 由「插件」面板的宿主侧拼出来({ state, mutate }),插件条目还没进配置目录时它是 undefined:
203
254
  // 那是"还没读到",不是"没有配置项",别骗用户说没声明。
204
255
  if (!form) {
205
- return h(
206
- 'div',
207
- { style: { fontSize: '13px', opacity: 0.7, lineHeight: 1.6 } },
208
- late ? TEXT.late : TEXT.loading,
209
- )
256
+ return h('div', { style: { fontSize: '13px', opacity: 0.7, lineHeight: 1.6 } },
257
+ biText(late ? TEXT.zh.late : TEXT.zh.loading, late ? TEXT.en.late : TEXT.en.loading))
258
+ }
259
+ if (st.status === 'loading') {
260
+ return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, biText(TEXT.zh.loading, TEXT.en.loading))
261
+ }
262
+ if (st.status === 'unavailable') {
263
+ return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, biText(TEXT.zh.unavailable, TEXT.en.unavailable))
210
264
  }
211
- if (st.status === 'loading') return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, TEXT.loading)
212
- if (st.status === 'unavailable') return h('div', { style: { fontSize: '13px', opacity: 0.7 } }, TEXT.unavailable)
213
265
 
214
266
  const value = st.value || {}
215
267
  const readonly = st.writable === false
@@ -243,7 +295,7 @@ window.__ModuleLoader__.load({
243
295
 
244
296
  async function save() {
245
297
  if (busy || readonly) return
246
- if (changed.length === 0) { setBanner({ kind: 'ok', text: TEXT.nothing }); return }
298
+ if (changed.length === 0) { setBanner({ kind: 'ok', zh: TEXT.zh.nothing, en: TEXT.en.nothing }); return }
247
299
 
248
300
  const bad = []
249
301
  const ops = []
@@ -254,14 +306,27 @@ window.__ModuleLoader__.load({
254
306
  continue
255
307
  }
256
308
  const num = Number(raw)
257
- if (raw === '' || !Number.isFinite(num)) { bad.push(item.label + '(不是数字)'); continue }
258
- if (item.min !== undefined && num < item.min) { bad.push(item.label + '(不能小于 ' + item.min + ')'); continue }
259
- if (item.max !== undefined && num > item.max) { bad.push(item.label + '(不能大于 ' + item.max + ')'); continue }
309
+ if (raw === '' || !Number.isFinite(num)) {
310
+ bad.push({ zh: item.label + '(不是数字)', en: item.labelEn + ' (not a number)' })
311
+ continue
312
+ }
313
+ if (item.min !== undefined && num < item.min) {
314
+ bad.push({ zh: item.label + '(不能小于 ' + item.min + ')', en: item.labelEn + ' (must be ≥ ' + item.min + ')' })
315
+ continue
316
+ }
317
+ if (item.max !== undefined && num > item.max) {
318
+ bad.push({ zh: item.label + '(不能大于 ' + item.max + ')', en: item.labelEn + ' (must be ≤ ' + item.max + ')' })
319
+ continue
320
+ }
260
321
  ops.push({ op: 'set', path: item.path, value: num })
261
322
  }
262
323
 
263
324
  if (bad.length > 0) {
264
- setBanner({ kind: 'error', text: TEXT.invalid + bad.join(';') })
325
+ setBanner({
326
+ kind: 'error',
327
+ zh: TEXT.zh.invalid + bad.map((b) => b.zh).join(';'),
328
+ en: TEXT.en.invalid + bad.map((b) => b.en).join('; '),
329
+ })
265
330
  return
266
331
  }
267
332
 
@@ -270,12 +335,13 @@ window.__ModuleLoader__.load({
270
335
  const ok = await form.mutate(ops, revision)
271
336
  if (ok) {
272
337
  setDraft({})
273
- setBanner({ kind: 'ok', text: TEXT.saved })
338
+ setBanner({ kind: 'ok', zh: TEXT.zh.saved, en: TEXT.en.saved })
274
339
  } else {
275
- setBanner({ kind: 'error', text: TEXT.refused })
340
+ setBanner({ kind: 'error', zh: TEXT.zh.refused, en: TEXT.en.refused })
276
341
  }
277
342
  } catch (err) {
278
- setBanner({ kind: 'error', text: TEXT.failed + (err && err.message ? err.message : String(err)) })
343
+ const msg = err && err.message ? err.message : String(err)
344
+ setBanner({ kind: 'error', zh: TEXT.zh.failed + msg, en: TEXT.en.failed + msg })
279
345
  } finally {
280
346
  setBusy(false)
281
347
  }
@@ -307,30 +373,39 @@ window.__ModuleLoader__.load({
307
373
  const groups = []
308
374
  for (const item of FIELDS) {
309
375
  if (groups.length === 0 || groups[groups.length - 1].name !== item.group) {
310
- groups.push({ name: item.group, items: [] })
376
+ groups.push({ name: item.group, nameEn: item.groupEn, items: [] })
311
377
  }
312
378
  groups[groups.length - 1].items.push(item)
313
379
  }
314
380
 
315
381
  return h('div', { style: { fontSize: '13px', color: 'inherit' } },
316
- h('div', { style: { fontSize: '14px', fontWeight: 600 } }, TEXT.title),
317
- h('div', { style: { ...detailStyle, marginTop: '2px', marginBottom: '8px' } }, readonly ? TEXT.readonly : TEXT.hint),
318
- h('div', { style: noteStyle }, TEXT.note),
319
- banner ? h('div', { style: bannerStyle(banner.kind) }, banner.text) : null,
382
+ h('div', { style: { fontSize: '14px', fontWeight: 600 } }, TEXT.zh.title),
383
+ h('div', { style: { fontSize: '12px', fontWeight: 500, opacity: 0.6, marginTop: '2px' } }, TEXT.en.title),
384
+ h('div', { style: { ...detailStyle, marginTop: '2px', marginBottom: '8px' } },
385
+ biText(readonly ? TEXT.zh.readonly : TEXT.zh.hint, readonly ? TEXT.en.readonly : TEXT.en.hint)),
386
+ h('div', { style: noteStyle },
387
+ h('div', null, TEXT.zh.note),
388
+ h('div', { style: noteEnStyle }, TEXT.en.note)),
389
+ banner ? h('div', { style: bannerStyle(banner.kind) },
390
+ h('div', null, banner.zh),
391
+ h('div', { style: bannerEnStyle }, banner.en)) : null,
320
392
  groups.map((g) => h('div', { key: g.name },
321
- h('div', { style: groupStyle }, g.name),
393
+ h('div', { style: groupStyle }, g.name, h('span', { style: groupEnStyle }, ' / ' + g.nameEn)),
322
394
  g.items.map((item) => {
323
395
  const k = keyOf(item)
324
396
  return h('div', { key: k },
325
397
  h('div', { style: rowStyle },
326
398
  h('div', null,
327
399
  h('span', { style: labelStyle }, item.label),
400
+ h('span', { style: labelEnStyle }, ' / ' + item.labelEn),
328
401
  h('button', {
329
402
  type: 'button',
330
403
  style: linkStyle,
331
404
  onClick: () => setOpen((prev) => ({ ...prev, [k]: !prev[k] })),
332
- }, open[k] ? TEXT.hide : TEXT.detail),
333
- open[k] ? h('div', { style: detailStyle }, item.detail) : null,
405
+ }, (open[k] ? TEXT.zh.hide : TEXT.zh.detail) + ' / ' + (open[k] ? TEXT.en.hide : TEXT.en.detail)),
406
+ open[k] ? h('div', null,
407
+ h('div', { style: detailStyle }, item.detail),
408
+ h('div', { style: detailEnStyle }, item.detailEn)) : null,
334
409
  ),
335
410
  control(item),
336
411
  ),
@@ -343,15 +418,15 @@ window.__ModuleLoader__.load({
343
418
  style: buttonStyle(true, readonly || busy),
344
419
  disabled: readonly || busy,
345
420
  onClick: save,
346
- }, busy ? TEXT.saving : TEXT.save),
421
+ }, (busy ? TEXT.zh.saving : TEXT.zh.save) + ' / ' + (busy ? TEXT.en.saving : TEXT.en.save)),
347
422
  h('button', {
348
423
  type: 'button',
349
424
  style: buttonStyle(false, readonly || busy || changed.length === 0),
350
425
  disabled: readonly || busy || changed.length === 0,
351
426
  onClick: () => { setDraft({}); setBanner(null) },
352
- }, TEXT.discard),
427
+ }, TEXT.zh.discard + ' / ' + TEXT.en.discard),
353
428
  h('span', { style: { fontSize: '12px', opacity: 0.6, alignSelf: 'center' } },
354
- changed.length > 0 ? '待保存 ' + changed.length + ' 项' : ''),
429
+ changed.length > 0 ? '待保存 ' + changed.length + ' 项 / ' + changed.length + ' unsaved' : ''),
355
430
  ),
356
431
  )
357
432
  }
package/index.js CHANGED
@@ -140,7 +140,7 @@ export function receiptLines(results, extras = {}) {
140
140
  lines.push(`这次切点跨过了轮次记录里缺失的 ${swallowed.map((t) => `T${t}`).join('、')}——它们的内容已经一起压进块里,块头标签只写到 to 那一轮。`)
141
141
  }
142
142
  if (extras.mapText) lines.push(extras.mapText)
143
- // 收手提醒放在地图之后:否则"可写 to=…"紧跟在"不要继续压缩"后面,两句话会打架。
143
+ // 收手提醒放在地图之后:否则"可写 to 的轮号/序号"紧跟在"不要继续压缩"后面,两句话会打架。
144
144
  lines.push(RECEIPT_STOP_LINE)
145
145
  return lines
146
146
  }
@@ -1284,7 +1284,7 @@ function noticeText(ctx, config, record, count, log) {
1284
1284
  // 让"压完就收手"在真正决定压哪几轮的位置上生效,而不是只出现在提醒正文的说明段。
1285
1285
  parts.join('') +
1286
1286
  (mapText
1287
- ? '决定要压时:照下面给的位置,一次调用把还能压的层一起给完(级联与填表顺序见 compress 的说明)。' +
1287
+ ? '决定要压时:照下面给的位置,一次调用把要压的层一起给全(深浅同批,区间自己权衡;级联与填表顺序见 compress 的说明)。' +
1288
1288
  '没有可压位置就直接告诉用户这一版压完了,并说明宿主自带压缩会把分层拍平、要不要用由用户决定。'
1289
1289
  : '决定要压时:这次没能生成地图(插件内部出错,先别压,按原计划继续),别凭记忆压——先调一次 context_map 拿到位置再动手;' +
1290
1290
  '如果连 context_map 也读不到,就照实告诉用户这一版没法压,别自己猜轮号。') +
@@ -1385,6 +1385,7 @@ function compressTool(ctx, config, log, foldQueues, reminded) {
1385
1385
  'l0 的产物是 L1 块,l1 的产物是 L2 块,l2 的产物是 L3 块(落在 L3 区尾部,旧 L3 块原样不动),l3 把整个 L3 区压成一块(唯一不写 to 的键)。' +
1386
1386
  '压缩是自下往上长的:哪一层还是空的,就不能压它上面那一层。' +
1387
1387
  '压哪个键,就必须同批把比它浅、且还能压的层一起压掉,只动深层会整批被拒(某层不足 2 块、或只剩被保护的最后几轮时不用给)。' +
1388
+ 'to 只写一个锚点:写最小的号 = 只压最老的一段;写最大的号 = 一次把可压段整段压掉(可压段只是"已结束、不在保护圈",不代表里面的事都做完了),通常不是默认选择——要压就压已经完成的部分,压到第一个还没收尾的轮次之前就停。' +
1388
1389
  '别压还没做完的轮次:那一轮里的事还在进行(目标还没收尾、todo 没打完、还在验证、等你拍板)就先留着——压掉之后你只剩自己写的那几行摘要。' +
1389
1390
  '填表按时间线顺序,最老的一层先写:l3 → l2 → l1 → l0。' +
1390
1391
  '一次调用可以给多个键,一起给只断一次前缀缓存,所以攒够了一次压。' +
@@ -1397,11 +1398,11 @@ function compressTool(ctx, config, log, foldQueues, reminded) {
1397
1398
  properties: {
1398
1399
  l3: blockSpec('把整个 L3 区压成一块(不写 to)', false, titleLimit, true),
1399
1400
  l2: blockSpec('压 L2 区最老的一块或几块,到 to 指定的那块为止', true, titleLimit, false,
1400
- '写并到第几个块:1 = 该区最老的块,最多到「块数 − 1」。只能从地图里抄。'),
1401
+ '写并到第几个块:1 = 该区最老的块,最多到「块数 − 1」。从地图「可压位置」里抄一个序号,不能写成区间。'),
1401
1402
  l1: blockSpec('压 L1 区最老的一块或几块,到 to 指定的那块为止', true, titleLimit, false,
1402
- '写并到第几个块:1 = 该区最老的块,最多到「块数 − 1」。只能从地图里抄。'),
1403
+ '写并到第几个块:1 = 该区最老的块,最多到「块数 − 1」。从地图「可压位置」里抄一个序号,不能写成区间。'),
1403
1404
  l0: blockSpec('压原文区最旧的一段', true, titleLimit, false,
1404
- '写到哪一轮为止:如 T19(压到那轮结束,不会切到轮中);只能写已经结束、且不在 keepTurns 里的轮;只认地图里的 T 号。'),
1405
+ '写到哪一轮为止:从地图「可压位置」里抄一个 T 号(只抄一个,不能写成区间;压到那轮结束,不会切到轮中);只能写已经结束、且不在 keepTurns 里的轮。'),
1405
1406
  },
1406
1407
  },
1407
1408
  output: textOutput(),
@@ -1447,7 +1448,10 @@ function compressTool(ctx, config, log, foldQueues, reminded) {
1447
1448
  // 代价(450–690 token/次)是设计上认过的账,不许拿"省 token"当理由改成紧凑版。
1448
1449
  const mapText = fullMap(ctx, agent, config, log)
1449
1450
  if (mapText) issueFor(ctx, agent, log)
1450
- return { text: `没能压缩:${result.reason}${landed}` + (mapText ? '\n\n' + mapText : '') }
1451
+ return {
1452
+ text: `没能压缩:${result.reason}${landed}` +
1453
+ (mapText ? '\n\n' + mapText : '\n\n(地图刷新失败:先调 context_map,再按它给的可压位置重试)'),
1454
+ }
1451
1455
  }
1452
1456
  log('compress-ok', { keys: result.results.map((r) => r.key), prunedChars: result.prunedChars })
1453
1457
  // 登记待折叠:真正的替换发生在下一次 pre-step(那时工具返回才存在)。
package/lib/blocks.js CHANGED
@@ -687,7 +687,7 @@ export function readZones(ctx, session, agent, log) {
687
687
  // 数字 -> 该区从 1 起的序号(l1 / l2 用;l3 不收)
688
688
  export function resolveTurnAnchor(zones, label, keepTurns = 0) {
689
689
  const m = /^T(\d+)$/.exec(String(label || '').trim())
690
- if (!m) return { ok: false, reason: '锚点写法不对:轮锚点要写成 T19 这种形式' }
690
+ if (!m) return { ok: false, reason: '锚点写法不对:轮号要写成 T 加数字(真实轮号从地图「可压位置」抄),不要只写数字' }
691
691
  const turn = Number(m[1])
692
692
  if (zones.turnStarts === null) {
693
693
  return {
package/lib/compress.js CHANGED
@@ -22,9 +22,9 @@ const KEYS = ['l3', 'l2', 'l1', 'l0']
22
22
  const FIELD_NAMES = ['title', 'summary', 'to']
23
23
 
24
24
  // 填表格式校验:格式错就一次性说清楚错在哪、该长什么样,别让模型猜。
25
- function checkShape(args) {
25
+ export function checkShape(args) {
26
26
  if (!args || typeof args !== 'object' || Array.isArray(args)) {
27
- return { ok: false, reason: '参数必须是对象,形如 {"l0":{"title":"…","summary":"…","to":"T19"},"l1":{"title":"…","summary":"…","to":"3"}}(l0 的 to 是轮号,l1/l2 的 to 是块序号,l3 不写 to)' }
27
+ return { ok: false, reason: '参数必须是对象,形如 {"l0":{"title":"…","summary":"…","to":"…"},"l1":{"title":"…","summary":"…","to":"…"}}(l0 的 to 是轮号、照地图「可压位置」抄,l1/l2 的 to 是块序号,l3 不写 to)' }
28
28
  }
29
29
  const unknown = Object.keys(args).filter((k) => !KEYS.includes(k))
30
30
  if (unknown.length) {
@@ -52,12 +52,19 @@ function checkShape(args) {
52
52
  if (to !== undefined && typeof to !== 'string') {
53
53
  return {
54
54
  ok: false,
55
- reason: `${key}.to 要写成字符串:l0 写 "T19" 这种轮号,l1/l2 写 "3" 这种块序号(都从地图抄);l3 不写 to`,
55
+ reason: `${key}.to 要写成字符串:l0 写轮号(照地图「可压位置」抄),l1/l2 写块序号(形如 3);l3 不写 to`,
56
56
  }
57
57
  }
58
58
  if (key === 'l3' && to !== undefined) return { ok: false, reason: 'l3 不写 to:它一次把整个 L3 区压成一块' }
59
59
  if (key !== 'l3' && to === undefined) {
60
- return { ok: false, reason: `${key} 必须写 to:${key === 'l0' ? '轮锚点 T19' : '该区从 1 起的块序号'},从地图里抄` }
60
+ return { ok: false, reason: `${key} 必须写 to:${key === 'l0' ? '轮号——照地图「可压位置」里的 T 号写' : '该区从 1 起的块序号——照地图「可压位置」抄'}` }
61
+ }
62
+ // 地图里「可写 to 的轮号:T99…T102」/「可写 to 的序号:1…6」是"可选范围"(写其中任意一个号),
63
+ // 不是让照抄整段区间。另一个会话的模型实测把区间整段抄进 to;这里提前给一句"应该写哪个"。
64
+ // 分隔符既认旧写法(–、~、至、到),也认地图现在用的省略号(…),以及连写两个的「……」。
65
+ const range = /^\s*[Tt]?(\d+)\s*[-–—~~至到…]{1,2}\s*[Tt]?(\d+)\s*$/.exec(String(to).trim())
66
+ if (range) {
67
+ return { ok: false, reason: `${key}.to 只写一个${key === 'l0' ? '轮号' : '块序号'},不要写区间「${String(to).trim()}」——这里应写 ${key === 'l0' ? 'T' : ''}${range[2]}` }
61
68
  }
62
69
  }
63
70
  return { ok: true }
package/lib/map.js CHANGED
@@ -104,14 +104,14 @@ export function renderMap(ctx, agent, config, usage = null) {
104
104
  }
105
105
 
106
106
  lines.push('')
107
- lines.push('可压位置(to 就从这里抄,别自己数)')
107
+ lines.push('可压位置(to 就从这里抄,别自己数;下面给的是可选范围,to 只写其中一个号,别照抄成区间)')
108
108
  if (zones.ambiguous) {
109
109
  lines.push(
110
110
  '这张地图只供查看:表层里有内容为空的步骤(宿主把它们跳过了),而表层上已经有压缩块,' +
111
111
  '块的位置推不出来。这一版整批拒绝压缩,免得把旧摘要再压成一份新的。别用宿主自带的压缩顶替(那会把分层拍平)'
112
112
  )
113
- // 下面每一行都是"可写 to=…",在这个状态下它们全是假话(compress 会整批拒绝),
114
- // 同一张地图里既说"压不了"又说"可写 to=T16"只会把模型骗去撞一次拒绝。直接收尾。
113
+ // 下面每一行都是"可写 to 的轮号/序号",在这个状态下它们全是假话(compress 会整批拒绝),
114
+ // 同一张地图里既说"压不了"又说"可写 to 的轮号:T16"只会把模型骗去撞一次拒绝。直接收尾。
115
115
  return { text: lines.join('\n'), zones }
116
116
  }
117
117
  const keepTurns = Math.max(0, Number(config.keepTurns) || 0)
@@ -162,7 +162,7 @@ export function renderMap(ctx, agent, config, usage = null) {
162
162
  ? `暂不可用:keepTurns=${keepTurns} 把现有轮次全保住了(当前才第 ${zones.currentTurn ?? '?'} 轮),可压范围是空的;把 keepTurns 调小或等轮次多了再压`
163
163
  : rawTurnList.length
164
164
  ? (worthCompressing(zones, 'l0', keepTurns)
165
- ? `可写 to=${writable === oldest ? `T${oldest}` : `T${oldest}–T${writable}`}(原文区最老的是 T${oldest},当前第 ${zones.currentTurn ?? '?'} 轮${kept};只压已经收尾、结果已落地的内容,还在推进或接下来还要用的留着);` +
165
+ ? `可写 to 的轮号:T${oldest}${writable > oldest ? `…T${writable}` : ''}(原文区最老的是 T${oldest},当前第 ${zones.currentTurn ?? '?'} 轮${kept};写哪个号就压到哪个号,to 只写一个号、别写成区间;只压已经收尾、结果已落地的内容,还在推进或接下来还要用的留着);` +
166
166
  `落在表层上的轮:${rawTurnShown}${rawTurnMore}。` +
167
167
  (missingInRange.length
168
168
  ? `缺的轮号 ${missingInRange.map((t) => `T${t}`).join('、')} 写不得——它们没有 ` +
@@ -172,8 +172,8 @@ export function renderMap(ctx, agent, config, usage = null) {
172
172
  ? (Number.isFinite(oldestCost) && oldestCost < MIN_L0_COST
173
173
  // 只给两端的事实:最早那一轮自己压不动,是因为它连块头开销都盖不住。
174
174
  // 不写"该并到哪一轮"——那等于替模型选区间(SPEC §6:只陈述事实)。
175
- ? `最早那一轮(T${oldest})单独是 ${oldestCost} token(${MIN_L0_COST} 是整段的叫醒门槛,不管单轮;能不能压要看地板 ${productFloor('l0', config)});T${oldest}–T${writable} 整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token`
176
- : `T${oldest} 单独是 ${oldestCost} token;T${oldest}–T${writable} 整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token`)
175
+ ? `最早那一轮(T${oldest})单独是 ${oldestCost} token(${MIN_L0_COST} 是整段的叫醒门槛,不管单轮;能不能压要看地板 ${productFloor('l0', config)});写到 T${writable} 会连压整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token,细节只留在摘要里`
176
+ : `T${oldest} 单独是 ${oldestCost} token;写到 T${writable} 会连压整段合计 ${Number.isFinite(totalL0Cost) ? totalL0Cost : '?'} token,细节只留在摘要里`)
177
177
  : `只写 T${oldest}:被压 ${oldestCost === null ? '?' : oldestCost} token`)
178
178
  // 压完必须比被压内容小(§4.7):剩下这一小段连块头开销都盖不住时,
179
179
  // 地图先说"压不了",别把模型叫来压一次然后被拒(口径一致)。
@@ -200,9 +200,19 @@ export function renderMap(ctx, agent, config, usage = null) {
200
200
  // 两边的事实:可压段一共多少 token、以及留给"摘要+标题"的剩余预算。
201
201
  // 后者 = 可压段 − 一块的最小产物成本(摘要≈0 时的开销),所以模型写得比它长就过不了收益校验。
202
202
  const budgetOf = (cost, key) => Math.max(0, cost - floorTok(key))
203
+ // 产物层的单块上限(buildOp 里 summaryTokens > cap 会直接拒):l1 压出的是 L2 块(2000),
204
+ // 只报"按收益算的预算"会给出一个根本写不出来的数(实测 5406 对 L2 上限 2000)。
205
+ const productCap = (key) => config.segment[key === 'l0' ? 'l1' : key === 'l1' ? 'l2' : 'l3']
206
+ const productLayerName = (key) => (key === 'l0' ? 'l1' : key === 'l1' ? 'l2' : 'l3').toUpperCase()
203
207
  const tooSmall = (cost, key) =>
204
208
  `暂不可用:可压的块合计 ${cost} token,盖不住一块的最小产物成本(约 ${floorTok(key)} token),压完反而更大`
205
- const budgetNote = (cost, key) => `这一段合计 ${cost} token;摘要+标题合计不超过 ${budgetOf(cost, key)} token 才过收益校验`
209
+ const budgetNote = (cost, key) => {
210
+ const byRatio = budgetOf(cost, key)
211
+ const cap = productCap(key)
212
+ return byRatio > cap
213
+ ? `这一段合计 ${cost} token;摘要正文不超过 ${cap}(${productLayerName(key)} 单块上限),摘要+标题合计不超过 ${byRatio} token 才过收益校验`
214
+ : `这一段合计 ${cost} token;摘要+标题合计不超过 ${byRatio} token 才过收益校验`
215
+ }
206
216
  // 只并第一块时的预算:整段的数字对 to=1 是虚构的(97 的块配不下 119 的预算)。
207
217
  const firstOnly = (key) => {
208
218
  const list = zones.zones[key] || []
@@ -214,7 +224,9 @@ export function renderMap(ctx, agent, config, usage = null) {
214
224
  const cost = firstOnly(key)
215
225
  if (!cost) return ''
216
226
  return cost >= floorTok(key)
217
- ? `to=1 只并最老的一块(${cost} token,够改写开销)`
227
+ // to=1 的产物同样是产物层的块:预算要按单块上限截断,否则首块很大时会给一个写不出来的数
228
+ // (qwen 快审 1163c1a 时实测:首块 4200 字 → 给 2390,实际 L2 上限 2000)。
229
+ ? `to=1 只并最老的一块(${cost} token,够改写开销;摘要+标题 ≤ ${Math.min(budgetOf(cost, key), productCap(key))})`
218
230
  : `to=1 只并最老的一块(${cost} token,单独写必被拒)`
219
231
  }
220
232
  lines.push(
@@ -223,7 +235,7 @@ export function renderMap(ctx, agent, config, usage = null) {
223
235
  `l1 压 L1 区最老的${maxL1 >= 1 ? ` 1–${maxL1} 块` : '一段'} -> ` +
224
236
  (maxL1 >= 1
225
237
  ? (costL1 >= floorTok('l1')
226
- ? `${firstOk('l1') || maxL1 < 2 ? `可写 to=1–${maxL1}` : `可写 to=2–${maxL1}(to=1 单独不可行)`}(该区共 ${zones.zones.l1.length} 块,1 最老;最后一块不能压)。` +
238
+ ? `${firstOk('l1') || maxL1 < 2 ? `可写 to 的序号:1${maxL1 > 1 ? `…${maxL1}` : ''}` : `可写 to 的序号:2${maxL1 > 2 ? `…${maxL1}` : ''}(to=1 单独不可行)`}(该区共 ${zones.zones.l1.length} 块,1 最老;最后一块不能压;to 只写一个序号、别写成区间)。` +
227
239
  `${firstNote('l1')};to=${maxL1} 一次并 ${maxL1} 块。${budgetNote(costL1, 'l1')}`
228
240
  : tooSmall(costL1, 'l1'))
229
241
  : '暂不可用:L1 区不足 2 块(最后一块不能压)')
@@ -232,7 +244,7 @@ export function renderMap(ctx, agent, config, usage = null) {
232
244
  `l2 压 L2 区最老的${maxL2 >= 1 ? ` 1–${maxL2} 块` : '一段'}(产物是新的 L3 块,旧 L3 不动)-> ` +
233
245
  (maxL2 >= 1
234
246
  ? (costL2 >= floorTok('l2')
235
- ? `${firstOk('l2') || maxL2 < 2 ? `可写 to=1–${maxL2}` : `可写 to=2–${maxL2}(to=1 单独不可行)`}(该区共 ${zones.zones.l2.length} 块,1 最老;最后一块不能压)。` +
247
+ ? `${firstOk('l2') || maxL2 < 2 ? `可写 to 的序号:1${maxL2 > 1 ? `…${maxL2}` : ''}` : `可写 to 的序号:2${maxL2 > 2 ? `…${maxL2}` : ''}(to=1 单独不可行)`}(该区共 ${zones.zones.l2.length} 块,1 最老;最后一块不能压;to 只写一个序号、别写成区间)。` +
236
248
  `${firstNote('l2')};to=${maxL2} 一次并 ${maxL2} 块。${budgetNote(costL2, 'l2')}`
237
249
  : tooSmall(costL2, 'l2'))
238
250
  : '暂不可用:L2 区不足 2 块(最后一块不能压)')
@@ -244,7 +256,7 @@ export function renderMap(ctx, agent, config, usage = null) {
244
256
 
245
257
  lines.push('')
246
258
  lines.push('压缩须知')
247
- lines.push('· 只压已经结束、结果已经落地的轮次;当前轮和还需要的轮绝对不可压——压掉它们等于把正在做的工作从上下文里删掉。不要一压到底:压到第一个还没收尾的轮次之前就停,拿不准是否收尾就按没收尾处理、留着。')
259
+ lines.push('· 只压已经结束、结果已经落地的轮次;当前轮和还需要的轮绝对不可压——压掉它们等于把正在做的工作从上下文里删掉。不要一压到底:压到第一个还没收尾的轮次之前就停,拿不准是否收尾就按没收尾处理、留着。地图列出的最大号只是上界,不是默认值:要压就压已经完成的部分。')
248
260
  lines.push('· 压缩专用轮:压完就收手——不要越过上一轮还没收尾的未决项,也不要在压缩成功后转做其它任务;未决的事原样留着。')
249
261
  lines.push('· 级联:压 l1/l2/l3 时,比它浅、且还能压的层要同批一起给,只给深层会整批被拒。')
250
262
  lines.push('· 压完必须比被压掉的内容小,不达标整批不写并返回两侧实测值;区间取多大由你权衡(越大省得越多、细节留得越少)。')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ebbing-context",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -21,6 +21,7 @@
21
21
  "locale/*.json",
22
22
  "icon.svg",
23
23
  "README.md",
24
+ "README.en.md",
24
25
  "CHANGELOG.md"
25
26
  ],
26
27
  "keywords": [
@@ -51,7 +52,7 @@
51
52
  "node": ">=20"
52
53
  },
53
54
  "scripts": {
54
- "test": "node test/blocks.test.mjs && node test/compress.test.mjs && node test/compress-e2e.test.mjs && node test/callfold.test.mjs && node test/client.test.mjs && node test/config.test.mjs && node test/map.test.mjs && node test/prestep.test.mjs && node test/usage.test.mjs && node test/scope.test.mjs && node test/imageprice.test.mjs && node test/log.test.mjs && node test/state.test.mjs",
55
+ "test": "node test/blocks.test.mjs && node test/compress.test.mjs && node test/compress-e2e.test.mjs && node test/callfold.test.mjs && node test/client.test.mjs && node test/client-render.test.mjs && node test/config.test.mjs && node test/map.test.mjs && node test/prestep.test.mjs && node test/usage.test.mjs && node test/scope.test.mjs && node test/imageprice.test.mjs && node test/log.test.mjs && node test/state.test.mjs",
55
56
  "check": "node --check index.js && node --check client.js && node --check lib/blocks.js && node --check lib/callfold.js && node --check lib/compress.js && node --check lib/config.js && node --check lib/license.js && node --check lib/log.js && node --check lib/map.js && node --check lib/messages.js && node --check lib/tokens.js && node --check lib/imageprice.js && node --check lib/state.js",
56
57
  "pack": "node -e \"require('node:fs').mkdirSync('dist',{recursive:true})\" && npm pack --pack-destination dist",
57
58
  "prepublishOnly": "npm run check && npm test"