smartcompact 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -19
- package/README.zh-CN.md +17 -17
- package/adapters/pi/index.ts +16 -17
- package/adapters/pi/recognize.ts +3 -2
- package/package.json +1 -1
- package/plugin/README.md +35 -0
- package/plugin/src/core/decide.ts +3 -3
- package/plugin/src/core/ledger.ts +18 -7
- package/plugin/src/core/lines.ts +2 -26
- package/plugin/src/core/params.ts +1 -5
- package/plugin/src/core/report.ts +2 -2
- package/plugin/src/core/signals.ts +8 -10
- package/plugin/src/core/state.ts +58 -5
package/README.md
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
<p align="center"><a href="https://github.com/coffeecoproject/SmartCompact/raw/main/docs/media/smartcompact-en.mp4"><img src="https://raw.githubusercontent.com/coffeecoproject/SmartCompact/main/docs/media/cover-en.jpg" alt="SmartCompact in 90 seconds (video)" width="100%"></a></p>
|
|
10
10
|
<p align="center"><sub>▶ Watch the 90-second explainer (click the image to play)</sub></p>
|
|
11
11
|
|
|
12
|
-
**SmartCompact lets long coding-agent sessions compact earlier, at a pause after a piece of work is finished.** On 18–60 consecutive coding tasks it cut cost by **7–44%** with no loss in hidden acceptance tests; the longer the session, the more it saves. In the longest runs (60 tasks, native sessions reaching 820k–960k tokens) the model missed 6 of 180 recall questions about details mentioned in passing much earlier ([
|
|
12
|
+
**SmartCompact lets long coding-agent sessions compact earlier, at a pause after a piece of work is finished.** On 18–60 consecutive coding tasks it cut cost by **7–44%** with no loss in hidden acceptance tests; the longer the session, the more it saves. In the longest runs (60 tasks, native sessions reaching 820k–960k tokens) the model missed 6 of 180 recall questions about details mentioned in passing much earlier ([how it was tested](https://github.com/coffeecoproject/SmartCompact/blob/main/docs/EVALUATION.md)).
|
|
13
13
|
|
|
14
14
|
Every request re-reads the whole context, so a long context makes every step expensive. Native auto-compaction waits until the context is nearly full (close to 1M tokens in Claude Code, up to 800k in Codex if configured), and it ignores timing, so it often lands in the middle of a task. SmartCompact works with Claude Code (CLI and desktop app), [Pi](https://github.com/earendil-works/pi) and the Codex CLI:
|
|
15
15
|
|
|
16
16
|
- **Compact at a good moment.** Once the context passes the **allow line** (200k tokens by default), it compacts when a turn ends, if a piece of work has been finished since the last compaction (a commit, tests passing, a task completed).
|
|
17
|
-
- **Never in the middle of work.** It only decides when a turn ends, never mid-turn, and it waits while tests or the build are failing
|
|
18
|
-
- **
|
|
17
|
+
- **Never in the middle of work.** It only decides when a turn ends, never mid-turn, and it waits while tests or the build are failing or errors keep repeating.
|
|
18
|
+
- **No limit of its own.** If no piece of work gets finished, the agent is usually deep in one large task, which is when a large context pays off. SmartCompact leaves it alone, and the platform's native compaction applies as it would without the plugin.
|
|
19
19
|
- **A progress ledger bridges the gap.** Files changed, the latest commit and test result, and decisions the agent recorded are handed back to the model after compaction.
|
|
20
20
|
- **No pointless compactions.** It skips compaction when it would not free enough space.
|
|
21
21
|
|
|
@@ -30,7 +30,7 @@ The same codebase and 18–30 consecutive development tasks, where later tasks c
|
|
|
30
30
|
| Claude Code (Haiku 5.5, 30 tasks) | close to 1M | −7% (noisy) | 30/30 both | 90/90 both |
|
|
31
31
|
| Claude Code (Haiku 5.5, 60 tasks) | close to 1M | **−44%** | native 59–60/60, SmartCompact 60/60 | native 180/180, SmartCompact 174/180 |
|
|
32
32
|
|
|
33
|
-
Cost is converted to cached-input-token equivalents using each provider's price ratios. The later native compaction kicks in and the faster a session grows, the more it saves. Below 200k SmartCompact does nothing, so it costs nothing either. After a compaction the model spends extra requests getting familiar with the code again; that is included in the cost above. These are small samples on one codebase and one kind of task; the [
|
|
33
|
+
Cost is converted to cached-input-token equivalents using each provider's price ratios. The later native compaction kicks in and the faster a session grows, the more it saves. Below 200k SmartCompact does nothing, so it costs nothing either. After a compaction the model spends extra requests getting familiar with the code again; that is included in the cost above. These are small samples on one codebase and one kind of task; the [evaluation](https://github.com/coffeecoproject/SmartCompact/blob/main/docs/EVALUATION.md) has the method, the data and the limitations.
|
|
34
34
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
@@ -41,7 +41,7 @@ Cost is converted to cached-input-token equivalents using each provider's price
|
|
|
41
41
|
/plugin install smartcompact@smartcompact
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
It takes effect in new sessions.
|
|
44
|
+
It takes effect in new sessions. `/smartcompact pause` turns it off for the current session.
|
|
45
45
|
|
|
46
46
|
**Pi**
|
|
47
47
|
|
|
@@ -54,31 +54,29 @@ With Pi's default settings, SmartCompact compacts at a finished piece of work wh
|
|
|
54
54
|
**Codex CLI** (0.162 or later, Node.js 22.18 or later)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
node scripts/codex.ts install
|
|
57
|
+
codex plugin marketplace add coffeecoproject/SmartCompact
|
|
58
|
+
codex plugin add smartcompact@smartcompact
|
|
60
59
|
```
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
Trust the plugin's 4 hooks when Codex asks on its next start. The hooks find a Node.js 22.18+ on their own (`PATH`, nvm, Homebrew; or set `SMARTCOMPACT_NODE`); without one they do nothing. To update, run `codex plugin marketplace upgrade smartcompact` and then the `codex plugin add` command again. If you installed an earlier version with `node scripts/codex.ts install`, run `node scripts/codex.ts uninstall` in that clone once, so the hooks do not run twice.
|
|
63
62
|
|
|
64
|
-
Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompact decides when a turn ends and, if it should compact, starts the compaction through Codex's shared background server once the session is idle (the interactive UI uses it by default). `codex exec` does not use the shared server by default, and the Codex App (ChatGPT.app) currently cannot; there SmartCompact only hands back the progress ledger, and compaction follows Codex's native rules.
|
|
63
|
+
Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompact decides when a turn ends and, if it should compact, starts the compaction through Codex's shared background server once the session is idle (the interactive UI uses it by default). `codex exec` does not use the shared server by default, and the Codex App (ChatGPT.app) currently cannot; there SmartCompact only hands back the progress ledger, and compaction follows Codex's native rules. If you want compaction to come earlier there, add Codex's own `model_post_turn_compact_threshold_percent` at the top of `config.toml`: it compacts after a turn ends once the context passes that percentage of the usable window (31 is about 260k for an 872k window; see the test report).
|
|
65
64
|
|
|
66
65
|
## Settings
|
|
67
66
|
|
|
68
67
|
| Setting | Default | Meaning |
|
|
69
68
|
|---|---|---|
|
|
70
69
|
| Allow line | 200000 | Above it, compact when a turn ends once a piece of work has been finished |
|
|
71
|
-
| Grace factor | 1.3 | Above allow line × factor, stop waiting for a finished piece of work |
|
|
72
70
|
| Minimum freed | 60000 | Skip compaction if it is expected to free less than this |
|
|
73
71
|
|
|
74
|
-
- Claude Code: the plugin options `allowLineTokens
|
|
75
|
-
- Pi: the flags `--smartcompact-allow-line
|
|
76
|
-
- Codex: the environment variables `SMARTCOMPACT_ALLOW_LINE
|
|
72
|
+
- Claude Code: the plugin options `allowLineTokens` and `minFreedTokens`; leave them empty for the defaults.
|
|
73
|
+
- Pi: the flags `--smartcompact-allow-line` and `--smartcompact-min-freed`.
|
|
74
|
+
- Codex: the environment variables `SMARTCOMPACT_ALLOW_LINE` and `SMARTCOMPACT_MIN_FREED`.
|
|
77
75
|
|
|
78
76
|
## Seeing what it did
|
|
79
77
|
|
|
80
|
-
- Claude Code and Pi: `/smartcompact` (or `/smartcompact report`) shows the
|
|
81
|
-
- Codex: `node adapters/codex/report.ts <session ID
|
|
78
|
+
- Claude Code and Pi: `/smartcompact` (or `/smartcompact report`) shows the allow line, the native compaction point, the finished-work signals it saw and a cost estimate; `/smartcompact pause` and `resume` turn it off and on for the session. The model can also call a `checkpoint` tool to record decisions, which come back with the ledger after compaction.
|
|
79
|
+
- Codex: `node ~/.codex/plugins/cache/smartcompact/smartcompact/<version>/adapters/codex/report.ts <session ID>` (Node.js 22.18+); records are kept in `~/.codex/smartcompact/`.
|
|
82
80
|
|
|
83
81
|
## FAQ
|
|
84
82
|
|
|
@@ -86,13 +84,15 @@ Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompac
|
|
|
86
84
|
|
|
87
85
|
**Will the model forget things after compaction?** Sometimes, for details mentioned in passing. The ledger only records files, commits, tests and decisions the model chose to record. Details the user mentions in passing are kept by asking the compaction summary to keep every specific fact the user stated. On Pi this clearly reduced losses, but it is not a guarantee.
|
|
88
86
|
|
|
89
|
-
**
|
|
87
|
+
**Does it change Claude Code's own settings?** No. Native auto-compaction stays where it is (close to 1M). Versions up to 0.1.2 moved it to about 260k by setting `CLAUDE_CODE_AUTO_COMPACT_WINDOW` in the session (which child processes inherited) and also compacted there without a finished piece of work. 0.1.3 removed both: a long task is no longer cut off at a fixed size. A session opened on 0.1.2 or earlier keeps the old setting until you start a new one.
|
|
88
|
+
|
|
89
|
+
**Does it help with a smaller context window?** Only when native compaction comes well after 200k. With a 200k window, native compaction comes first (about 167k in Claude Code) and SmartCompact never acts. On Codex's default 272k window, native compaction is at about 245k, which leaves only 200k–245k for SmartCompact, so it saves little unless `model_auto_compact_token_limit` is set higher. The allow line can be lowered (`allowLineTokens`, `--smartcompact-allow-line`, `SMARTCOMPACT_ALLOW_LINE`), but lower lines have not been evaluated.
|
|
90
90
|
|
|
91
91
|
**How do I remove it?**
|
|
92
92
|
|
|
93
93
|
- Claude Code: `/plugin uninstall smartcompact@smartcompact`.
|
|
94
94
|
- Pi: `pi remove npm:smartcompact`.
|
|
95
|
-
- Codex: `
|
|
95
|
+
- Codex: `codex plugin remove smartcompact@smartcompact`.
|
|
96
96
|
|
|
97
97
|
## Development
|
|
98
98
|
|
|
@@ -105,7 +105,7 @@ This runs the structure checks, the core dependency boundary check and all tests
|
|
|
105
105
|
- `plugin/`: the Claude Code plugin. `plugin/src/core/` holds the platform-independent decisions, signals, ledger and cost estimate that every adapter reuses.
|
|
106
106
|
- `adapters/pi/`, `adapters/codex/`: the Pi and Codex adapters.
|
|
107
107
|
- `eval/`: the A/B evaluation scripts (one each for Claude, Pi and Codex); the task format is described in `eval/day.py`. The tasks and hidden tests come from an internal project and are not published.
|
|
108
|
-
- `DESIGN.md`: design notes
|
|
108
|
+
- `docs/EVALUATION.md`: how the A/B runs were done and their results. `DESIGN.md`: design notes and `docs/TEST-REPORT.md`: the full test report (both in Chinese).
|
|
109
109
|
|
|
110
110
|
## License
|
|
111
111
|
|
package/README.zh-CN.md
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
agent 每发一次请求,都要把整段上下文重新读一遍,上下文越长,每一步越贵。原生的自动压缩要等到快满了才触发(Claude Code 接近 100 万,Codex 可以配到 80 万),而且不看时机,常常发生在一件事做到一半的时候。SmartCompact 支持 Claude Code(终端和桌面 App)、[Pi](https://github.com/earendil-works/pi) 和 Codex 命令行:
|
|
15
15
|
|
|
16
16
|
- **挑对时机压缩。** 上下文超过**允许压缩线**(默认 20 万)以后,只要上次压缩以来做完过一件事(一次提交、测试通过、任务完成),就在这一轮结束时压缩。
|
|
17
|
-
- **不在做到一半时压缩。**
|
|
18
|
-
-
|
|
17
|
+
- **不在做到一半时压缩。** 只在一轮结束时判断,从不在一轮中间主动压缩;测试失败、构建失败或连续报错时先等着。
|
|
18
|
+
- **不设自己的上限。** 一直没做完一件事,多半是在干一件大活,这时正需要大上下文。插件不去打断,压缩按平台原生规则来,和没装插件时一样。
|
|
19
19
|
- **用进度笔记接上。** 改过的文件、最近的提交和测试结果、记下的决定,压缩后补回给模型。
|
|
20
20
|
- **不做白费的压缩。** 预计腾不出足够空间时不压缩,避免刚压缩完又压缩。
|
|
21
21
|
|
|
@@ -41,7 +41,7 @@ agent 每发一次请求,都要把整段上下文重新读一遍,上下文
|
|
|
41
41
|
/plugin install smartcompact@smartcompact
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
装好后新开会话生效。`/smartcompact pause` 可以暂停当前会话的插件。
|
|
45
45
|
|
|
46
46
|
**Pi**
|
|
47
47
|
|
|
@@ -54,31 +54,29 @@ Pi 默认配置下,插件在一轮结束(`agent_settled`)时挑刚做完
|
|
|
54
54
|
**Codex 命令行**(0.162 及以上,Node.js 22.18 及以上)
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
node scripts/codex.ts install
|
|
57
|
+
codex plugin marketplace add coffeecoproject/SmartCompact
|
|
58
|
+
codex plugin add smartcompact@smartcompact
|
|
60
59
|
```
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
下次启动 Codex 时按提示信任插件的 4 个 hook。hook 会自己找 22.18 及以上的 Node.js(PATH、nvm、Homebrew,也可以用 `SMARTCOMPACT_NODE` 指定),找不到就什么都不做。更新时运行 `codex plugin marketplace upgrade smartcompact`,再执行一次上面的 `codex plugin add`。以前用 `node scripts/codex.ts install` 装过的,先在那份仓库里运行一次 `node scripts/codex.ts uninstall`,避免 hook 运行两遍。
|
|
63
62
|
|
|
64
|
-
Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束时判断,需要压缩就通过 Codex 的共享后台服务(交互界面默认会用它)在会话空闲时发起。`codex exec` 默认不走共享后台服务,Codex App(ChatGPT.app)目前走不了,这时插件只补回进度笔记,压缩仍按 Codex
|
|
63
|
+
Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束时判断,需要压缩就通过 Codex 的共享后台服务(交互界面默认会用它)在会话空闲时发起。`codex exec` 默认不走共享后台服务,Codex App(ChatGPT.app)目前走不了,这时插件只补回进度笔记,压缩仍按 Codex 原生规则。想让它们早点压缩,可以在 `config.toml` 顶部加上 Codex 自带的 `model_post_turn_compact_threshold_percent`:上下文超过可用窗口的这个百分比时,在一轮结束后压缩(窗口 87.2 万时设 31,约 26 万;详见测试报告)。
|
|
65
64
|
|
|
66
65
|
## 参数
|
|
67
66
|
|
|
68
67
|
| 参数 | 默认 | 含义 |
|
|
69
68
|
|---|---|---|
|
|
70
69
|
| 允许压缩线 | 200000 | 超过它、做完过一件事后,在一轮结束时压缩 |
|
|
71
|
-
| 宽限倍数 | 1.3 | 超过 允许压缩线 × 倍数 时不再等刚做完一件事 |
|
|
72
70
|
| 最少腾出空间 | 60000 | 预计腾出的空间少于它时不压缩 |
|
|
73
71
|
|
|
74
|
-
- Claude Code:插件配置项 `allowLineTokens`、`
|
|
75
|
-
- Pi:命令行开关 `--smartcompact-allow-line`、`--smartcompact-
|
|
76
|
-
- Codex:环境变量 `SMARTCOMPACT_ALLOW_LINE`、`
|
|
72
|
+
- Claude Code:插件配置项 `allowLineTokens`、`minFreedTokens`,留空用默认值。
|
|
73
|
+
- Pi:命令行开关 `--smartcompact-allow-line`、`--smartcompact-min-freed`。
|
|
74
|
+
- Codex:环境变量 `SMARTCOMPACT_ALLOW_LINE`、`SMARTCOMPACT_MIN_FREED`。
|
|
77
75
|
|
|
78
76
|
## 查看效果
|
|
79
77
|
|
|
80
|
-
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report
|
|
81
|
-
- Codex:`node adapters/codex/report.ts <会话 ID
|
|
78
|
+
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report`)查看允许压缩线、原生压缩点、识别到的阶段节点和成本估算;`/smartcompact pause` / `resume` 暂停或恢复当前会话。模型还可以调用 `checkpoint` 工具记下决定,压缩后和进度笔记一起补回。
|
|
79
|
+
- Codex:`node ~/.codex/plugins/cache/smartcompact/smartcompact/<版本>/adapters/codex/report.ts <会话 ID>`(Node.js 22.18 及以上),记录在 `~/.codex/smartcompact/`。
|
|
82
80
|
|
|
83
81
|
## 常见问题
|
|
84
82
|
|
|
@@ -86,13 +84,15 @@ Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束
|
|
|
86
84
|
|
|
87
85
|
**压缩后模型会不会忘事?** 随口提到的细节有时会丢。进度笔记只记文件、提交、测试和模型主动记下的决定。用户在对话里随口交代的细节,靠压缩时要求摘要原样保留用户说过的具体信息。Pi 上这样做明显减少了丢失,但不能保证不丢。
|
|
88
86
|
|
|
89
|
-
|
|
87
|
+
**会改 Claude Code 自己的设置吗?** 不会,原生自动压缩点保持原样(接近 100 万)。0.1.2 及以前的版本会在会话里设置 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,把原生压缩点挪到约 26 万(子进程也会继承),并且到了那里不等做完一件事也压缩。0.1.3 去掉了这两点:大活不会再在固定大小处被打断。升级前就开着的会话还留着旧设置,新开一个会话就没有了。
|
|
88
|
+
|
|
89
|
+
**上下文窗口小的模型有用吗?** 只有原生压缩点比 20 万高出一截时才有用。窗口只有 20 万时,原生先压(Claude Code 约 16.7 万),插件根本不会出手。Codex 默认窗口 27.2 万,原生约 24.5 万压,插件只有 20 万~24.5 万这一小段可用,省得不多,除非把 `model_auto_compact_token_limit` 调高。允许压缩线可以调低(`allowLineTokens`、`--smartcompact-allow-line`、`SMARTCOMPACT_ALLOW_LINE`),但调低后的效果没有评测过。
|
|
90
90
|
|
|
91
91
|
**怎么卸载?**
|
|
92
92
|
|
|
93
93
|
- Claude Code:`/plugin uninstall smartcompact@smartcompact`。
|
|
94
94
|
- Pi:`pi remove npm:smartcompact`。
|
|
95
|
-
- Codex
|
|
95
|
+
- Codex:`codex plugin remove smartcompact@smartcompact`。
|
|
96
96
|
|
|
97
97
|
## 开发
|
|
98
98
|
|
|
@@ -105,7 +105,7 @@ sh scripts/check.sh
|
|
|
105
105
|
- `plugin/`:Claude Code 插件;`plugin/src/core/` 是和平台无关的决策、信号、进度笔记和成本估算,其他适配都复用它。
|
|
106
106
|
- `adapters/pi/`、`adapters/codex/`:Pi 和 Codex 的适配。
|
|
107
107
|
- `eval/`:对照评测脚本(Claude、Pi、Codex 各一个),任务格式见 `eval/day.py`;评测用的题目和隐藏测试来自内部项目,没有放进仓库。
|
|
108
|
-
- `DESIGN.md`:设计说明;`docs/TEST-REPORT.md
|
|
108
|
+
- `DESIGN.md`:设计说明;`docs/TEST-REPORT.md`:测试报告;`docs/EVALUATION.md`:对照评测的英文说明。
|
|
109
109
|
|
|
110
110
|
## 许可证
|
|
111
111
|
|
package/adapters/pi/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// SmartCompact 的 pi 扩展:把 pi 的事件接到 core 的决策上。
|
|
2
|
-
//
|
|
3
|
-
// -
|
|
4
|
-
//
|
|
5
|
-
// 用户把 reserveTokens 调大、让原生压缩提前时,插件在提前的那个点按工作状态决定是否取消。
|
|
2
|
+
// - 插件在 agent_settled(不会再自动继续时)挑做完一件事的时机主动压缩;等不到就交给 pi 原生压缩。
|
|
3
|
+
// - 上限 = 窗口 − pi 默认预留。用户把 reserveTokens 调大、让原生压缩提前时,
|
|
4
|
+
// 插件在提前的那个点按工作状态决定是否取消,最晚到上限放行。
|
|
6
5
|
|
|
7
6
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
8
7
|
import { SettingsManager } from '@earendil-works/pi-coding-agent'
|
|
@@ -13,12 +12,15 @@ import type { Lines } from '../../plugin/src/core/lines.ts'
|
|
|
13
12
|
import { resolveParams } from '../../plugin/src/core/params.ts'
|
|
14
13
|
import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
|
|
15
14
|
import {
|
|
15
|
+
cycleReads,
|
|
16
16
|
type DecisionLogEntry,
|
|
17
17
|
expectedAfterTokens,
|
|
18
|
+
growthSinceMilestone,
|
|
18
19
|
INITIAL_SESSION_STATE,
|
|
19
20
|
parseState,
|
|
20
21
|
type SessionState,
|
|
21
22
|
withCompaction,
|
|
23
|
+
withRead,
|
|
22
24
|
withSample,
|
|
23
25
|
withSignals,
|
|
24
26
|
} from '../../plugin/src/core/state.ts'
|
|
@@ -41,7 +43,6 @@ const nudgeText = (tokens: number): string =>
|
|
|
41
43
|
|
|
42
44
|
export default function smartCompact(pi: ExtensionAPI) {
|
|
43
45
|
pi.registerFlag('smartcompact-allow-line', { description: 'SmartCompact 允许压缩线(token)', type: 'string' })
|
|
44
|
-
pi.registerFlag('smartcompact-grace', { description: 'SmartCompact 宽限倍数', type: 'string' })
|
|
45
46
|
pi.registerFlag('smartcompact-min-freed', { description: 'SmartCompact 最少腾出空间(token)', type: 'string' })
|
|
46
47
|
|
|
47
48
|
let state: SessionState = INITIAL_SESSION_STATE
|
|
@@ -64,7 +65,6 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
64
65
|
const params = (ctx: ExtensionContext) =>
|
|
65
66
|
resolveParams({
|
|
66
67
|
allowLineTokens: Number(pi.getFlag('smartcompact-allow-line')) || undefined,
|
|
67
|
-
graceFactor: Number(pi.getFlag('smartcompact-grace')) || undefined,
|
|
68
68
|
minFreedTokens: Number(pi.getFlag('smartcompact-min-freed')) || undefined,
|
|
69
69
|
defaultAfterTokens: compactionSettings(ctx).keepRecent + PI_AFTER_OVERHEAD,
|
|
70
70
|
})
|
|
@@ -73,21 +73,17 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
73
73
|
const usage = ctx.getContextUsage()
|
|
74
74
|
if (!usage) return null
|
|
75
75
|
const { reserve } = compactionSettings(ctx)
|
|
76
|
-
return {
|
|
77
|
-
allow: params(ctx).allowLineTokens,
|
|
78
|
-
midTurn: usage.contextWindow - reserve,
|
|
79
|
-
ceiling: usage.contextWindow - Math.min(reserve, PI_DEFAULT_RESERVE),
|
|
80
|
-
adjustsNative: false,
|
|
81
|
-
}
|
|
76
|
+
return { allow: params(ctx).allowLineTokens, ceiling: usage.contextWindow - Math.min(reserve, PI_DEFAULT_RESERVE) }
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
const tokensOf = (ctx: ExtensionContext): number | undefined => ctx.getContextUsage()?.tokens ?? undefined
|
|
85
80
|
|
|
86
|
-
// 成本估算对比的是"没有插件时":pi
|
|
81
|
+
// 成本估算对比的是"没有插件时":pi 原生会在它配置的压缩点(窗口 − reserveTokens)压缩,而不是等到真正的极限。
|
|
87
82
|
const sample = (ctx: ExtensionContext, current: Lines | null) => {
|
|
88
83
|
const tokens = tokensOf(ctx)
|
|
84
|
+
const window = ctx.getContextUsage()?.contextWindow
|
|
89
85
|
const model = `${ctx.model?.provider ?? ''} ${ctx.model?.id ?? ''}`
|
|
90
|
-
if (tokens !== undefined && current) state = withSample(state, tokens,
|
|
86
|
+
if (tokens !== undefined && current && window) state = withSample(state, tokens, window - compactionSettings(ctx).reserve, pricesFor(model))
|
|
91
87
|
return tokens
|
|
92
88
|
}
|
|
93
89
|
|
|
@@ -169,8 +165,9 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
169
165
|
if (event.toolName === CHECKPOINT_TOOL) return
|
|
170
166
|
sample(ctx, lines(ctx))
|
|
171
167
|
const text = event.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
|
|
172
|
-
const { signals, ledger } = recognizePi({ toolName: event.toolName, input: event.input, isError: event.isError, text }, state.ledger)
|
|
168
|
+
const { signals, ledger, read } = recognizePi({ toolName: event.toolName, input: event.input, isError: event.isError, text }, state.ledger)
|
|
173
169
|
state = withSignals({ ...state, ledger }, signals)
|
|
170
|
+
if (read) state = withRead(state, read)
|
|
174
171
|
})
|
|
175
172
|
|
|
176
173
|
pi.on('before_agent_start', async (_event, ctx) => {
|
|
@@ -191,7 +188,8 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
191
188
|
{ tokens, lines: current, work: state.work, expectedAfterTokens: expectedAfterTokens(state, params(ctx).defaultAfterTokens), paused: state.paused },
|
|
192
189
|
params(ctx),
|
|
193
190
|
)
|
|
194
|
-
|
|
191
|
+
const sinceMilestoneTokens = decision.action === 'compact' ? growthSinceMilestone(state.work, tokens) : undefined
|
|
192
|
+
if (decision.action !== 'none') log({ event: 'turn-end', action: decision.action, reason: decision.reason, tokens, sinceMilestoneTokens })
|
|
195
193
|
if (decision.action !== 'compact' || !ctx.isIdle() || ctx.hasPendingMessages()) return
|
|
196
194
|
ours = true
|
|
197
195
|
ctx.compact({
|
|
@@ -221,10 +219,11 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
221
219
|
const fromPlugin = event.reason === 'manual' && ours
|
|
222
220
|
ours = false
|
|
223
221
|
const summaryTokens = Math.round(event.compactionEntry.summary.length / 4)
|
|
222
|
+
const reads = cycleReads(state)
|
|
224
223
|
// 原生按阈值的压缩、溢出恢复和用户的 /compact,没有插件时也会发生;只有插件发起的压缩计入节省。
|
|
225
224
|
state = withCompaction(state, event.compactionEntry.tokensBefore, summaryTokens, fromPlugin)
|
|
226
225
|
persist()
|
|
227
|
-
log({ event: 'compacted', trigger: fromPlugin ? 'plugin' : event.reason === 'threshold' ? 'auto' : event.reason, tokensBefore: event.compactionEntry.tokensBefore })
|
|
226
|
+
log({ event: 'compacted', trigger: fromPlugin ? 'plugin' : event.reason === 'threshold' ? 'auto' : event.reason, tokensBefore: event.compactionEntry.tokensBefore, ...reads })
|
|
228
227
|
pi.sendMessage(
|
|
229
228
|
{ customType: LEDGER_MESSAGE, content: renderLedger(state.ledger, params(ctx).ledgerMaxChars), display: false },
|
|
230
229
|
{ deliverAs: ctx.isIdle() ? 'nextTurn' : 'steer' },
|
package/adapters/pi/recognize.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type PiToolOutcome = {
|
|
|
14
14
|
|
|
15
15
|
const EDIT_TOOLS = new Set(['edit', 'write'])
|
|
16
16
|
|
|
17
|
-
export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals: Signal[]; ledger: Ledger } {
|
|
17
|
+
export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals: Signal[]; ledger: Ledger; read?: string } {
|
|
18
18
|
const signals: Signal[] = [{ kind: 'tool-result', isError: outcome.isError }]
|
|
19
19
|
let next = ledger
|
|
20
20
|
const { path, command } = outcome.input
|
|
@@ -26,5 +26,6 @@ export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals:
|
|
|
26
26
|
signals.push(...classified.signals)
|
|
27
27
|
next = classified.ledger
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
const read = outcome.toolName === 'read' && !outcome.isError && typeof path === 'string' && path ? path : undefined
|
|
30
|
+
return { signals, ledger: next, read }
|
|
30
31
|
}
|
package/package.json
CHANGED
package/plugin/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SmartCompact for Claude Code
|
|
2
|
+
|
|
3
|
+
SmartCompact compacts long Claude Code sessions earlier, at a pause after a piece of work is finished, so every later request re-reads a smaller context. Once the context passes 200k tokens, it compacts at the end of a turn if a commit, passing tests or a completed task happened since the last compaction. It waits while tests or the build are failing or errors repeat. It sets no limit of its own: if no piece of work gets finished, the session keeps its context and Claude Code's native auto-compaction applies as usual. A short progress ledger (files changed, latest commit and test result, recorded decisions) is handed back to the model after compaction.
|
|
4
|
+
|
|
5
|
+
In a 60-task A/B run on Claude Haiku it cut estimated cost by 44% with no loss in hidden acceptance tests; the model missed 6 of 180 recall questions about details mentioned in passing long before. Method and data: [docs/EVALUATION.md](https://github.com/coffeecoproject/SmartCompact/blob/main/docs/EVALUATION.md).
|
|
6
|
+
|
|
7
|
+
## What it runs, reads and writes
|
|
8
|
+
|
|
9
|
+
Everything runs locally inside Claude Code. **The plugin makes no network requests, starts no processes, and does not read or change environment variables or Claude Code's settings.**
|
|
10
|
+
|
|
11
|
+
- **Reads**: the session's token usage; the tool name, input and success of each tool call, to recognize work: file paths from Edit/Write, Bash command text (to recognize `git commit` and test or build commands, and whether they failed), the `[branch hash] message` line from a commit's output, TodoWrite items and task events. It reads no credentials or tokens and no file contents, and nothing it reads leaves the machine.
|
|
12
|
+
- **Writes**: only the plugin's own storage (`$.store`): per-session state and the progress ledger (file paths, the latest commit line, the latest test command and result, in-progress task names, notes recorded with the `checkpoint` tool) and a short decision log. Old session records are pruned automatically.
|
|
13
|
+
|
|
14
|
+
### What each hook does
|
|
15
|
+
|
|
16
|
+
All hooks live in one module, `hooks/register.ts`.
|
|
17
|
+
|
|
18
|
+
- **Session start**: records the context window (for the cost estimate) and registers the `checkpoint` tool and the `/smartcompact` command.
|
|
19
|
+
- **`/smartcompact` command**: answers only its own command (`report`, `pause`, `resume`).
|
|
20
|
+
- **Tool calls**: answers calls to its own `checkpoint` tool by recording the note. For every other tool it only looks at the result to recognize commits, tests, builds and edited files, and passes the result through unchanged.
|
|
21
|
+
- **Task created / completed**: keeps the list of in-progress task names for the ledger.
|
|
22
|
+
- **Prompt submit**: adds context to the turn, never changes the user's message. After a compaction it adds the progress ledger; once per cycle, when the context nears the allow line, it adds a one-line reminder to record progress with `checkpoint`.
|
|
23
|
+
- **Turn complete**: decides whether to compact. When it should, it compacts the session. Where a session cannot compact directly (headless sessions such as the desktop app), it runs the `/compact` command itself with SmartCompact's compaction instructions; it runs no other commands.
|
|
24
|
+
- **Compaction**: never blocks or changes a compaction itself. After any compaction of the main session it appends the progress ledger to the compacted context. Subagent compactions are left alone.
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
- Install: `/plugin marketplace add coffeecoproject/SmartCompact`, then `/plugin install smartcompact@smartcompact`. It takes effect in new sessions.
|
|
29
|
+
- `/smartcompact` (or `/smartcompact report`) shows the lines, recognized milestones and a cost estimate; `/smartcompact pause` and `/smartcompact resume` turn it off and on for the session.
|
|
30
|
+
- Settings (optional, empty means default): `allowLineTokens` (200000), `minFreedTokens` (60000).
|
|
31
|
+
- Remove: `/plugin uninstall smartcompact@smartcompact`.
|
|
32
|
+
|
|
33
|
+
Full documentation, Pi and Codex versions: https://github.com/coffeecoproject/SmartCompact
|
|
34
|
+
|
|
35
|
+
License: MIT.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 两个决策函数:一轮结束时要不要主动压缩;一轮进行中原生自动压缩来问时放不放行(只有原生压缩点能提前的平台用到,目前是 pi)。
|
|
2
2
|
// 读不到用量时一律不干预,按原生行为来。
|
|
3
3
|
|
|
4
4
|
import type { Lines } from './lines.ts'
|
|
@@ -18,7 +18,7 @@ export type DecisionInput = {
|
|
|
18
18
|
export type TurnEndDecision =
|
|
19
19
|
| { action: 'none'; reason: 'paused' | 'no-reading' | 'below-line' }
|
|
20
20
|
| { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'waiting-for-milestone' }
|
|
21
|
-
| { action: 'compact'; reason: MilestoneSource
|
|
21
|
+
| { action: 'compact'; reason: MilestoneSource }
|
|
22
22
|
|
|
23
23
|
export type MidTurnDecision =
|
|
24
24
|
| { action: 'allow'; reason: 'paused' | 'no-reading' | 'at-ceiling' | 'default' }
|
|
@@ -37,7 +37,7 @@ export function decideAtTurnEnd(input: DecisionInput, params: Params): TurnEndDe
|
|
|
37
37
|
if (hard) return { action: 'wait', reason: hard }
|
|
38
38
|
if (!freesEnough(tokens, input, params)) return { action: 'wait', reason: 'insufficient-benefit' }
|
|
39
39
|
if (work.milestone) return { action: 'compact', reason: work.milestone }
|
|
40
|
-
|
|
40
|
+
// 不设兜底线:一直等不到做完一件事,多半是在做一件大活,正需要大上下文;交给原生在上限处压缩。
|
|
41
41
|
return { action: 'wait', reason: 'waiting-for-milestone' }
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -128,9 +128,20 @@ export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
131
|
+
/** 排版成补回上下文的文本,超长时先丢最旧的决定、再丢最早改过的文件,仍超长才截断。 */
|
|
132
132
|
export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
133
|
-
|
|
133
|
+
let decisions = ledger.notes.decisions
|
|
134
|
+
let files = ledger.facts.filesChanged
|
|
135
|
+
let rendered = layoutLedger(ledger, decisions, files)
|
|
136
|
+
while (rendered.length > maxChars && (decisions.length || files.length)) {
|
|
137
|
+
if (decisions.length) decisions = decisions.slice(1)
|
|
138
|
+
else files = files.slice(1)
|
|
139
|
+
rendered = layoutLedger(ledger, decisions, files)
|
|
140
|
+
}
|
|
141
|
+
return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function layoutLedger({ facts, notes }: Ledger, decisions: readonly string[], files: readonly string[]): string {
|
|
134
145
|
const lines = [
|
|
135
146
|
LEDGER_MARKER,
|
|
136
147
|
'Generated by the SmartCompact plugin to keep continuity across compaction. It is not a message from the user.',
|
|
@@ -139,19 +150,19 @@ export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
|
139
150
|
const rows = body.filter((row): row is string => Boolean(row))
|
|
140
151
|
if (rows.length) lines.push('', `## ${title}`, ...rows)
|
|
141
152
|
}
|
|
153
|
+
const omitted = (dropped: number, what: string) => (dropped ? `- …(${dropped} earlier ${what} omitted)` : undefined)
|
|
142
154
|
section('Goal', [notes.goal])
|
|
143
155
|
section('Current state', [notes.current])
|
|
144
156
|
section('Next', [notes.next])
|
|
145
|
-
section('Decisions', notes.decisions.map(decision => `- ${decision}`))
|
|
146
|
-
section('Tasks in progress', facts.tasksInProgress.map(task => `- ${task}`))
|
|
147
|
-
section('Files changed', facts.filesChanged.map(file => `- ${file}`))
|
|
148
157
|
section('Latest results', [
|
|
149
158
|
facts.lastTest && `- tests ${facts.lastTest.passed ? 'passed' : 'FAILED'}: ${facts.lastTest.command}`,
|
|
150
159
|
facts.lastBuild && `- build ${facts.lastBuild.passed ? 'passed' : 'FAILED'}: ${facts.lastBuild.command}`,
|
|
151
160
|
facts.lastCommit && `- last commit: ${facts.lastCommit.summary}`,
|
|
152
161
|
])
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
section('Tasks in progress', facts.tasksInProgress.map(task => `- ${task}`))
|
|
163
|
+
section('Decisions', [omitted(notes.decisions.length - decisions.length, 'decisions'), ...decisions.map(decision => `- ${decision}`)])
|
|
164
|
+
section('Files changed', [omitted(facts.filesChanged.length - files.length, 'files'), ...files.map(file => `- ${file}`)])
|
|
165
|
+
return lines.join('\n')
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
export function isLedgerText(value: string): boolean {
|
package/plugin/src/core/lines.ts
CHANGED
|
@@ -1,31 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
import type { Params } from './params.ts'
|
|
1
|
+
// 两条线:允许压缩线 L(超过它,做完一件事时压缩才划算)、上限 C(原生压缩点)。
|
|
2
|
+
// 插件不设自己的兜底线:一直等不到做完一件事,就交给原生在上限处压缩,和没装插件时一样。
|
|
4
3
|
|
|
5
4
|
export type Lines = {
|
|
6
5
|
allow: number
|
|
7
|
-
midTurn: number
|
|
8
6
|
ceiling: number
|
|
9
|
-
/** false 表示窗口本身放不下 L 和 M,插件不调整原生压缩线,只负责进度笔记 */
|
|
10
|
-
adjustsNative: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** CLAUDE_CODE_AUTO_COMPACT_WINDOW 允许的最小值 */
|
|
14
|
-
export const MIN_NATIVE_SETTING = 100_000
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param nativeSettingTokens 插件调整之前,原生压缩线的设定值(窗口或用户自己的配置)
|
|
18
|
-
*/
|
|
19
|
-
export function planLines(nativeSettingTokens: number, params: Params): Lines {
|
|
20
|
-
const ceiling = nativeSettingTokens - params.nativeBufferTokens
|
|
21
|
-
const allow = params.allowLineTokens
|
|
22
|
-
// 原生压缩线设定值有下限,M 不会低于它对应的触发点,否则算出的 M 与原生实际行为不符。
|
|
23
|
-
const midTurn = Math.max(Math.round(allow * params.graceFactor), MIN_NATIVE_SETTING - params.nativeBufferTokens)
|
|
24
|
-
if (midTurn >= ceiling) return { allow, midTurn: ceiling, ceiling, adjustsNative: false }
|
|
25
|
-
return { allow, midTurn, ceiling, adjustsNative: true }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 让原生自动压缩从 M 开始尝试,需要写入的压缩线设定值。 */
|
|
29
|
-
export function nativeSettingFor(lines: Lines, params: Params): number {
|
|
30
|
-
return lines.midTurn + params.nativeBufferTokens
|
|
31
7
|
}
|
|
@@ -3,13 +3,11 @@
|
|
|
3
3
|
export type Params = {
|
|
4
4
|
/** 允许压缩线 L:一轮结束时超过它才考虑压缩 */
|
|
5
5
|
allowLineTokens: number
|
|
6
|
-
/** 轮中兜底线 M = L × graceFactor */
|
|
7
|
-
graceFactor: number
|
|
8
6
|
/** 一次压缩至少要腾出的空间,不够就不压,防止连续压缩 */
|
|
9
7
|
minFreedTokens: number
|
|
10
8
|
/** 还没实测到压缩后大小时,用这个估计值 */
|
|
11
9
|
defaultAfterTokens: number
|
|
12
|
-
/** 原生触发点 ≈
|
|
10
|
+
/** Claude 原生触发点 ≈ 窗口 − 这个值(实测约 33k) */
|
|
13
11
|
nativeBufferTokens: number
|
|
14
12
|
/** 用量读数可能落后于最新的工具结果,接近上限时留出的余量 */
|
|
15
13
|
ceilingMarginTokens: number
|
|
@@ -23,7 +21,6 @@ export type Params = {
|
|
|
23
21
|
|
|
24
22
|
export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
25
23
|
allowLineTokens: 200_000,
|
|
26
|
-
graceFactor: 1.3,
|
|
27
24
|
minFreedTokens: 60_000,
|
|
28
25
|
defaultAfterTokens: 50_000,
|
|
29
26
|
nativeBufferTokens: 33_000,
|
|
@@ -35,7 +32,6 @@ export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
|
35
32
|
|
|
36
33
|
const MINIMUMS: Record<keyof Params, number> = {
|
|
37
34
|
allowLineTokens: 1,
|
|
38
|
-
graceFactor: 1,
|
|
39
35
|
minFreedTokens: 0,
|
|
40
36
|
defaultAfterTokens: 0,
|
|
41
37
|
nativeBufferTokens: 0,
|
|
@@ -29,10 +29,10 @@ export function renderReport(input: {
|
|
|
29
29
|
const seen = state.seen
|
|
30
30
|
return [
|
|
31
31
|
`SmartCompact${state.paused ? '(已暂停)' : ''}`,
|
|
32
|
-
`- 当前用量 ${k(tokens)};允许压缩线 ${k(lines.allow)}
|
|
32
|
+
`- 当前用量 ${k(tokens)};允许压缩线 ${k(lines.allow)},原生压缩点 ${k(lines.ceiling)}`,
|
|
33
33
|
`- 本会话压缩 ${state.compactions} 次;最近一次压缩后约 ${k(state.measuredAfterTokens)}`,
|
|
34
34
|
`- 本会话决策:插件压缩 ${(counts['compacted:plugin'] ?? 0) + (counts['compacted:plugin-queued'] ?? 0)},自动压缩放行 ${counts['compacted:auto'] ?? 0},手动压缩 ${counts['compacted:manual'] ?? 0},轮中否决 ${counts['mid-turn:veto'] ?? 0},轮末等待 ${counts['turn-end:wait'] ?? 0}`,
|
|
35
|
-
`- 信号:提交 ${seen.commit},测试通过 ${seen['tests-passed']},任务完成 ${seen['task-completed']},checkpoint 完成 ${seen.checkpoint}
|
|
35
|
+
`- 信号:提交 ${seen.commit},测试通过 ${seen['tests-passed']},任务完成 ${seen['task-completed']},checkpoint 完成 ${seen.checkpoint}`,
|
|
36
36
|
`- 当前状态:${state.work.milestone ? `已到阶段节点(${state.work.milestone})` : '等待阶段节点'}${state.work.testsFailing ? ',测试失败中' : ''}${state.work.buildFailing ? ',构建失败中' : ''}`,
|
|
37
37
|
ratio === undefined
|
|
38
38
|
? '- 成本估算:还没有读数'
|
|
@@ -9,19 +9,19 @@ export type Signal =
|
|
|
9
9
|
| { kind: 'task-completed' }
|
|
10
10
|
| { kind: 'checkpoint'; milestone: boolean }
|
|
11
11
|
| { kind: 'tool-result'; isError: boolean }
|
|
12
|
-
| { kind: 'background'; running: number }
|
|
13
12
|
|
|
14
13
|
export type MilestoneSource = 'commit' | 'tests-passed' | 'task-completed' | 'checkpoint'
|
|
15
14
|
|
|
16
|
-
export type HardSignal = 'tests-failing' | 'build-failing' | 'error-streak'
|
|
15
|
+
export type HardSignal = 'tests-failing' | 'build-failing' | 'error-streak'
|
|
17
16
|
|
|
18
17
|
export type WorkState = {
|
|
19
18
|
/** 自上次压缩以来最近的阶段节点 */
|
|
20
19
|
milestone: MilestoneSource | null
|
|
20
|
+
/** 最近的阶段节点出现时的上下文用量,用来看它到压缩时隔了多远 */
|
|
21
|
+
milestoneTokens?: number
|
|
21
22
|
testsFailing: boolean
|
|
22
23
|
buildFailing: boolean
|
|
23
24
|
errorStreak: number
|
|
24
|
-
backgroundRunning: number
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const INITIAL_WORK_STATE: WorkState = Object.freeze({
|
|
@@ -29,7 +29,6 @@ export const INITIAL_WORK_STATE: WorkState = Object.freeze({
|
|
|
29
29
|
testsFailing: false,
|
|
30
30
|
buildFailing: false,
|
|
31
31
|
errorStreak: 0,
|
|
32
|
-
backgroundRunning: 0,
|
|
33
32
|
})
|
|
34
33
|
|
|
35
34
|
/** 这个信号代表哪种阶段节点,不是阶段节点时为 null。 */
|
|
@@ -49,7 +48,9 @@ export function milestoneOf(signal: Signal): MilestoneSource | null {
|
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
52
|
-
const
|
|
51
|
+
const milestone = milestoneOf(signal)
|
|
52
|
+
// 失败之后又做完了一件事(比如提交、checkpoint),那段失败就算过去了,不再挡着压缩。
|
|
53
|
+
const next = milestone ? { ...state, milestone, testsFailing: false, buildFailing: false } : state
|
|
53
54
|
switch (signal.kind) {
|
|
54
55
|
case 'tests':
|
|
55
56
|
return { ...next, testsFailing: !signal.passed }
|
|
@@ -57,16 +58,14 @@ export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
|
57
58
|
return { ...next, buildFailing: !signal.passed }
|
|
58
59
|
case 'tool-result':
|
|
59
60
|
return { ...next, errorStreak: signal.isError ? state.errorStreak + 1 : 0 }
|
|
60
|
-
case 'background':
|
|
61
|
-
return { ...next, backgroundRunning: Math.max(0, signal.running) }
|
|
62
61
|
default:
|
|
63
62
|
return next
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
|
|
67
|
-
/**
|
|
66
|
+
/** 压缩后清空阶段节点;失败这些仍然成立的事实保留。 */
|
|
68
67
|
export function afterCompaction(state: WorkState): WorkState {
|
|
69
|
-
return { ...state, milestone: null }
|
|
68
|
+
return { ...state, milestone: null, milestoneTokens: undefined }
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
/** 表明工作做到一半的硬信号,没有则返回 null。 */
|
|
@@ -74,6 +73,5 @@ export function hardSignal(state: WorkState, params: Params): HardSignal | null
|
|
|
74
73
|
if (state.testsFailing) return 'tests-failing'
|
|
75
74
|
if (state.buildFailing) return 'build-failing'
|
|
76
75
|
if (state.errorStreak >= params.errorStreakLimit) return 'error-streak'
|
|
77
|
-
if (state.backgroundRunning > 0) return 'background-running'
|
|
78
76
|
return null
|
|
79
77
|
}
|
package/plugin/src/core/state.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { EMPTY_LEDGER, type Ledger } from './ledger.ts'
|
|
|
4
4
|
import { afterCompaction, applySignal, INITIAL_WORK_STATE, milestoneOf, type MilestoneSource, type Signal, type WorkState } from './signals.ts'
|
|
5
5
|
import { EMPTY_TALLY, type Prices, recordCompaction, recordSample, type UsageTally } from './usage.ts'
|
|
6
6
|
|
|
7
|
-
export type SignalsSeen = Record<MilestoneSource
|
|
7
|
+
export type SignalsSeen = Record<MilestoneSource, number>
|
|
8
8
|
|
|
9
9
|
export type SessionState = {
|
|
10
10
|
work: WorkState
|
|
@@ -20,8 +20,24 @@ export type SessionState = {
|
|
|
20
20
|
/** 各种信号出现过的次数,用于确认识别是否生效 */
|
|
21
21
|
seen: SignalsSeen
|
|
22
22
|
usage: UsageTally
|
|
23
|
+
/** 读文件的统计;平台认不出读文件时没有这一项 */
|
|
24
|
+
reads?: ReadStats
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** 用来看压缩后要花多少次读取重新熟悉代码。 */
|
|
28
|
+
export type ReadStats = {
|
|
29
|
+
/** 上次压缩之前读过或改过的文件 */
|
|
30
|
+
known: string[]
|
|
31
|
+
/** 上次压缩以来读过的文件 */
|
|
32
|
+
current: string[]
|
|
33
|
+
/** 上次压缩以来读文件的次数 */
|
|
34
|
+
reads: number
|
|
35
|
+
/** 其中读的是压缩前已经看过的文件 */
|
|
36
|
+
rereads: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const MAX_KNOWN_FILES = 300
|
|
40
|
+
|
|
25
41
|
export const INITIAL_SESSION_STATE: SessionState = Object.freeze({
|
|
26
42
|
work: INITIAL_WORK_STATE,
|
|
27
43
|
ledger: EMPTY_LEDGER,
|
|
@@ -29,7 +45,7 @@ export const INITIAL_SESSION_STATE: SessionState = Object.freeze({
|
|
|
29
45
|
paused: false,
|
|
30
46
|
notesUpdated: false,
|
|
31
47
|
nudged: false,
|
|
32
|
-
seen: Object.freeze({ commit: 0, 'tests-passed': 0, 'task-completed': 0, checkpoint: 0
|
|
48
|
+
seen: Object.freeze({ commit: 0, 'tests-passed': 0, 'task-completed': 0, checkpoint: 0 }),
|
|
33
49
|
usage: EMPTY_TALLY,
|
|
34
50
|
}) as SessionState
|
|
35
51
|
|
|
@@ -45,15 +61,21 @@ export function expectedAfterTokens(state: SessionState, defaultAfterTokens: num
|
|
|
45
61
|
return state.measuredAfterTokens ?? defaultAfterTokens
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
/**
|
|
64
|
+
/** 应用信号,同时更新信号计数;出现阶段节点时记下当时的上下文用量(最近一次读数)。 */
|
|
49
65
|
export function withSignals(state: SessionState, signals: readonly Signal[]): SessionState {
|
|
50
66
|
const seen = { ...state.seen }
|
|
67
|
+
let work = signals.reduce(applySignal, state.work)
|
|
51
68
|
for (const signal of signals) {
|
|
52
69
|
const milestone = milestoneOf(signal)
|
|
53
70
|
if (milestone) seen[milestone] += 1
|
|
54
|
-
if (signal.kind === 'background' && signal.running > 0) seen.background += 1
|
|
55
71
|
}
|
|
56
|
-
|
|
72
|
+
if (signals.some(signal => milestoneOf(signal))) work = { ...work, milestoneTokens: state.usage.lastTokens }
|
|
73
|
+
return { ...state, work, seen }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 最近的阶段节点之后上下文又涨了多少;没有记录时为 undefined。 */
|
|
77
|
+
export function growthSinceMilestone(work: WorkState, tokens: number | undefined): number | undefined {
|
|
78
|
+
return tokens === undefined || work.milestoneTokens === undefined ? undefined : tokens - work.milestoneTokens
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
/** 记一次用量读数:压缩后的第一次读数同时记为实测的压缩后大小。 */
|
|
@@ -67,8 +89,15 @@ export function withSample(state: SessionState, tokens: number, ceiling: number,
|
|
|
67
89
|
* @param attributed 是否算作插件带来的压缩;用户手动 /compact 没有插件时也会发生,不计入节省
|
|
68
90
|
*/
|
|
69
91
|
export function withCompaction(state: SessionState, before: number, summary: number, attributed = true): SessionState {
|
|
92
|
+
const reads = state.reads && {
|
|
93
|
+
known: [...new Set([...state.reads.known, ...state.reads.current, ...state.ledger.facts.filesChanged])].slice(-MAX_KNOWN_FILES),
|
|
94
|
+
current: [],
|
|
95
|
+
reads: 0,
|
|
96
|
+
rereads: 0,
|
|
97
|
+
}
|
|
70
98
|
return {
|
|
71
99
|
...state,
|
|
100
|
+
...(reads && { reads }),
|
|
72
101
|
work: afterCompaction(state.work),
|
|
73
102
|
compactions: state.compactions + 1,
|
|
74
103
|
notesUpdated: false,
|
|
@@ -77,6 +106,25 @@ export function withCompaction(state: SessionState, before: number, summary: num
|
|
|
77
106
|
}
|
|
78
107
|
}
|
|
79
108
|
|
|
109
|
+
/** 记一次读文件:读的是上次压缩前看过的文件就算重读。 */
|
|
110
|
+
export function withRead(state: SessionState, path: string): SessionState {
|
|
111
|
+
const stats = state.reads ?? { known: [], current: [], reads: 0, rereads: 0 }
|
|
112
|
+
return {
|
|
113
|
+
...state,
|
|
114
|
+
reads: {
|
|
115
|
+
known: stats.known,
|
|
116
|
+
current: [...stats.current.filter(file => file !== path), path].slice(-MAX_KNOWN_FILES),
|
|
117
|
+
reads: stats.reads + 1,
|
|
118
|
+
rereads: stats.rereads + (stats.known.includes(path) ? 1 : 0),
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 压缩时记进日志的本周期读文件次数;平台认不出读文件时为空。 */
|
|
124
|
+
export function cycleReads(state: SessionState): Pick<DecisionLogEntry, 'reads' | 'rereads'> {
|
|
125
|
+
return state.reads ? { reads: state.reads.reads, rereads: state.reads.rereads } : {}
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
export type DecisionLogEntry = {
|
|
81
129
|
at: string
|
|
82
130
|
/** 会话 ID,报告据此只统计当前会话 */
|
|
@@ -87,6 +135,11 @@ export type DecisionLogEntry = {
|
|
|
87
135
|
tokens?: number
|
|
88
136
|
tokensBefore?: number
|
|
89
137
|
tokensAfter?: number
|
|
138
|
+
/** 决定压缩时,距离最近的阶段节点上下文涨了多少 */
|
|
139
|
+
sinceMilestoneTokens?: number
|
|
140
|
+
/** 压缩时:上次压缩以来读文件的次数,以及其中重读压缩前看过的文件的次数 */
|
|
141
|
+
reads?: number
|
|
142
|
+
rereads?: number
|
|
90
143
|
trigger?: string
|
|
91
144
|
detail?: string
|
|
92
145
|
}
|