smartcompact 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -2
- package/adapters/pi/index.ts +237 -0
- package/adapters/pi/recognize.ts +30 -0
- package/package.json +12 -4
- package/plugin/src/core/commands.ts +47 -0
- package/plugin/src/core/decide.ts +63 -0
- package/plugin/src/core/ledger.ts +159 -0
- package/plugin/src/core/lines.ts +31 -0
- package/plugin/src/core/params.ts +56 -0
- package/plugin/src/core/report.ts +43 -0
- package/plugin/src/core/signals.ts +79 -0
- package/plugin/src/core/state.ts +92 -0
- package/plugin/src/core/usage.ts +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 coffeecoproject
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SmartCompact
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English summary.** SmartCompact makes long coding-agent sessions compact *earlier, at the right moment* — once the context is past a threshold (200k tokens by default) and a piece of work has just been finished, typically a commit. Every request re-reads the whole context, so keeping it smaller saves money; picking the moment right after a commit avoids interrupting work. It never compacts in the middle of a task by itself. It works with Claude Code (CLI and desktop app), [pi](https://github.com/earendil-works/pi) and the Codex CLI. In controlled A/B runs (18–30 consecutive coding tasks, 3 runs per arm) it cut cost by 7–22% with no loss in hidden acceptance tests or recall questions. Details: [docs/TEST-REPORT.md](docs/TEST-REPORT.md) (Chinese).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
让长会话早点、挑对时机压缩:降低成本,同时不损失结果。
|
|
8
|
+
|
|
9
|
+
## 为什么
|
|
10
|
+
|
|
11
|
+
agent 每发一次请求,都要把整段上下文重新读一遍。上下文越长,每一步越贵。原生的自动压缩通常要等到快满了才触发(Claude Code 接近 100 万,Codex 可以配到 80 万),在那之前每一步都在为很长的上下文付钱;而且原生压缩不看时机,常常发生在一件事做到一半的时候。
|
|
12
|
+
|
|
13
|
+
SmartCompact 的做法:
|
|
14
|
+
|
|
15
|
+
- 上下文超过**允许压缩线**(默认 20 万)以后,等到**刚做完一件事**(一次提交、测试刚通过、任务完成)再压缩。
|
|
16
|
+
- 不在一件事做到一半时主动压缩;测试失败、构建失败、连续报错或后台任务还在跑时也不压缩。
|
|
17
|
+
- 一直等不到合适时机、上下文超过**宽限线**(允许压缩线 × 1.3)时,在一轮结束时压缩,作为兜底。
|
|
18
|
+
- 压缩前后用一份**进度笔记**接上:改过的文件、最近的提交和测试结果、记下的决定,压缩后补回给模型。
|
|
19
|
+
- 预计腾不出足够空间时不压缩,避免刚压缩完又压缩。
|
|
20
|
+
|
|
21
|
+
## 效果
|
|
22
|
+
|
|
23
|
+
同一个代码库、连续 18~30 个开发任务(后面的任务会修改前面任务的结果),每个平台装与不装各跑 3 次。验收由脚本跑模型看不到的隐藏测试;每轮结束问几道记忆题,包括很早之前随口提到的细节。
|
|
24
|
+
|
|
25
|
+
| 平台(模型) | 原生压缩点 | 成本 | 任务验收 | 记忆题 |
|
|
26
|
+
|---|---|---|---|---|
|
|
27
|
+
| Codex(gpt-6-astra) | 80 万 | **−19%** | 两组都 18/18 | 两组都 54/54 |
|
|
28
|
+
| pi(gpt-5.5) | 约 25.6 万 | −11%~−22% | 两组都 18/18 | 原生 51/54,插件 53/54 |
|
|
29
|
+
| Claude Code(Haiku 5.5,30 题) | 接近 100 万 | −7%(波动较大) | 两组都 30/30 | 两组都 90/90 |
|
|
30
|
+
|
|
31
|
+
成本按各家价格比例折算成"缓存读取 token 当量"。规律:原生压缩得越晚、会话涨得越快,省得越多;会话没超过 20 万时插件不起作用,也没有坏处。完整方法、数据、代价和局限见 [docs/TEST-REPORT.md](docs/TEST-REPORT.md)。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
### Claude Code(终端和桌面 App)
|
|
36
|
+
|
|
37
|
+
需要 Claude Code ≥ 2.1.295。
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
/plugin marketplace add coffeecoproject/SmartCompact
|
|
41
|
+
/plugin install smartcompact@smartcompact
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
装好后新开会话生效。插件会把原生自动压缩线调到宽限线附近,`/smartcompact pause` 可以让当前会话恢复原生行为。
|
|
45
|
+
|
|
46
|
+
### pi
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pi install npm:smartcompact
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
也可以从 GitHub 装:`pi install git:github.com/coffeecoproject/SmartCompact`。
|
|
53
|
+
|
|
54
|
+
pi 默认配置下,插件在一轮结束(`agent_settled`)时挑阶段节点主动压缩。如果你把 `reserveTokens` 调大、让 pi 的原生压缩提前,插件会在那个点按工作状态决定是否推迟。
|
|
55
|
+
|
|
56
|
+
### Codex 命令行
|
|
57
|
+
|
|
58
|
+
需要 Node ≥ 22.18(直接运行 TypeScript)和 Codex CLI ≥ 0.162。
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/coffeecoproject/SmartCompact.git
|
|
62
|
+
cd SmartCompact
|
|
63
|
+
node scripts/codex.ts install
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
安装脚本只在 `~/.codex/config.toml`(或 `$CODEX_HOME/config.toml`)末尾写入一段带标记的 hook 配置,改之前备份原文件;`node scripts/codex.ts uninstall` 移除。下次启动 Codex 时按提示信任这 4 个 hook。升级 Node 或移动仓库后重新运行一次 install。
|
|
67
|
+
|
|
68
|
+
Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束时判断,需要压缩就通过 Codex 的共享后台服务(交互界面默认会用它)在会话空闲时发起。`codex exec` 和 Codex App(ChatGPT.app)默认不走共享后台服务,这时插件只补回进度笔记,压缩仍按 Codex 原生规则。
|
|
69
|
+
|
|
70
|
+
## 参数
|
|
71
|
+
|
|
72
|
+
| 参数 | 默认 | 含义 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| 允许压缩线 | 200000 | 超过它且刚做完一件事时压缩 |
|
|
75
|
+
| 宽限倍数 | 1.3 | 超过 允许压缩线 × 倍数 时不再等阶段节点 |
|
|
76
|
+
| 最少腾出空间 | 60000 | 预计腾出的空间少于它时不压缩 |
|
|
77
|
+
|
|
78
|
+
- Claude Code:安装插件时的配置项(`allowLineTokens`、`graceFactor`、`minFreedTokens`),留空用默认值。
|
|
79
|
+
- pi:命令行开关 `--smartcompact-allow-line`、`--smartcompact-grace`、`--smartcompact-min-freed`。
|
|
80
|
+
- Codex:环境变量 `SMARTCOMPACT_ALLOW_LINE`、`SMARTCOMPACT_GRACE`、`SMARTCOMPACT_MIN_FREED`。
|
|
81
|
+
|
|
82
|
+
## 查看效果
|
|
83
|
+
|
|
84
|
+
- Claude Code 和 pi:`/smartcompact`(或 `/smartcompact report`)查看三条线、阶段节点计数和成本估算;`/smartcompact pause` / `resume` 暂停或恢复。Claude Code 和 pi 里模型还可以调用 checkpoint 工具记下决定,压缩后一起补回。
|
|
85
|
+
- Codex:`node adapters/codex/report.ts <会话 ID>`,记录在 `~/.codex/smartcompact/`。
|
|
86
|
+
|
|
87
|
+
## 局限
|
|
88
|
+
|
|
89
|
+
- 判断"刚做完一件事"靠识别命令:提交只要真的执行了 `git commit` 且没报错就算;测试命令接了管道(`| tail`)又没开 `pipefail` 时退出码不可信,不算测试结果。
|
|
90
|
+
- 进度笔记只记文件、提交、测试和模型主动记下的决定。用户在对话里随口交代的细节靠压缩时的说明要求摘要保留,pi 上能明显减少丢失,但不能保证不丢。
|
|
91
|
+
- Claude Code 上,插件的兜底压缩点和它调整后的原生压缩点接近;长时间认不出阶段节点时,可能由原生压缩在一轮中间触发。
|
|
92
|
+
- 评测只用了一个代码库和一种任务类型,Claude 只测了 Haiku。
|
|
93
|
+
|
|
94
|
+
## 开发
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
sh scripts/check.sh
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
结构校验、core 依赖边界检查和全部测试。目录:
|
|
101
|
+
|
|
102
|
+
- `plugin/`:Claude Code 插件;`plugin/src/core/` 是和平台无关的决策、信号、进度笔记和成本估算,其他适配都复用它。
|
|
103
|
+
- `adapters/pi/`、`adapters/codex/`:pi 和 Codex 的适配。
|
|
104
|
+
- `eval/`:对照评测脚本(Claude、pi、Codex 各一个),任务格式见 `eval/day.py`;评测用的题目和隐藏测试涉及内部项目,没有放进仓库。
|
|
105
|
+
- `DESIGN.md`:设计说明;`docs/TEST-REPORT.md`:测试报告。
|
|
106
|
+
|
|
107
|
+
## 许可证
|
|
108
|
+
|
|
109
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SmartCompact 的 pi 扩展:把 pi 的事件接到 core 的决策上。
|
|
2
|
+
// 和 Claude 版的区别:插件改不了 pi 的原生压缩线。
|
|
3
|
+
// - 兜底线 = pi 配置的原生压缩点(窗口 − reserveTokens),上限 = 窗口 − pi 默认预留。
|
|
4
|
+
// - 默认配置下两者相同,插件只在 agent_settled(不会再自动继续时)挑阶段节点主动压缩;
|
|
5
|
+
// 用户把 reserveTokens 调大、让原生压缩提前时,插件在提前的那个点按工作状态决定是否取消。
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
8
|
+
import { SettingsManager } from '@earendil-works/pi-coding-agent'
|
|
9
|
+
import { Type } from 'typebox'
|
|
10
|
+
import { decideAtTurnEnd, decideMidTurn, shouldNudge } from '../../plugin/src/core/decide.ts'
|
|
11
|
+
import { applyCheckpoint, COMPACT_INSTRUCTIONS, parseCheckpoint, renderLedger } from '../../plugin/src/core/ledger.ts'
|
|
12
|
+
import type { Lines } from '../../plugin/src/core/lines.ts'
|
|
13
|
+
import { resolveParams } from '../../plugin/src/core/params.ts'
|
|
14
|
+
import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
|
|
15
|
+
import {
|
|
16
|
+
type DecisionLogEntry,
|
|
17
|
+
expectedAfterTokens,
|
|
18
|
+
INITIAL_SESSION_STATE,
|
|
19
|
+
parseState,
|
|
20
|
+
type SessionState,
|
|
21
|
+
withCompaction,
|
|
22
|
+
withSample,
|
|
23
|
+
withSignals,
|
|
24
|
+
} from '../../plugin/src/core/state.ts'
|
|
25
|
+
import { pricesFor } from '../../plugin/src/core/usage.ts'
|
|
26
|
+
import { recognizePi } from './recognize.ts'
|
|
27
|
+
|
|
28
|
+
const STATE_ENTRY = 'smartcompact-state'
|
|
29
|
+
const LOG_ENTRY = 'smartcompact-log'
|
|
30
|
+
const LEDGER_MESSAGE = 'smartcompact-ledger'
|
|
31
|
+
const NUDGE_MESSAGE = 'smartcompact-nudge'
|
|
32
|
+
const CHECKPOINT_TOOL = 'smartcompact_checkpoint'
|
|
33
|
+
/** pi 内置的默认 reserveTokens,即不配置时原生压缩点离满窗的距离 */
|
|
34
|
+
const PI_DEFAULT_RESERVE = 16_384
|
|
35
|
+
/** 压缩后除了保留的最近内容,还有系统提示、工具和摘要,粗估这么多 */
|
|
36
|
+
const PI_AFTER_OVERHEAD = 10_000
|
|
37
|
+
|
|
38
|
+
const nudgeText = (tokens: number): string =>
|
|
39
|
+
`SmartCompact: the context is at about ${Math.round(tokens / 1000)}k tokens and will be compacted at the next finished piece of work. ` +
|
|
40
|
+
`If decisions or progress are not yet recorded, call ${CHECKPOINT_TOOL} briefly. If the user is waiting for an answer, answer first.`
|
|
41
|
+
|
|
42
|
+
export default function smartCompact(pi: ExtensionAPI) {
|
|
43
|
+
pi.registerFlag('smartcompact-allow-line', { description: 'SmartCompact 允许压缩线(token)', type: 'string' })
|
|
44
|
+
pi.registerFlag('smartcompact-grace', { description: 'SmartCompact 宽限倍数', type: 'string' })
|
|
45
|
+
pi.registerFlag('smartcompact-min-freed', { description: 'SmartCompact 最少腾出空间(token)', type: 'string' })
|
|
46
|
+
|
|
47
|
+
let state: SessionState = INITIAL_SESSION_STATE
|
|
48
|
+
let saved = ''
|
|
49
|
+
/** 插件自己发起的压缩在 pi 里同样是 manual,据此和用户的 /compact 区分。 */
|
|
50
|
+
let ours = false
|
|
51
|
+
/** 当前模型的 reserveTokens 和 keepRecentTokens;读设置文件有开销,按会话和模型缓存。 */
|
|
52
|
+
let compaction: { reserve: number; keepRecent: number } | undefined
|
|
53
|
+
|
|
54
|
+
const compactionSettings = (ctx: ExtensionContext) => {
|
|
55
|
+
if (!compaction) {
|
|
56
|
+
// 和 pi 自己一样:项目未被信任时不读项目目录下的设置。
|
|
57
|
+
const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() }).getCompactionSettings(ctx.model)
|
|
58
|
+
compaction = { reserve: settings.reserveTokens, keepRecent: settings.keepRecentTokens }
|
|
59
|
+
}
|
|
60
|
+
return compaction
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// pi 的基础占用比 Claude 小得多:压缩后的大小按 pi 保留的最近内容估算,而不是用 core 的默认值。
|
|
64
|
+
const params = (ctx: ExtensionContext) =>
|
|
65
|
+
resolveParams({
|
|
66
|
+
allowLineTokens: Number(pi.getFlag('smartcompact-allow-line')) || undefined,
|
|
67
|
+
graceFactor: Number(pi.getFlag('smartcompact-grace')) || undefined,
|
|
68
|
+
minFreedTokens: Number(pi.getFlag('smartcompact-min-freed')) || undefined,
|
|
69
|
+
defaultAfterTokens: compactionSettings(ctx).keepRecent + PI_AFTER_OVERHEAD,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const lines = (ctx: ExtensionContext): Lines | null => {
|
|
73
|
+
const usage = ctx.getContextUsage()
|
|
74
|
+
if (!usage) return null
|
|
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
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const tokensOf = (ctx: ExtensionContext): number | undefined => ctx.getContextUsage()?.tokens ?? undefined
|
|
85
|
+
|
|
86
|
+
// 成本估算对比的是"没有插件时":pi 原生会在它配置的压缩点(兜底线)压缩,而不是等到真正的极限。
|
|
87
|
+
const sample = (ctx: ExtensionContext, current: Lines | null) => {
|
|
88
|
+
const tokens = tokensOf(ctx)
|
|
89
|
+
const model = `${ctx.model?.provider ?? ''} ${ctx.model?.id ?? ''}`
|
|
90
|
+
if (tokens !== undefined && current) state = withSample(state, tokens, current.midTurn, pricesFor(model))
|
|
91
|
+
return tokens
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 状态变了才写一条,避免会话文件膨胀。
|
|
95
|
+
const persist = () => {
|
|
96
|
+
const json = JSON.stringify(state)
|
|
97
|
+
if (json === saved) return
|
|
98
|
+
saved = json
|
|
99
|
+
pi.appendEntry(STATE_ENTRY, state)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const log = (entry: Omit<DecisionLogEntry, 'at' | 'session'>) => pi.appendEntry(LOG_ENTRY, { at: new Date().toISOString(), ...entry })
|
|
103
|
+
|
|
104
|
+
const report = (ctx: ExtensionContext): string => {
|
|
105
|
+
const current = lines(ctx)
|
|
106
|
+
if (!current) return 'SmartCompact 尚未读到上下文用量。'
|
|
107
|
+
const entries = ctx.sessionManager
|
|
108
|
+
.getBranch()
|
|
109
|
+
.flatMap(entry => (entry.type === 'custom' && entry.customType === LOG_ENTRY ? [entry.data as DecisionLogEntry] : []))
|
|
110
|
+
return renderReport({ state, lines: current, tokens: tokensOf(ctx), counts: countDecisions(entries) })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
114
|
+
// 恢复当前分支上最后一次保存的状态。
|
|
115
|
+
const last = ctx.sessionManager
|
|
116
|
+
.getBranch()
|
|
117
|
+
.filter(entry => entry.type === 'custom' && entry.customType === STATE_ENTRY)
|
|
118
|
+
.at(-1)
|
|
119
|
+
state = parseState(last && last.type === 'custom' ? last.data : undefined)
|
|
120
|
+
saved = JSON.stringify(state)
|
|
121
|
+
ours = false
|
|
122
|
+
compaction = undefined
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
pi.on('model_select', async () => {
|
|
126
|
+
compaction = undefined
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
pi.registerTool({
|
|
130
|
+
name: CHECKPOINT_TOOL,
|
|
131
|
+
label: 'Checkpoint',
|
|
132
|
+
description:
|
|
133
|
+
'Record a short progress note that survives context compaction: the goal, decisions made, where the work stands, and what is next. ' +
|
|
134
|
+
'Call it after finishing a piece of work (set milestone: true) or after an important decision. Keep it brief; do not call it for trivial steps.',
|
|
135
|
+
parameters: Type.Object({
|
|
136
|
+
goal: Type.Optional(Type.String({ description: 'The overall goal of the current work.' })),
|
|
137
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: 'Decisions made since the last checkpoint.' })),
|
|
138
|
+
next: Type.Optional(Type.String({ description: 'What to do next.' })),
|
|
139
|
+
current: Type.Optional(Type.String({ description: 'Where the work stands right now.' })),
|
|
140
|
+
milestone: Type.Optional(Type.Boolean({ description: 'True when a piece of work was just finished.' })),
|
|
141
|
+
}),
|
|
142
|
+
async execute(_toolCallId, input) {
|
|
143
|
+
const parsed = parseCheckpoint(input)
|
|
144
|
+
if (!parsed.ok) return { content: [{ type: 'text', text: `Not recorded: ${parsed.error}` }], details: {} }
|
|
145
|
+
state = withSignals({ ...state, ledger: applyCheckpoint(state.ledger, parsed.value), notesUpdated: true }, [
|
|
146
|
+
{ kind: 'checkpoint', milestone: parsed.value.milestone === true },
|
|
147
|
+
])
|
|
148
|
+
persist()
|
|
149
|
+
log({ event: 'checkpoint', action: parsed.value.milestone ? 'milestone' : 'note' })
|
|
150
|
+
return { content: [{ type: 'text', text: 'Recorded. Continue with the work.' }], details: {} }
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
pi.registerCommand('smartcompact', {
|
|
155
|
+
description: 'SmartCompact:report 查看效果,pause / resume 暂停或恢复',
|
|
156
|
+
handler: async (args, ctx) => {
|
|
157
|
+
const action = args.trim() || 'report'
|
|
158
|
+
if (action === 'pause' || action === 'resume') {
|
|
159
|
+
state = { ...state, paused: action === 'pause' }
|
|
160
|
+
persist()
|
|
161
|
+
ctx.ui.notify(action === 'pause' ? 'SmartCompact 已暂停:本会话不再干预压缩。' : 'SmartCompact 已恢复。', 'info')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
ctx.ui.notify(action === 'report' ? report(ctx) : '用法:/smartcompact [report|pause|resume]', 'info')
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
169
|
+
if (event.toolName === CHECKPOINT_TOOL) return
|
|
170
|
+
sample(ctx, lines(ctx))
|
|
171
|
+
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)
|
|
173
|
+
state = withSignals({ ...state, ledger }, signals)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
pi.on('before_agent_start', async (_event, ctx) => {
|
|
177
|
+
const current = lines(ctx)
|
|
178
|
+
const tokens = tokensOf(ctx)
|
|
179
|
+
if (!current || !shouldNudge({ tokens, lines: current, notesUpdated: state.notesUpdated, nudged: state.nudged, paused: state.paused }, params(ctx))) return
|
|
180
|
+
state = { ...state, nudged: true }
|
|
181
|
+
return { message: { customType: NUDGE_MESSAGE, content: nudgeText(tokens ?? 0), display: false } }
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// agent_settled:pi 不会再自动继续,此时压缩不会打断工作。
|
|
185
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
186
|
+
const current = lines(ctx)
|
|
187
|
+
if (!current) return
|
|
188
|
+
const tokens = sample(ctx, current)
|
|
189
|
+
persist()
|
|
190
|
+
const decision = decideAtTurnEnd(
|
|
191
|
+
{ tokens, lines: current, work: state.work, expectedAfterTokens: expectedAfterTokens(state, params(ctx).defaultAfterTokens), paused: state.paused },
|
|
192
|
+
params(ctx),
|
|
193
|
+
)
|
|
194
|
+
if (decision.action !== 'none') log({ event: 'turn-end', action: decision.action, reason: decision.reason, tokens })
|
|
195
|
+
if (decision.action !== 'compact' || !ctx.isIdle() || ctx.hasPendingMessages()) return
|
|
196
|
+
ours = true
|
|
197
|
+
ctx.compact({
|
|
198
|
+
customInstructions: COMPACT_INSTRUCTIONS,
|
|
199
|
+
onError: error => {
|
|
200
|
+
ours = false
|
|
201
|
+
log({ event: 'compacted', action: 'failed', trigger: 'plugin', detail: error.message })
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
pi.on('session_before_compact', async (event, ctx) => {
|
|
207
|
+
// 只在原生按阈值压缩时判断;上下文溢出的恢复压缩和用户的 /compact 一律放行。
|
|
208
|
+
if (event.reason !== 'threshold') return
|
|
209
|
+
const current = lines(ctx)
|
|
210
|
+
if (!current) return
|
|
211
|
+
const tokens = sample(ctx, current)
|
|
212
|
+
const decision = decideMidTurn(
|
|
213
|
+
{ tokens, lines: current, work: state.work, expectedAfterTokens: expectedAfterTokens(state, params(ctx).defaultAfterTokens), paused: state.paused },
|
|
214
|
+
params(ctx),
|
|
215
|
+
)
|
|
216
|
+
log({ event: 'mid-turn', action: decision.action, reason: decision.reason, tokens })
|
|
217
|
+
if (decision.action === 'veto') return { cancel: true }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
221
|
+
const fromPlugin = event.reason === 'manual' && ours
|
|
222
|
+
ours = false
|
|
223
|
+
const summaryTokens = Math.round(event.compactionEntry.summary.length / 4)
|
|
224
|
+
// 原生按阈值的压缩、溢出恢复和用户的 /compact,没有插件时也会发生;只有插件发起的压缩计入节省。
|
|
225
|
+
state = withCompaction(state, event.compactionEntry.tokensBefore, summaryTokens, fromPlugin)
|
|
226
|
+
persist()
|
|
227
|
+
log({ event: 'compacted', trigger: fromPlugin ? 'plugin' : event.reason === 'threshold' ? 'auto' : event.reason, tokensBefore: event.compactionEntry.tokensBefore })
|
|
228
|
+
pi.sendMessage(
|
|
229
|
+
{ customType: LEDGER_MESSAGE, content: renderLedger(state.ledger, params(ctx).ledgerMaxChars), display: false },
|
|
230
|
+
{ deliverAs: ctx.isIdle() ? 'nextTurn' : 'steer' },
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
pi.on('session_compact_failed', async () => {
|
|
235
|
+
ours = false
|
|
236
|
+
})
|
|
237
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 把 pi 的工具结果翻译成 core 的信号和笔记事实。
|
|
2
|
+
|
|
3
|
+
import { classifyCommand } from '../../plugin/src/core/commands.ts'
|
|
4
|
+
import { type Ledger, recordFileChanged } from '../../plugin/src/core/ledger.ts'
|
|
5
|
+
import type { Signal } from '../../plugin/src/core/signals.ts'
|
|
6
|
+
|
|
7
|
+
export type PiToolOutcome = {
|
|
8
|
+
toolName: string
|
|
9
|
+
input: Readonly<Record<string, unknown>>
|
|
10
|
+
isError: boolean
|
|
11
|
+
/** 结果里的文本内容 */
|
|
12
|
+
text: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const EDIT_TOOLS = new Set(['edit', 'write'])
|
|
16
|
+
|
|
17
|
+
export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals: Signal[]; ledger: Ledger } {
|
|
18
|
+
const signals: Signal[] = [{ kind: 'tool-result', isError: outcome.isError }]
|
|
19
|
+
let next = ledger
|
|
20
|
+
const { path, command } = outcome.input
|
|
21
|
+
if (EDIT_TOOLS.has(outcome.toolName) && !outcome.isError && typeof path === 'string' && path) {
|
|
22
|
+
next = recordFileChanged(next, path)
|
|
23
|
+
}
|
|
24
|
+
if (outcome.toolName === 'bash' && typeof command === 'string' && command) {
|
|
25
|
+
const classified = classifyCommand(command, outcome.isError, outcome.text, next)
|
|
26
|
+
signals.push(...classified.signals)
|
|
27
|
+
next = classified.ledger
|
|
28
|
+
}
|
|
29
|
+
return { signals, ledger: next }
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smartcompact",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Compact long agent sessions earlier, at a finished piece of work, to cut cost without losing results (Claude Code, pi, Codex)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "coffeecoproject",
|
|
8
|
+
"homepage": "https://github.com/coffeecoproject/SmartCompact",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/coffeecoproject/SmartCompact.git" },
|
|
10
|
+
"keywords": ["pi-package", "compaction", "context", "claude-code", "codex", "coding-agent"],
|
|
11
|
+
"engines": { "node": ">=22.18" },
|
|
12
|
+
"files": ["adapters/pi", "plugin/src/core", "README.md", "LICENSE"],
|
|
13
|
+
"pi": { "extensions": ["./adapters/pi/index.ts"] }
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// shell 命令结果的识别:提交、测试、构建。各平台的 shell 工具名不同,但命令和输出的规则相同。
|
|
2
|
+
|
|
3
|
+
import { type Ledger, recordBuild, recordCommit, recordTest } from './ledger.ts'
|
|
4
|
+
import type { Signal } from './signals.ts'
|
|
5
|
+
|
|
6
|
+
// 吞掉退出码的写法(`npm test || true`)看不出真实结果,不当作测试或构建结果。
|
|
7
|
+
const SWALLOWS_EXIT = /\|\|\s*(true|:)\b|;\s*(true|exit\s+0)\s*$/
|
|
8
|
+
// 接了管道(`npm test | tail`)又没开 pipefail 时,退出码是最后一个命令的,同样看不出真实结果。
|
|
9
|
+
const PIPE = /(^|[^|])\|(?!\|)/
|
|
10
|
+
const PIPEFAIL = /\bpipefail\b/
|
|
11
|
+
|
|
12
|
+
// 真的在执行 git commit:一段命令以它开头(不是 `echo "git commit"` 这类只是提到它的)。
|
|
13
|
+
// 不再核对提交是否真的成功:插件只在一轮结束、且超过允许压缩线时才压缩,误认一次失败的提交代价很小,
|
|
14
|
+
// 而核对规则(看输出、看退出码)会漏掉模型的各种写法(`-q`、提交后接 `git log`、前面连着接管道的测试)。
|
|
15
|
+
const COMMIT = /(^|&&|;|\n)\s*git(\s+-[Cc]\s+\S+)*\s+commit\b/
|
|
16
|
+
// git commit 正常输出 "[分支 短哈希] 提交说明",有就记进笔记,没有就记命令
|
|
17
|
+
const COMMIT_OUTPUT = /^\[[^\]\n]+ [0-9a-f]{7,40}\][^\n]*/m
|
|
18
|
+
|
|
19
|
+
const TEST =
|
|
20
|
+
/\b(npm|pnpm|yarn|bun)\b[^|;&\n]*\s(run\s+)?test(:[\w:-]+)?(?=[\s;&|)]|$)|\bvitest\b|\bjest\b|\bpytest\b|\bgo\s+test\b|\bcargo\s+(test|nextest)\b|\bmvn\b.*\btest\b|\bgradlew?\b.*\btest\b|\bdotnet\s+test\b|\bswift\s+test\b|\bxcodebuild\b.*\btest\b|\bphpunit\b|\brspec\b|\bclaude\s+plugin\s+test\b|\bnode\s+--test\b/
|
|
21
|
+
|
|
22
|
+
const BUILD =
|
|
23
|
+
/\btsc\b|\b(npm|pnpm|yarn|bun)\s+run\s+(build|typecheck|check)\b|\bcargo\s+(build|check)\b|\bgo\s+(build|vet)\b|\bxcodebuild\b|\bmake\b|\bgradlew?\b.*\b(build|assemble)\b/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param isError 命令是否以非零退出码结束
|
|
27
|
+
* @param output 命令输出
|
|
28
|
+
*/
|
|
29
|
+
export function classifyCommand(command: string, isError: boolean, output: string, ledger: Ledger): { signals: Signal[]; ledger: Ledger } {
|
|
30
|
+
const signals: Signal[] = []
|
|
31
|
+
let next = ledger
|
|
32
|
+
if (COMMIT.test(command) && !isError && !/--dry-run\b/.test(command)) {
|
|
33
|
+
signals.push({ kind: 'commit' })
|
|
34
|
+
next = recordCommit(next, COMMIT_OUTPUT.exec(output)?.[0] ?? command.slice(0, 200))
|
|
35
|
+
}
|
|
36
|
+
if (!SWALLOWS_EXIT.test(command) && !(PIPE.test(command) && !PIPEFAIL.test(command))) {
|
|
37
|
+
const passed = !isError
|
|
38
|
+
if (TEST.test(command)) {
|
|
39
|
+
signals.push({ kind: 'tests', passed })
|
|
40
|
+
next = recordTest(next, command.slice(0, 200), passed)
|
|
41
|
+
} else if (BUILD.test(command)) {
|
|
42
|
+
signals.push({ kind: 'build', passed })
|
|
43
|
+
next = recordBuild(next, command.slice(0, 200), passed)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { signals, ledger: next }
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 两个决策函数:一轮结束时要不要主动压缩;一轮进行中原生自动压缩来问时放不放行。
|
|
2
|
+
// 读不到用量时一律不干预,按原生行为来。
|
|
3
|
+
|
|
4
|
+
import type { Lines } from './lines.ts'
|
|
5
|
+
import type { Params } from './params.ts'
|
|
6
|
+
import { type HardSignal, hardSignal, type MilestoneSource, type WorkState } from './signals.ts'
|
|
7
|
+
|
|
8
|
+
export type DecisionInput = {
|
|
9
|
+
/** 当前上下文用量;undefined 表示读不到 */
|
|
10
|
+
tokens: number | undefined
|
|
11
|
+
lines: Lines
|
|
12
|
+
work: WorkState
|
|
13
|
+
/** 预计压缩后的大小(上次实测值,没有时用默认估计) */
|
|
14
|
+
expectedAfterTokens: number
|
|
15
|
+
paused: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TurnEndDecision =
|
|
19
|
+
| { action: 'none'; reason: 'paused' | 'no-reading' | 'below-line' }
|
|
20
|
+
| { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'waiting-for-milestone' }
|
|
21
|
+
| { action: 'compact'; reason: MilestoneSource | 'past-grace' }
|
|
22
|
+
|
|
23
|
+
export type MidTurnDecision =
|
|
24
|
+
| { action: 'allow'; reason: 'paused' | 'no-reading' | 'at-ceiling' | 'default' }
|
|
25
|
+
| { action: 'veto'; reason: HardSignal | 'insufficient-benefit' }
|
|
26
|
+
|
|
27
|
+
function freesEnough(tokens: number, input: DecisionInput, params: Params): boolean {
|
|
28
|
+
return tokens - input.expectedAfterTokens >= params.minFreedTokens
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function decideAtTurnEnd(input: DecisionInput, params: Params): TurnEndDecision {
|
|
32
|
+
const { tokens, lines, work } = input
|
|
33
|
+
if (input.paused) return { action: 'none', reason: 'paused' }
|
|
34
|
+
if (tokens === undefined) return { action: 'none', reason: 'no-reading' }
|
|
35
|
+
if (tokens < lines.allow) return { action: 'none', reason: 'below-line' }
|
|
36
|
+
const hard = hardSignal(work, params)
|
|
37
|
+
if (hard) return { action: 'wait', reason: hard }
|
|
38
|
+
if (!freesEnough(tokens, input, params)) return { action: 'wait', reason: 'insufficient-benefit' }
|
|
39
|
+
if (work.milestone) return { action: 'compact', reason: work.milestone }
|
|
40
|
+
if (tokens >= lines.midTurn) return { action: 'compact', reason: 'past-grace' }
|
|
41
|
+
return { action: 'wait', reason: 'waiting-for-milestone' }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 快到允许压缩线、本周期笔记没更新也没提醒过时,提醒模型更新笔记。 */
|
|
45
|
+
export function shouldNudge(
|
|
46
|
+
input: { tokens: number | undefined; lines: Lines; notesUpdated: boolean; nudged: boolean; paused: boolean },
|
|
47
|
+
params: Params,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (input.paused || input.notesUpdated || input.nudged || input.tokens === undefined) return false
|
|
50
|
+
return input.tokens >= input.lines.allow * params.nudgeAtShare
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function decideMidTurn(input: DecisionInput, params: Params): MidTurnDecision {
|
|
54
|
+
const { tokens, lines, work } = input
|
|
55
|
+
if (input.paused) return { action: 'allow', reason: 'paused' }
|
|
56
|
+
if (tokens === undefined) return { action: 'allow', reason: 'no-reading' }
|
|
57
|
+
// 用量读数不含最新的工具结果,接近上限时留出余量,绝不挡住快满时的压缩。
|
|
58
|
+
if (tokens >= lines.ceiling - params.ceilingMarginTokens) return { action: 'allow', reason: 'at-ceiling' }
|
|
59
|
+
const hard = hardSignal(work, params)
|
|
60
|
+
if (hard) return { action: 'veto', reason: hard }
|
|
61
|
+
if (!freesEnough(tokens, input, params)) return { action: 'veto', reason: 'insufficient-benefit' }
|
|
62
|
+
return { action: 'allow', reason: 'default' }
|
|
63
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// 进度笔记:程序自动记录的事实 + 模型通过 checkpoint 补充的内容。
|
|
2
|
+
// core 只负责内容和排版,怎么补回上下文由适配层决定。
|
|
3
|
+
|
|
4
|
+
export type LedgerFacts = {
|
|
5
|
+
filesChanged: string[]
|
|
6
|
+
lastTest?: { command: string; passed: boolean }
|
|
7
|
+
lastBuild?: { command: string; passed: boolean }
|
|
8
|
+
lastCommit?: { summary: string }
|
|
9
|
+
tasksInProgress: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type LedgerNotes = {
|
|
13
|
+
goal?: string
|
|
14
|
+
decisions: string[]
|
|
15
|
+
next?: string
|
|
16
|
+
current?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Ledger = { facts: LedgerFacts; notes: LedgerNotes }
|
|
20
|
+
|
|
21
|
+
export const EMPTY_LEDGER: Ledger = Object.freeze({
|
|
22
|
+
facts: Object.freeze({ filesChanged: [], tasksInProgress: [] }),
|
|
23
|
+
notes: Object.freeze({ decisions: [] }),
|
|
24
|
+
}) as Ledger
|
|
25
|
+
|
|
26
|
+
/** 笔记文本的开头,适配层据此认出上一次放入的笔记并替换。 */
|
|
27
|
+
export const LEDGER_MARKER = '[SmartCompact progress ledger]'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 插件发起压缩时给摘要的说明。用户顺口交代的具体信息(人名、日期、编号)当下代码用不到,
|
|
31
|
+
* 摘要最容易丢,进度笔记也不记,所以要明确要求原样保留。
|
|
32
|
+
*/
|
|
33
|
+
export const COMPACT_INSTRUCTIONS =
|
|
34
|
+
'Preserve the current goal, decisions and their reasons, unfinished work, failed attempts, and exact file paths. ' +
|
|
35
|
+
'Also keep every specific fact the user stated, verbatim, even if the current work does not need it: ' +
|
|
36
|
+
'names of people and customers, dates and time windows, ticket or ID numbers, and constraints. ' +
|
|
37
|
+
'A SmartCompact progress ledger is attached after the summary; do not restate it.'
|
|
38
|
+
|
|
39
|
+
const MAX_FILES = 30
|
|
40
|
+
const MAX_DECISIONS = 20
|
|
41
|
+
const MAX_FIELD_CHARS = 1_000
|
|
42
|
+
|
|
43
|
+
export function recordFileChanged(ledger: Ledger, path: string): Ledger {
|
|
44
|
+
const files = [...ledger.facts.filesChanged.filter(file => file !== path), path].slice(-MAX_FILES)
|
|
45
|
+
return { ...ledger, facts: { ...ledger.facts, filesChanged: files } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordTest(ledger: Ledger, command: string, passed: boolean): Ledger {
|
|
49
|
+
return { ...ledger, facts: { ...ledger.facts, lastTest: { command, passed } } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function recordBuild(ledger: Ledger, command: string, passed: boolean): Ledger {
|
|
53
|
+
return { ...ledger, facts: { ...ledger.facts, lastBuild: { command, passed } } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function recordCommit(ledger: Ledger, summary: string): Ledger {
|
|
57
|
+
return { ...ledger, facts: { ...ledger.facts, lastCommit: { summary } } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function recordTasksInProgress(ledger: Ledger, tasks: readonly string[]): Ledger {
|
|
61
|
+
return { ...ledger, facts: { ...ledger.facts, tasksInProgress: [...tasks] } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CheckpointInput = {
|
|
65
|
+
goal?: string
|
|
66
|
+
decisions?: string[]
|
|
67
|
+
next?: string
|
|
68
|
+
current?: string
|
|
69
|
+
/** 模型声明刚完成一块工作 */
|
|
70
|
+
milestone?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** checkpoint 工具的输入 schema,适配层注册工具时使用。 */
|
|
74
|
+
export const CHECKPOINT_INPUT_SCHEMA = {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
goal: { type: 'string', description: 'The overall goal of the current work.' },
|
|
78
|
+
decisions: { type: 'array', items: { type: 'string' }, description: 'Decisions made since the last checkpoint.' },
|
|
79
|
+
next: { type: 'string', description: 'What to do next.' },
|
|
80
|
+
current: { type: 'string', description: 'Where the work stands right now.' },
|
|
81
|
+
milestone: { type: 'boolean', description: 'True when a piece of work was just finished.' },
|
|
82
|
+
},
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
} as const
|
|
85
|
+
|
|
86
|
+
export type CheckpointParse = { ok: true; value: CheckpointInput } | { ok: false; error: string }
|
|
87
|
+
|
|
88
|
+
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0
|
|
89
|
+
const clip = (value: string): string => value.trim().slice(0, MAX_FIELD_CHARS)
|
|
90
|
+
|
|
91
|
+
export function parseCheckpoint(raw: unknown): CheckpointParse {
|
|
92
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'input must be an object' }
|
|
93
|
+
const input = raw as Record<string, unknown>
|
|
94
|
+
const value: CheckpointInput = {}
|
|
95
|
+
for (const key of ['goal', 'next', 'current'] as const) {
|
|
96
|
+
const field = input[key]
|
|
97
|
+
if (field === undefined) continue
|
|
98
|
+
if (!text(field)) return { ok: false, error: `${key} must be a non-empty string` }
|
|
99
|
+
value[key] = clip(field)
|
|
100
|
+
}
|
|
101
|
+
const { decisions, milestone } = input
|
|
102
|
+
if (decisions !== undefined) {
|
|
103
|
+
if (!Array.isArray(decisions) || !decisions.every(text)) {
|
|
104
|
+
return { ok: false, error: 'decisions must be an array of non-empty strings' }
|
|
105
|
+
}
|
|
106
|
+
value.decisions = decisions.map(clip)
|
|
107
|
+
}
|
|
108
|
+
if (milestone !== undefined) {
|
|
109
|
+
if (typeof milestone !== 'boolean') return { ok: false, error: 'milestone must be a boolean' }
|
|
110
|
+
value.milestone = milestone
|
|
111
|
+
}
|
|
112
|
+
if (!value.goal && !value.next && !value.current && !value.decisions?.length) {
|
|
113
|
+
return { ok: false, error: 'record at least one of goal, decisions, next, current' }
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, value }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger {
|
|
119
|
+
const decisions = [...ledger.notes.decisions, ...(input.decisions ?? [])]
|
|
120
|
+
return {
|
|
121
|
+
...ledger,
|
|
122
|
+
notes: {
|
|
123
|
+
goal: input.goal ?? ledger.notes.goal,
|
|
124
|
+
next: input.next ?? ledger.notes.next,
|
|
125
|
+
current: input.current ?? ledger.notes.current,
|
|
126
|
+
decisions: [...new Set(decisions)].slice(-MAX_DECISIONS),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 排版成补回上下文的文本,超长时截断。 */
|
|
132
|
+
export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
133
|
+
const { facts, notes } = ledger
|
|
134
|
+
const lines = [
|
|
135
|
+
LEDGER_MARKER,
|
|
136
|
+
'Generated by the SmartCompact plugin to keep continuity across compaction. It is not a message from the user.',
|
|
137
|
+
]
|
|
138
|
+
const section = (title: string, body: (string | undefined)[]) => {
|
|
139
|
+
const rows = body.filter((row): row is string => Boolean(row))
|
|
140
|
+
if (rows.length) lines.push('', `## ${title}`, ...rows)
|
|
141
|
+
}
|
|
142
|
+
section('Goal', [notes.goal])
|
|
143
|
+
section('Current state', [notes.current])
|
|
144
|
+
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
|
+
section('Latest results', [
|
|
149
|
+
facts.lastTest && `- tests ${facts.lastTest.passed ? 'passed' : 'FAILED'}: ${facts.lastTest.command}`,
|
|
150
|
+
facts.lastBuild && `- build ${facts.lastBuild.passed ? 'passed' : 'FAILED'}: ${facts.lastBuild.command}`,
|
|
151
|
+
facts.lastCommit && `- last commit: ${facts.lastCommit.summary}`,
|
|
152
|
+
])
|
|
153
|
+
const rendered = lines.join('\n')
|
|
154
|
+
return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isLedgerText(value: string): boolean {
|
|
158
|
+
return value.startsWith(LEDGER_MARKER)
|
|
159
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 三条线:允许压缩线 L、轮中兜底线 M、上限 C(原生压缩点)。
|
|
2
|
+
|
|
3
|
+
import type { Params } from './params.ts'
|
|
4
|
+
|
|
5
|
+
export type Lines = {
|
|
6
|
+
allow: number
|
|
7
|
+
midTurn: number
|
|
8
|
+
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 决策用到的全部参数。规则里不写死数字,试用后按数据校准,也可由用户配置覆盖。
|
|
2
|
+
|
|
3
|
+
export type Params = {
|
|
4
|
+
/** 允许压缩线 L:一轮结束时超过它才考虑压缩 */
|
|
5
|
+
allowLineTokens: number
|
|
6
|
+
/** 轮中兜底线 M = L × graceFactor */
|
|
7
|
+
graceFactor: number
|
|
8
|
+
/** 一次压缩至少要腾出的空间,不够就不压,防止连续压缩 */
|
|
9
|
+
minFreedTokens: number
|
|
10
|
+
/** 还没实测到压缩后大小时,用这个估计值 */
|
|
11
|
+
defaultAfterTokens: number
|
|
12
|
+
/** 原生触发点 ≈ 压缩线设定值 − 这个值(实测约 33k) */
|
|
13
|
+
nativeBufferTokens: number
|
|
14
|
+
/** 用量读数可能落后于最新的工具结果,接近上限时留出的余量 */
|
|
15
|
+
ceilingMarginTokens: number
|
|
16
|
+
/** 连续报错多少次算硬信号 */
|
|
17
|
+
errorStreakLimit: number
|
|
18
|
+
/** 进度笔记的最大字符数 */
|
|
19
|
+
ledgerMaxChars: number
|
|
20
|
+
/** 用量到 L × 这个比例、笔记又没更新时,提醒模型记一下 */
|
|
21
|
+
nudgeAtShare: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
25
|
+
allowLineTokens: 200_000,
|
|
26
|
+
graceFactor: 1.3,
|
|
27
|
+
minFreedTokens: 60_000,
|
|
28
|
+
defaultAfterTokens: 50_000,
|
|
29
|
+
nativeBufferTokens: 33_000,
|
|
30
|
+
ceilingMarginTokens: 50_000,
|
|
31
|
+
errorStreakLimit: 3,
|
|
32
|
+
ledgerMaxChars: 6_000,
|
|
33
|
+
nudgeAtShare: 0.85,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const MINIMUMS: Record<keyof Params, number> = {
|
|
37
|
+
allowLineTokens: 1,
|
|
38
|
+
graceFactor: 1,
|
|
39
|
+
minFreedTokens: 0,
|
|
40
|
+
defaultAfterTokens: 0,
|
|
41
|
+
nativeBufferTokens: 0,
|
|
42
|
+
ceilingMarginTokens: 0,
|
|
43
|
+
errorStreakLimit: 1,
|
|
44
|
+
ledgerMaxChars: 200,
|
|
45
|
+
nudgeAtShare: 0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 用户覆盖的值逐项校验,不合法的项沿用默认值。 */
|
|
49
|
+
export function resolveParams(overrides: Readonly<Record<string, unknown>> = {}): Params {
|
|
50
|
+
const resolved = { ...DEFAULT_PARAMS }
|
|
51
|
+
for (const key of Object.keys(DEFAULT_PARAMS) as (keyof Params)[]) {
|
|
52
|
+
const value = overrides[key]
|
|
53
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= MINIMUMS[key]) resolved[key] = value
|
|
54
|
+
}
|
|
55
|
+
return resolved
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// /smartcompact report 的内容:当前状态、决策次数、信号次数、成本估算。只排版,不读写。
|
|
2
|
+
|
|
3
|
+
import type { Lines } from './lines.ts'
|
|
4
|
+
import type { DecisionLogEntry, SessionState } from './state.ts'
|
|
5
|
+
import { savingsRatio } from './usage.ts'
|
|
6
|
+
|
|
7
|
+
export type DecisionCounts = Record<string, number>
|
|
8
|
+
|
|
9
|
+
/** 按"事件:动作"统计决策日志,例如 compacted:plugin、mid-turn:veto。 */
|
|
10
|
+
export function countDecisions(entries: readonly DecisionLogEntry[]): DecisionCounts {
|
|
11
|
+
const counts: DecisionCounts = {}
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const key = entry.event === 'compacted' ? `compacted:${entry.action ?? entry.trigger ?? ''}` : `${entry.event}:${entry.action ?? ''}`
|
|
14
|
+
counts[key] = (counts[key] ?? 0) + 1
|
|
15
|
+
}
|
|
16
|
+
return counts
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const k = (tokens: number | undefined): string => (tokens === undefined ? '—' : `${Math.round(tokens / 1000)}k`)
|
|
20
|
+
|
|
21
|
+
export function renderReport(input: {
|
|
22
|
+
state: SessionState
|
|
23
|
+
lines: Lines
|
|
24
|
+
tokens: number | undefined
|
|
25
|
+
counts: DecisionCounts
|
|
26
|
+
}): string {
|
|
27
|
+
const { state, lines, tokens, counts } = input
|
|
28
|
+
const ratio = savingsRatio(state.usage)
|
|
29
|
+
const seen = state.seen
|
|
30
|
+
return [
|
|
31
|
+
`SmartCompact${state.paused ? '(已暂停)' : ''}`,
|
|
32
|
+
`- 当前用量 ${k(tokens)};允许压缩线 ${k(lines.allow)},兜底线 ${k(lines.midTurn)},上限 ${k(lines.ceiling)}${lines.adjustsNative ? '' : '(原生压缩线未调整)'}`,
|
|
33
|
+
`- 本会话压缩 ${state.compactions} 次;最近一次压缩后约 ${k(state.measuredAfterTokens)}`,
|
|
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},后台任务 ${seen.background}`,
|
|
36
|
+
`- 当前状态:${state.work.milestone ? `已到阶段节点(${state.work.milestone})` : '等待阶段节点'}${state.work.testsFailing ? ',测试失败中' : ''}${state.work.buildFailing ? ',构建失败中' : ''}`,
|
|
37
|
+
ratio === undefined
|
|
38
|
+
? '- 成本估算:还没有读数'
|
|
39
|
+
: `- 成本估算:上下文成本约节省 ${Math.round(ratio * 100)}%(${state.usage.samples} 次读数,已扣除压缩本身的开销;对比的是没有插件、一直涨到原生压缩点的情况)${
|
|
40
|
+
ratio < 0 ? '。刚压缩完时压缩开销还没赚回来,继续使用后会转正' : ''
|
|
41
|
+
}`,
|
|
42
|
+
].join('\n')
|
|
43
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 信号词表与工作状态。适配层把平台事件翻译成这里的信号,core 不认识任何平台的工具名。
|
|
2
|
+
|
|
3
|
+
import type { Params } from './params.ts'
|
|
4
|
+
|
|
5
|
+
export type Signal =
|
|
6
|
+
| { kind: 'commit' }
|
|
7
|
+
| { kind: 'tests'; passed: boolean }
|
|
8
|
+
| { kind: 'build'; passed: boolean }
|
|
9
|
+
| { kind: 'task-completed' }
|
|
10
|
+
| { kind: 'checkpoint'; milestone: boolean }
|
|
11
|
+
| { kind: 'tool-result'; isError: boolean }
|
|
12
|
+
| { kind: 'background'; running: number }
|
|
13
|
+
|
|
14
|
+
export type MilestoneSource = 'commit' | 'tests-passed' | 'task-completed' | 'checkpoint'
|
|
15
|
+
|
|
16
|
+
export type HardSignal = 'tests-failing' | 'build-failing' | 'error-streak' | 'background-running'
|
|
17
|
+
|
|
18
|
+
export type WorkState = {
|
|
19
|
+
/** 自上次压缩以来最近的阶段节点 */
|
|
20
|
+
milestone: MilestoneSource | null
|
|
21
|
+
testsFailing: boolean
|
|
22
|
+
buildFailing: boolean
|
|
23
|
+
errorStreak: number
|
|
24
|
+
backgroundRunning: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const INITIAL_WORK_STATE: WorkState = Object.freeze({
|
|
28
|
+
milestone: null,
|
|
29
|
+
testsFailing: false,
|
|
30
|
+
buildFailing: false,
|
|
31
|
+
errorStreak: 0,
|
|
32
|
+
backgroundRunning: 0,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** 这个信号代表哪种阶段节点,不是阶段节点时为 null。 */
|
|
36
|
+
export function milestoneOf(signal: Signal): MilestoneSource | null {
|
|
37
|
+
switch (signal.kind) {
|
|
38
|
+
case 'commit':
|
|
39
|
+
return 'commit'
|
|
40
|
+
case 'tests':
|
|
41
|
+
return signal.passed ? 'tests-passed' : null
|
|
42
|
+
case 'task-completed':
|
|
43
|
+
return 'task-completed'
|
|
44
|
+
case 'checkpoint':
|
|
45
|
+
return signal.milestone ? 'checkpoint' : null
|
|
46
|
+
default:
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
52
|
+
const next = { ...state, milestone: milestoneOf(signal) ?? state.milestone }
|
|
53
|
+
switch (signal.kind) {
|
|
54
|
+
case 'tests':
|
|
55
|
+
return { ...next, testsFailing: !signal.passed }
|
|
56
|
+
case 'build':
|
|
57
|
+
return { ...next, buildFailing: !signal.passed }
|
|
58
|
+
case 'tool-result':
|
|
59
|
+
return { ...next, errorStreak: signal.isError ? state.errorStreak + 1 : 0 }
|
|
60
|
+
case 'background':
|
|
61
|
+
return { ...next, backgroundRunning: Math.max(0, signal.running) }
|
|
62
|
+
default:
|
|
63
|
+
return next
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 压缩后清空阶段节点;失败、后台任务这些仍然成立的事实保留。 */
|
|
68
|
+
export function afterCompaction(state: WorkState): WorkState {
|
|
69
|
+
return { ...state, milestone: null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 表明工作做到一半的硬信号,没有则返回 null。 */
|
|
73
|
+
export function hardSignal(state: WorkState, params: Params): HardSignal | null {
|
|
74
|
+
if (state.testsFailing) return 'tests-failing'
|
|
75
|
+
if (state.buildFailing) return 'build-failing'
|
|
76
|
+
if (state.errorStreak >= params.errorStreakLimit) return 'error-streak'
|
|
77
|
+
if (state.backgroundRunning > 0) return 'background-running'
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// 会话状态与决策日志的数据格式。读写由适配层负责,core 只定义格式。
|
|
2
|
+
|
|
3
|
+
import { EMPTY_LEDGER, type Ledger } from './ledger.ts'
|
|
4
|
+
import { afterCompaction, applySignal, INITIAL_WORK_STATE, milestoneOf, type MilestoneSource, type Signal, type WorkState } from './signals.ts'
|
|
5
|
+
import { EMPTY_TALLY, type Prices, recordCompaction, recordSample, type UsageTally } from './usage.ts'
|
|
6
|
+
|
|
7
|
+
export type SignalsSeen = Record<MilestoneSource | 'background', number>
|
|
8
|
+
|
|
9
|
+
export type SessionState = {
|
|
10
|
+
work: WorkState
|
|
11
|
+
ledger: Ledger
|
|
12
|
+
/** 最近一次实测的压缩后大小 */
|
|
13
|
+
measuredAfterTokens?: number
|
|
14
|
+
compactions: number
|
|
15
|
+
paused: boolean
|
|
16
|
+
/** 自上次压缩以来,模型是否通过 checkpoint 更新过笔记 */
|
|
17
|
+
notesUpdated: boolean
|
|
18
|
+
/** 自上次压缩以来,是否提醒过模型更新笔记 */
|
|
19
|
+
nudged: boolean
|
|
20
|
+
/** 各种信号出现过的次数,用于确认识别是否生效 */
|
|
21
|
+
seen: SignalsSeen
|
|
22
|
+
usage: UsageTally
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const INITIAL_SESSION_STATE: SessionState = Object.freeze({
|
|
26
|
+
work: INITIAL_WORK_STATE,
|
|
27
|
+
ledger: EMPTY_LEDGER,
|
|
28
|
+
compactions: 0,
|
|
29
|
+
paused: false,
|
|
30
|
+
notesUpdated: false,
|
|
31
|
+
nudged: false,
|
|
32
|
+
seen: Object.freeze({ commit: 0, 'tests-passed': 0, 'task-completed': 0, checkpoint: 0, background: 0 }),
|
|
33
|
+
usage: EMPTY_TALLY,
|
|
34
|
+
}) as SessionState
|
|
35
|
+
|
|
36
|
+
/** 读回的状态结构不对时,从初始状态开始。 */
|
|
37
|
+
export function parseState(raw: unknown): SessionState {
|
|
38
|
+
if (!raw || typeof raw !== 'object') return INITIAL_SESSION_STATE
|
|
39
|
+
const state = raw as Partial<SessionState>
|
|
40
|
+
if (!state.work || !state.ledger || !state.seen || !state.usage) return INITIAL_SESSION_STATE
|
|
41
|
+
return state as SessionState
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function expectedAfterTokens(state: SessionState, defaultAfterTokens: number): number {
|
|
45
|
+
return state.measuredAfterTokens ?? defaultAfterTokens
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 应用信号,同时更新信号计数。 */
|
|
49
|
+
export function withSignals(state: SessionState, signals: readonly Signal[]): SessionState {
|
|
50
|
+
const seen = { ...state.seen }
|
|
51
|
+
for (const signal of signals) {
|
|
52
|
+
const milestone = milestoneOf(signal)
|
|
53
|
+
if (milestone) seen[milestone] += 1
|
|
54
|
+
if (signal.kind === 'background' && signal.running > 0) seen.background += 1
|
|
55
|
+
}
|
|
56
|
+
return { ...state, work: signals.reduce(applySignal, state.work), seen }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 记一次用量读数:压缩后的第一次读数同时记为实测的压缩后大小。 */
|
|
60
|
+
export function withSample(state: SessionState, tokens: number, ceiling: number, prices: Prices): SessionState {
|
|
61
|
+
const measured = state.usage.pending ? { measuredAfterTokens: tokens } : {}
|
|
62
|
+
return { ...state, ...measured, usage: recordSample(state.usage, tokens, ceiling, prices) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 一次压缩完成:清空阶段节点和本周期的笔记标记,等待读到压缩后大小。
|
|
67
|
+
* @param attributed 是否算作插件带来的压缩;用户手动 /compact 没有插件时也会发生,不计入节省
|
|
68
|
+
*/
|
|
69
|
+
export function withCompaction(state: SessionState, before: number, summary: number, attributed = true): SessionState {
|
|
70
|
+
return {
|
|
71
|
+
...state,
|
|
72
|
+
work: afterCompaction(state.work),
|
|
73
|
+
compactions: state.compactions + 1,
|
|
74
|
+
notesUpdated: false,
|
|
75
|
+
nudged: false,
|
|
76
|
+
usage: attributed ? recordCompaction(state.usage, before, summary) : state.usage,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type DecisionLogEntry = {
|
|
81
|
+
at: string
|
|
82
|
+
/** 会话 ID,报告据此只统计当前会话 */
|
|
83
|
+
session?: string
|
|
84
|
+
event: 'setup' | 'turn-end' | 'mid-turn' | 'compacted' | 'checkpoint' | 'error'
|
|
85
|
+
action?: string
|
|
86
|
+
reason?: string
|
|
87
|
+
tokens?: number
|
|
88
|
+
tokensBefore?: number
|
|
89
|
+
tokensAfter?: number
|
|
90
|
+
trigger?: string
|
|
91
|
+
detail?: string
|
|
92
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 成本估算:把实际上下文和"没有插件时会是多大"逐次对比,并扣掉压缩本身的开销。
|
|
2
|
+
// 单位都是"缓存读取 token":每次调用的成本 ≈ 当时的上下文大小。
|
|
3
|
+
|
|
4
|
+
/** 压缩摘要的输出价、压缩后新前缀的缓存写入价,各相当于缓存读取价的多少倍。 */
|
|
5
|
+
export type Prices = { outputOverCacheRead: number; cacheWriteOverCacheRead: number }
|
|
6
|
+
|
|
7
|
+
// Anthropic:缓存读取 0.1、1 小时缓存写入 2、输出 5(以输入价为 1)。
|
|
8
|
+
const ANTHROPIC: Prices = Object.freeze({ outputOverCacheRead: 50, cacheWriteOverCacheRead: 20 })
|
|
9
|
+
// OpenAI:缓存读取 0.1、写入不加价 1、输出 8。
|
|
10
|
+
const OPENAI: Prices = Object.freeze({ outputOverCacheRead: 80, cacheWriteOverCacheRead: 10 })
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 按模型选价格比例,传入供应商名和模型名(例如 "github-copilot gpt-5")。
|
|
14
|
+
* 不认识的按 Anthropic 算,压缩开销估得偏高、结论偏保守。
|
|
15
|
+
*/
|
|
16
|
+
export function pricesFor(model: string): Prices {
|
|
17
|
+
return /openai|codex|gpt|\bo[134]\b/i.test(model) ? OPENAI : ANTHROPIC
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type UsageTally = {
|
|
21
|
+
/** 计入的读数次数 */
|
|
22
|
+
samples: number
|
|
23
|
+
/** 实际上下文之和 */
|
|
24
|
+
sumTokens: number
|
|
25
|
+
/** 没有插件时上下文之和 */
|
|
26
|
+
sumBaseline: number
|
|
27
|
+
/** 本插件压缩累计腾出的空间:没有插件时的上下文 = 实际 + freed */
|
|
28
|
+
freed: number
|
|
29
|
+
/** 压缩本身的开销之和 */
|
|
30
|
+
overhead: number
|
|
31
|
+
lastTokens?: number
|
|
32
|
+
/** 刚压缩完、还没读到压缩后大小时,记下压缩前大小和摘要长度 */
|
|
33
|
+
pending?: { before: number; summary: number }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const EMPTY_TALLY: UsageTally = Object.freeze({ samples: 0, sumTokens: 0, sumBaseline: 0, freed: 0, overhead: 0 })
|
|
37
|
+
|
|
38
|
+
export function recordCompaction(tally: UsageTally, before: number, summary: number): UsageTally {
|
|
39
|
+
return { ...tally, pending: { before, summary } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 记一次用量读数。同一次调用可能读到多次(并行的工具调用),和上一次相同的读数不重复计入。
|
|
44
|
+
* @param ceiling 原生压缩点:没有插件时上下文超过它,原生也会压缩
|
|
45
|
+
*/
|
|
46
|
+
export function recordSample(tally: UsageTally, tokens: number, ceiling: number, prices: Prices): UsageTally {
|
|
47
|
+
if (tokens === tally.lastTokens) return tally
|
|
48
|
+
let { freed, overhead } = tally
|
|
49
|
+
if (tally.pending) {
|
|
50
|
+
const { before, summary } = tally.pending
|
|
51
|
+
freed += Math.max(0, before - tokens)
|
|
52
|
+
overhead += before + prices.outputOverCacheRead * summary + prices.cacheWriteOverCacheRead * tokens
|
|
53
|
+
}
|
|
54
|
+
// 没有插件时,涨过原生压缩点也会被压缩,之后两边大小相近。
|
|
55
|
+
if (tokens + freed > ceiling) freed = 0
|
|
56
|
+
return {
|
|
57
|
+
samples: tally.samples + 1,
|
|
58
|
+
sumTokens: tally.sumTokens + tokens,
|
|
59
|
+
sumBaseline: tally.sumBaseline + tokens + freed,
|
|
60
|
+
freed,
|
|
61
|
+
overhead,
|
|
62
|
+
lastTokens: tokens,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 估算节省的比例:1 − (实际 + 压缩开销) / 没有插件时。没有读数时为 undefined。 */
|
|
67
|
+
export function savingsRatio(tally: UsageTally): number | undefined {
|
|
68
|
+
if (tally.sumBaseline <= 0) return undefined
|
|
69
|
+
return 1 - (tally.sumTokens + tally.overhead) / tally.sumBaseline
|
|
70
|
+
}
|