smartcompact 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +8 -7
- package/adapters/pi/index.ts +98 -36
- package/package.json +1 -1
- package/plugin/README.md +7 -6
- package/plugin/src/core/decide.ts +13 -0
- package/plugin/src/core/ledger.ts +73 -21
- package/plugin/src/core/params.ts +8 -0
- package/plugin/src/core/pending.ts +30 -0
- package/plugin/src/core/reminder.ts +54 -0
- package/plugin/src/core/report.ts +1 -1
- package/plugin/src/core/state.ts +18 -3
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
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) and no file has been changed since.
|
|
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.
|
|
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. It also waits until you have been idle for 3 minutes, so a back-and-forth discussion is not cut off.
|
|
18
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
|
-
- **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.
|
|
19
|
+
- **A progress ledger bridges the gap.** Files changed, the latest commit and test result, the user requirements and decisions the agent recorded, and the agent's last reply 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
|
|
|
22
22
|
## Results
|
|
@@ -68,21 +68,22 @@ Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompac
|
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| Allow line | 200000 | Above it, compact when a turn ends once a piece of work has been finished |
|
|
70
70
|
| Minimum freed | 60000 | Skip compaction if it is expected to free less than this |
|
|
71
|
+
| Idle wait | 180 | After deciding to compact, wait until you have been idle this many seconds; a new message or command cancels it |
|
|
71
72
|
|
|
72
|
-
- Claude Code: the plugin options `allowLineTokens` and `
|
|
73
|
-
- Pi: the flags `--smartcompact-allow-line` and `--smartcompact-
|
|
74
|
-
- Codex: the environment variables `SMARTCOMPACT_ALLOW_LINE` and `
|
|
73
|
+
- Claude Code: the plugin options `allowLineTokens`, `minFreedTokens` and `compactIdleSeconds`; leave them empty for the defaults.
|
|
74
|
+
- Pi: the flags `--smartcompact-allow-line`, `--smartcompact-min-freed` and `--smartcompact-idle-seconds`.
|
|
75
|
+
- Codex: the environment variables `SMARTCOMPACT_ALLOW_LINE`, `SMARTCOMPACT_MIN_FREED` and `SMARTCOMPACT_IDLE_SECONDS`.
|
|
75
76
|
|
|
76
77
|
## Seeing what it did
|
|
77
78
|
|
|
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
|
+
- 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 the user's requirements and decisions, which come back with the ledger after compaction.
|
|
79
80
|
- Codex: `node ~/.codex/plugins/cache/smartcompact/smartcompact/<version>/adapters/codex/report.ts <session ID>` (Node.js 22.18+); records are kept in `~/.codex/smartcompact/`.
|
|
80
81
|
|
|
81
82
|
## FAQ
|
|
82
83
|
|
|
83
84
|
**How does it know a piece of work is finished?** From the commands the agent runs. Any finished piece of work since the last compaction counts, not only one in the current turn: the end of a turn is already a pause, so waiting for the next commit would mostly just cost more. Once files are changed again after it, the next piece of work has started, and SmartCompact waits for that one to finish. A commit counts if `git commit` actually ran and the tool call did not fail. A test command piped into something else (`| tail`) without `pipefail` does not count, since its exit code cannot be trusted.
|
|
84
85
|
|
|
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.
|
|
86
|
+
**Will the model forget things after compaction?** Sometimes, for details mentioned in passing. The ledger only records files, commits, tests and the requirements and decisions the model chose to record; user requirements have their own section, so later notes do not push them out, and only the oldest are dropped once there are more than 30. 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.
|
|
86
87
|
|
|
87
88
|
**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
|
|
package/README.zh-CN.md
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
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
|
-
- **不在做到一半时压缩。**
|
|
17
|
+
- **不在做到一半时压缩。** 只在一轮结束时判断,从不在一轮中间主动压缩;测试失败、构建失败或连续报错时先等着。还要等你空闲 3 分钟没发消息才压,来回讨论时不会被打断。
|
|
18
18
|
- **不设自己的上限。** 一直没做完一件事,多半是在干一件大活,这时正需要大上下文。插件不去打断,压缩按平台原生规则来,和没装插件时一样。
|
|
19
|
-
- **用进度笔记接上。**
|
|
19
|
+
- **用进度笔记接上。** 改过的文件、最近的提交和测试结果、记下的用户要求和决定,以及模型最后一条回复,压缩后补回给模型。
|
|
20
20
|
- **不做白费的压缩。** 预计腾不出足够空间时不压缩,避免刚压缩完又压缩。
|
|
21
21
|
|
|
22
22
|
## 实验结果
|
|
@@ -68,21 +68,22 @@ Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束
|
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| 允许压缩线 | 200000 | 超过它、做完过一件事后,在一轮结束时压缩 |
|
|
70
70
|
| 最少腾出空间 | 60000 | 预计腾出的空间少于它时不压缩 |
|
|
71
|
+
| 空闲等待 | 180 | 决定压缩后,等你空闲这么多秒才压;期间发了消息或命令就取消 |
|
|
71
72
|
|
|
72
|
-
- Claude Code:插件配置项 `allowLineTokens`、`minFreedTokens`,留空用默认值。
|
|
73
|
-
- Pi:命令行开关 `--smartcompact-allow-line`、`--smartcompact-min-freed`。
|
|
74
|
-
- Codex:环境变量 `SMARTCOMPACT_ALLOW_LINE`、`SMARTCOMPACT_MIN_FREED`。
|
|
73
|
+
- Claude Code:插件配置项 `allowLineTokens`、`minFreedTokens`、`compactIdleSeconds`,留空用默认值。
|
|
74
|
+
- Pi:命令行开关 `--smartcompact-allow-line`、`--smartcompact-min-freed`、`--smartcompact-idle-seconds`。
|
|
75
|
+
- Codex:环境变量 `SMARTCOMPACT_ALLOW_LINE`、`SMARTCOMPACT_MIN_FREED`、`SMARTCOMPACT_IDLE_SECONDS`。
|
|
75
76
|
|
|
76
77
|
## 查看效果
|
|
77
78
|
|
|
78
|
-
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report`)查看允许压缩线、原生压缩点、识别到的阶段节点和成本估算;`/smartcompact pause` / `resume` 暂停或恢复当前会话。模型还可以调用 `checkpoint`
|
|
79
|
+
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report`)查看允许压缩线、原生压缩点、识别到的阶段节点和成本估算;`/smartcompact pause` / `resume` 暂停或恢复当前会话。模型还可以调用 `checkpoint` 工具记下用户的要求和决定,压缩后和进度笔记一起补回。
|
|
79
80
|
- Codex:`node ~/.codex/plugins/cache/smartcompact/smartcompact/<版本>/adapters/codex/report.ts <会话 ID>`(Node.js 22.18 及以上),记录在 `~/.codex/smartcompact/`。
|
|
80
81
|
|
|
81
82
|
## 常见问题
|
|
82
83
|
|
|
83
84
|
**怎么判断"做完一件事"?** 靠识别 agent 执行的命令。看的是上次压缩以来有没有做完过一件事,不要求正好在这一轮:一轮结束本身就是停顿点,非要等下一次提交,多半只是多花钱。但做完之后又开始改文件,就算在做下一件事,要等它做完。提交只要真的执行了 `git commit` 且工具调用没报错就算;测试命令接了管道(`| tail`)又没开 `pipefail` 时退出码不可信,不算测试结果。
|
|
84
85
|
|
|
85
|
-
**压缩后模型会不会忘事?**
|
|
86
|
+
**压缩后模型会不会忘事?** 随口提到的细节有时会丢。进度笔记只记文件、提交、测试和模型主动记下的要求和决定;用户要求单独一栏,不会因为后来记的内容多了被挤掉,超过 30 条才丢最旧的。用户在对话里随口交代的细节,靠压缩时要求摘要原样保留用户说过的具体信息。Pi 上这样做明显减少了丢失,但不能保证不丢。
|
|
86
87
|
|
|
87
88
|
**会改 Claude Code 自己的设置吗?** 不会,原生自动压缩点保持原样(接近 100 万)。0.1.2 及以前的版本会在会话里设置 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,把原生压缩点挪到约 26 万(子进程也会继承),并且到了那里不等做完一件事也压缩。0.1.3 去掉了这两点:大活不会再在固定大小处被打断。升级前就开着的会话还留着旧设置,新开一个会话就没有了。
|
|
88
89
|
|
package/adapters/pi/index.ts
CHANGED
|
@@ -6,12 +6,23 @@
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
7
7
|
import { SettingsManager } from '@earendil-works/pi-coding-agent'
|
|
8
8
|
import { Type } from 'typebox'
|
|
9
|
-
import { decideAtTurnEnd, decideMidTurn
|
|
10
|
-
import {
|
|
9
|
+
import { decideAtTurnEnd, decideMidTurn } from '../../plugin/src/core/decide.ts'
|
|
10
|
+
import {
|
|
11
|
+
applyCheckpoint,
|
|
12
|
+
CHECKPOINT_DESCRIPTION,
|
|
13
|
+
CHECKPOINT_INPUT_SCHEMA,
|
|
14
|
+
COMPACT_INSTRUCTIONS,
|
|
15
|
+
parseCheckpoint,
|
|
16
|
+
recordLastReply,
|
|
17
|
+
renderLedger,
|
|
18
|
+
} from '../../plugin/src/core/ledger.ts'
|
|
11
19
|
import type { Lines } from '../../plugin/src/core/lines.ts'
|
|
12
20
|
import { resolveParams } from '../../plugin/src/core/params.ts'
|
|
21
|
+
import { planCompaction, staleCompaction } from '../../plugin/src/core/pending.ts'
|
|
22
|
+
import { promptReminder } from '../../plugin/src/core/reminder.ts'
|
|
13
23
|
import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
|
|
14
24
|
import {
|
|
25
|
+
checkpointLog,
|
|
15
26
|
cycleReads,
|
|
16
27
|
type DecisionLogEntry,
|
|
17
28
|
expectedAfterTokens,
|
|
@@ -30,25 +41,31 @@ import { recognizePi } from './recognize.ts'
|
|
|
30
41
|
const STATE_ENTRY = 'smartcompact-state'
|
|
31
42
|
const LOG_ENTRY = 'smartcompact-log'
|
|
32
43
|
const LEDGER_MESSAGE = 'smartcompact-ledger'
|
|
33
|
-
const
|
|
44
|
+
const REMINDER_MESSAGE = 'smartcompact-reminder'
|
|
34
45
|
const CHECKPOINT_TOOL = 'smartcompact_checkpoint'
|
|
35
46
|
/** pi 内置的默认 reserveTokens,即不配置时原生压缩点离满窗的距离 */
|
|
36
47
|
const PI_DEFAULT_RESERVE = 16_384
|
|
37
48
|
/** 压缩后除了保留的最近内容,还有系统提示、工具和摘要,粗估这么多 */
|
|
38
49
|
const PI_AFTER_OVERHEAD = 10_000
|
|
39
50
|
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
const field = CHECKPOINT_INPUT_SCHEMA.properties
|
|
52
|
+
|
|
53
|
+
/** 没设置时用默认值;0 也是合法的设置。 */
|
|
54
|
+
const flagNumber = (value: unknown): number | undefined => (typeof value === 'string' && value.trim() ? Number(value) : undefined)
|
|
43
55
|
|
|
44
56
|
export default function smartCompact(pi: ExtensionAPI) {
|
|
45
57
|
pi.registerFlag('smartcompact-allow-line', { description: 'SmartCompact 允许压缩线(token)', type: 'string' })
|
|
46
58
|
pi.registerFlag('smartcompact-min-freed', { description: 'SmartCompact 最少腾出空间(token)', type: 'string' })
|
|
59
|
+
pi.registerFlag('smartcompact-idle-seconds', { description: 'SmartCompact 决定压缩后等用户空闲多少秒', type: 'string' })
|
|
47
60
|
|
|
48
61
|
let state: SessionState = INITIAL_SESSION_STATE
|
|
49
62
|
let saved = ''
|
|
50
63
|
/** 插件自己发起的压缩在 pi 里同样是 manual,据此和用户的 /compact 区分。 */
|
|
51
64
|
let ours = false
|
|
65
|
+
/** 这次运行给用户的最后一段文字;运行正常结束时记进笔记,压缩后原样带回。 */
|
|
66
|
+
let lastReply = ''
|
|
67
|
+
/** 一轮结束后等用户空闲再执行的压缩;用户发新消息、换会话或退出时作废,到点时再核对会话现状。 */
|
|
68
|
+
let idleCompaction: ReturnType<typeof setTimeout> | undefined
|
|
52
69
|
/** 当前模型的 reserveTokens 和 keepRecentTokens;读设置文件有开销,按会话和模型缓存。 */
|
|
53
70
|
let compaction: { reserve: number; keepRecent: number } | undefined
|
|
54
71
|
|
|
@@ -64,8 +81,9 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
64
81
|
// pi 的基础占用比 Claude 小得多:压缩后的大小按 pi 保留的最近内容估算,而不是用 core 的默认值。
|
|
65
82
|
const params = (ctx: ExtensionContext) =>
|
|
66
83
|
resolveParams({
|
|
67
|
-
allowLineTokens:
|
|
68
|
-
minFreedTokens:
|
|
84
|
+
allowLineTokens: flagNumber(pi.getFlag('smartcompact-allow-line')),
|
|
85
|
+
minFreedTokens: flagNumber(pi.getFlag('smartcompact-min-freed')),
|
|
86
|
+
compactIdleSeconds: flagNumber(pi.getFlag('smartcompact-idle-seconds')),
|
|
69
87
|
defaultAfterTokens: compactionSettings(ctx).keepRecent + PI_AFTER_OVERHEAD,
|
|
70
88
|
})
|
|
71
89
|
|
|
@@ -97,6 +115,14 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
97
115
|
|
|
98
116
|
const log = (entry: Omit<DecisionLogEntry, 'at' | 'session'>) => pi.appendEntry(LOG_ENTRY, { at: new Date().toISOString(), ...entry })
|
|
99
117
|
|
|
118
|
+
/** 撤销还在等用户空闲的压缩;返回是否真的撤销了。 */
|
|
119
|
+
const cancelIdleCompaction = (): boolean => {
|
|
120
|
+
if (!idleCompaction) return false
|
|
121
|
+
clearTimeout(idleCompaction)
|
|
122
|
+
idleCompaction = undefined
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
const report = (ctx: ExtensionContext): string => {
|
|
101
127
|
const current = lines(ctx)
|
|
102
128
|
if (!current) return 'SmartCompact 尚未读到上下文用量。'
|
|
@@ -116,6 +142,7 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
116
142
|
saved = JSON.stringify(state)
|
|
117
143
|
ours = false
|
|
118
144
|
compaction = undefined
|
|
145
|
+
cancelIdleCompaction()
|
|
119
146
|
})
|
|
120
147
|
|
|
121
148
|
pi.on('model_select', async () => {
|
|
@@ -125,24 +152,22 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
125
152
|
pi.registerTool({
|
|
126
153
|
name: CHECKPOINT_TOOL,
|
|
127
154
|
label: 'Checkpoint',
|
|
128
|
-
description:
|
|
129
|
-
'Record a short progress note that survives context compaction: the goal, decisions made, where the work stands, and what is next. ' +
|
|
130
|
-
'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.',
|
|
155
|
+
description: CHECKPOINT_DESCRIPTION,
|
|
131
156
|
parameters: Type.Object({
|
|
132
|
-
goal: Type.Optional(Type.String({ description:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
157
|
+
goal: Type.Optional(Type.String({ description: field.goal.description })),
|
|
158
|
+
requirements: Type.Optional(Type.Array(Type.String(), { description: field.requirements.description })),
|
|
159
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: field.decisions.description })),
|
|
160
|
+
next: Type.Optional(Type.String({ description: field.next.description })),
|
|
161
|
+
current: Type.Optional(Type.String({ description: field.current.description })),
|
|
162
|
+
milestone: Type.Optional(Type.Boolean({ description: field.milestone.description })),
|
|
137
163
|
}),
|
|
138
164
|
async execute(_toolCallId, input) {
|
|
139
165
|
const parsed = parseCheckpoint(input)
|
|
140
166
|
if (!parsed.ok) return { content: [{ type: 'text', text: `Not recorded: ${parsed.error}` }], details: {} }
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
])
|
|
167
|
+
const ledger = applyCheckpoint(state.ledger, parsed.value)
|
|
168
|
+
state = withSignals({ ...state, ledger, notesUpdated: true }, [{ kind: 'checkpoint', milestone: parsed.value.milestone === true }])
|
|
144
169
|
persist()
|
|
145
|
-
log(
|
|
170
|
+
log(checkpointLog(parsed.value, ledger))
|
|
146
171
|
return { content: [{ type: 'text', text: 'Recorded. Continue with the work.' }], details: {} }
|
|
147
172
|
},
|
|
148
173
|
})
|
|
@@ -171,17 +196,34 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
171
196
|
})
|
|
172
197
|
|
|
173
198
|
pi.on('before_agent_start', async (_event, ctx) => {
|
|
199
|
+
// 等待期间来了新消息,说明还在进行,这次不压,等下一次停顿。
|
|
200
|
+
if (cancelIdleCompaction()) log({ event: 'compacted', action: 'cancelled', reason: 'new-turn', trigger: 'plugin' })
|
|
174
201
|
const current = lines(ctx)
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
state =
|
|
178
|
-
|
|
202
|
+
const reminder = current && promptReminder(state, tokensOf(ctx), current, params(ctx), CHECKPOINT_TOOL)
|
|
203
|
+
if (!reminder) return
|
|
204
|
+
state = reminder.state
|
|
205
|
+
persist()
|
|
206
|
+
log(reminder.log)
|
|
207
|
+
return { message: { customType: REMINDER_MESSAGE, content: reminder.text, display: false } }
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
pi.on('agent_end', async event => {
|
|
211
|
+
const reply = event.messages.findLast(message => message.role === 'assistant')
|
|
212
|
+
lastReply =
|
|
213
|
+
reply && 'content' in reply && Array.isArray(reply.content)
|
|
214
|
+
? reply.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
|
|
215
|
+
: ''
|
|
179
216
|
})
|
|
180
217
|
|
|
181
|
-
// agent_settled:pi
|
|
182
|
-
pi.on('agent_settled', async (
|
|
218
|
+
// agent_settled:pi 不会再自动继续,此时压缩不会打断工作。被用户打断的运行不压缩,也不记回复。
|
|
219
|
+
pi.on('agent_settled', async (event, ctx) => {
|
|
220
|
+
if (event.aborted) return
|
|
221
|
+
state = { ...state, ledger: recordLastReply(state.ledger, lastReply) }
|
|
183
222
|
const current = lines(ctx)
|
|
184
|
-
if (!current)
|
|
223
|
+
if (!current) {
|
|
224
|
+
persist()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
185
227
|
const tokens = sample(ctx, current)
|
|
186
228
|
persist()
|
|
187
229
|
const decision = decideAtTurnEnd(
|
|
@@ -190,15 +232,35 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
190
232
|
)
|
|
191
233
|
const sinceMilestoneTokens = decision.action === 'compact' ? growthSinceMilestone(state.work, tokens) : undefined
|
|
192
234
|
if (decision.action !== 'none') log({ event: 'turn-end', action: decision.action, reason: decision.reason, tokens, sinceMilestoneTokens })
|
|
193
|
-
if (decision.action !== 'compact'
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
235
|
+
if (decision.action !== 'compact') return
|
|
236
|
+
// 等用户空闲一会儿再压:还在讨论时不打断,用户也不用等压缩完才得到回复。
|
|
237
|
+
cancelIdleCompaction()
|
|
238
|
+
const pending = planCompaction(ctx.sessionManager.getSessionId(), state, Date.now(), params(ctx))
|
|
239
|
+
idleCompaction = setTimeout(() => {
|
|
240
|
+
idleCompaction = undefined
|
|
241
|
+
// 新的运行会先经过 before_agent_start 撤销;到点还不空闲的是 pi 自己在重试、恢复或处理排队的消息,这次不压,下一次停顿时重新判断。
|
|
242
|
+
// 等待期间用户可能自己 /compact、原生压缩过或暂停了插件,这些也都不经过 before_agent_start。
|
|
243
|
+
const stale = !ctx.isIdle() || ctx.hasPendingMessages() ? 'busy' : staleCompaction(pending, state, ctx.sessionManager.getSessionId())
|
|
244
|
+
if (stale) {
|
|
245
|
+
log({ event: 'compacted', action: 'skipped', reason: stale, trigger: 'plugin' })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
ours = true
|
|
249
|
+
ctx.compact({
|
|
250
|
+
customInstructions: COMPACT_INSTRUCTIONS,
|
|
251
|
+
onError: error => {
|
|
252
|
+
ours = false
|
|
253
|
+
log({ event: 'compacted', action: 'failed', trigger: 'plugin', detail: error.message })
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
}, pending.notBefore - Date.now())
|
|
257
|
+
// 等待中的压缩不拦着 pi 退出(比如 -p 模式跑完就该结束)。
|
|
258
|
+
idleCompaction.unref?.()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// 计时器拿着旧会话的 ctx,换会话或退出时必须清掉。
|
|
262
|
+
pi.on('session_shutdown', async () => {
|
|
263
|
+
cancelIdleCompaction()
|
|
202
264
|
})
|
|
203
265
|
|
|
204
266
|
pi.on('session_before_compact', async (event, ctx) => {
|
package/package.json
CHANGED
package/plugin/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# SmartCompact for Claude Code
|
|
2
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
|
|
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 decides at the end of a turn to compact if a commit, passing tests or a completed task happened since the last compaction, then waits until you have been idle for 3 minutes so a discussion is not cut off; a new turn cancels it. 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 user requirements and decisions) and the model's last reply are handed back after compaction.
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -8,8 +8,8 @@ In a 60-task A/B run on Claude Haiku it cut estimated cost by 44% with no loss i
|
|
|
8
8
|
|
|
9
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
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.
|
|
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, file paths from Read (to count files re-read after a compaction), 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, and the text of the model's final reply each turn (to hand it back after a compaction). 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, the model's latest reply up to 3,000 characters), the paths of files read before and since the last compaction, and a short decision log. Old session records are pruned automatically.
|
|
13
13
|
|
|
14
14
|
### What each hook does
|
|
15
15
|
|
|
@@ -19,15 +19,16 @@ All hooks live in one module, `hooks/register.ts`.
|
|
|
19
19
|
- **`/smartcompact` command**: answers only its own command (`report`, `pause`, `resume`).
|
|
20
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
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
|
|
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`; past the allow line, while it waits for a finished piece of work, it reminds the model to mark each finished step with `checkpoint`, again every further 100k tokens.
|
|
23
|
+
- **Turn start**: cancels a compaction still waiting for the user to go idle; any new turn counts, including one started by a slash command.
|
|
24
|
+
- **Turn complete**: keeps the model's final reply and decides whether to compact. When it should, it waits until the user has been idle for 3 minutes (a new turn cancels it) and then compacts the session; the reply is handed back with the ledger. 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
25
|
- **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
|
|
|
26
27
|
## Use
|
|
27
28
|
|
|
28
29
|
- Install: `/plugin marketplace add coffeecoproject/SmartCompact`, then `/plugin install smartcompact@smartcompact`. It takes effect in new sessions.
|
|
29
30
|
- `/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
|
+
- Settings (optional, empty means default): `allowLineTokens` (200000), `minFreedTokens` (60000), `compactIdleSeconds` (180).
|
|
31
32
|
- Remove: `/plugin uninstall smartcompact@smartcompact`.
|
|
32
33
|
|
|
33
34
|
Full documentation, Pi and Codex versions: https://github.com/coffeecoproject/SmartCompact
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// 两个决策函数:一轮结束时要不要主动压缩;一轮进行中原生自动压缩来问时放不放行(只有原生压缩点能提前的平台用到,目前是 pi)。
|
|
2
|
+
// 另有两个判断供 reminder.ts 用:要不要提醒记笔记、要不要提醒标完成。
|
|
2
3
|
// 读不到用量时一律不干预,按原生行为来。
|
|
3
4
|
|
|
4
5
|
import type { Lines } from './lines.ts'
|
|
@@ -52,6 +53,18 @@ export function shouldNudge(
|
|
|
52
53
|
return input.tokens >= input.lines.allow * params.nudgeAtShare
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** 超过允许线、因为还没做完一件事在等时,提醒模型做完一步就标完成;上下文每涨 remindEveryTokens 才再提醒一次。 */
|
|
57
|
+
export function shouldRemindMilestone(
|
|
58
|
+
decision: TurnEndDecision,
|
|
59
|
+
tokens: number,
|
|
60
|
+
remindedAtTokens: number | undefined,
|
|
61
|
+
params: Params,
|
|
62
|
+
): boolean {
|
|
63
|
+
if (decision.action !== 'wait') return false
|
|
64
|
+
if (decision.reason !== 'changed-since-milestone' && decision.reason !== 'waiting-for-milestone') return false
|
|
65
|
+
return remindedAtTokens === undefined || tokens - remindedAtTokens >= params.remindEveryTokens
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
export function decideMidTurn(input: DecisionInput, params: Params): MidTurnDecision {
|
|
56
69
|
const { tokens, lines, work } = input
|
|
57
70
|
if (input.paused) return { action: 'allow', reason: 'paused' }
|
|
@@ -7,10 +7,14 @@ export type LedgerFacts = {
|
|
|
7
7
|
lastBuild?: { command: string; passed: boolean }
|
|
8
8
|
lastCommit?: { summary: string }
|
|
9
9
|
tasksInProgress: string[]
|
|
10
|
+
/** 模型最近一轮给用户的回复,压缩后原样带回;用户的下一句话常常是在回答它 */
|
|
11
|
+
lastReply?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export type LedgerNotes = {
|
|
13
15
|
goal?: string
|
|
16
|
+
/** 用户交代的要求和具体信息;单独存、只追加,再记一次的挪到最后,超过上限才丢最旧的。旧版本存下的笔记没有这一项 */
|
|
17
|
+
requirements?: string[]
|
|
14
18
|
decisions: string[]
|
|
15
19
|
next?: string
|
|
16
20
|
current?: string
|
|
@@ -37,8 +41,13 @@ export const COMPACT_INSTRUCTIONS =
|
|
|
37
41
|
'A SmartCompact progress ledger is attached after the summary; do not restate it.'
|
|
38
42
|
|
|
39
43
|
const MAX_FILES = 30
|
|
40
|
-
const MAX_DECISIONS =
|
|
44
|
+
const MAX_DECISIONS = 30
|
|
41
45
|
const MAX_FIELD_CHARS = 1_000
|
|
46
|
+
/** 用户要求只追加,超过这么多条才丢最旧的 */
|
|
47
|
+
const MAX_REQUIREMENTS = 30
|
|
48
|
+
/** 带回的最后一条回复最多这么长,单独计算,不占笔记的长度;超长时留结尾,提问和待确认的提议通常在最后 */
|
|
49
|
+
const MAX_REPLY_CHARS = 3_000
|
|
50
|
+
const REPLY_CUT = '…(earlier part omitted)\n'
|
|
42
51
|
|
|
43
52
|
export function recordFileChanged(ledger: Ledger, path: string): Ledger {
|
|
44
53
|
const files = [...ledger.facts.filesChanged.filter(file => file !== path), path].slice(-MAX_FILES)
|
|
@@ -57,12 +66,20 @@ export function recordCommit(ledger: Ledger, summary: string): Ledger {
|
|
|
57
66
|
return { ...ledger, facts: { ...ledger.facts, lastCommit: { summary } } }
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
export function recordLastReply(ledger: Ledger, reply: string): Ledger {
|
|
70
|
+
const text = reply.trim()
|
|
71
|
+
if (!text) return ledger
|
|
72
|
+
const kept = text.length <= MAX_REPLY_CHARS ? text : `${REPLY_CUT}${text.slice(-(MAX_REPLY_CHARS - REPLY_CUT.length))}`
|
|
73
|
+
return { ...ledger, facts: { ...ledger.facts, lastReply: kept } }
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
export function recordTasksInProgress(ledger: Ledger, tasks: readonly string[]): Ledger {
|
|
61
77
|
return { ...ledger, facts: { ...ledger.facts, tasksInProgress: [...tasks] } }
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
export type CheckpointInput = {
|
|
65
81
|
goal?: string
|
|
82
|
+
requirements?: string[]
|
|
66
83
|
decisions?: string[]
|
|
67
84
|
next?: string
|
|
68
85
|
current?: string
|
|
@@ -70,11 +87,28 @@ export type CheckpointInput = {
|
|
|
70
87
|
milestone?: boolean
|
|
71
88
|
}
|
|
72
89
|
|
|
90
|
+
/** checkpoint 工具的说明,适配层注册工具时使用。 */
|
|
91
|
+
export const CHECKPOINT_DESCRIPTION =
|
|
92
|
+
'Record a short progress note that survives context compaction: the goal, requirements and specific facts the user stated, decisions made, ' +
|
|
93
|
+
'where the work stands, and what is next. ' +
|
|
94
|
+
'Call it after finishing a piece of work (set milestone: true), after an important decision, or when the user states a requirement or a specific fact. ' +
|
|
95
|
+
'Keep it brief; do not call it for trivial steps.'
|
|
96
|
+
|
|
73
97
|
/** checkpoint 工具的输入 schema,适配层注册工具时使用。 */
|
|
74
98
|
export const CHECKPOINT_INPUT_SCHEMA = {
|
|
75
99
|
type: 'object',
|
|
76
100
|
properties: {
|
|
77
101
|
goal: { type: 'string', description: 'The overall goal of the current work.' },
|
|
102
|
+
requirements: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: { type: 'string' },
|
|
105
|
+
description:
|
|
106
|
+
'Facts and requirements the user stated for this session: names of people and customers, dates, ticket or ID numbers, constraints, ' +
|
|
107
|
+
'things to avoid. One per item, with the context needed to use it later (such as which task it belongs to), in the user\'s words ' +
|
|
108
|
+
'where possible; record them when the user states them. Not for progress, task status or commit IDs; those go in current and decisions. ' +
|
|
109
|
+
'Items are added to the list and only the oldest are dropped when it gets long. If the user changes one, record the new version; ' +
|
|
110
|
+
'the later one wins. Long-term preferences belong in your own memory, not here.',
|
|
111
|
+
},
|
|
78
112
|
decisions: { type: 'array', items: { type: 'string' }, description: 'Decisions made since the last checkpoint.' },
|
|
79
113
|
next: { type: 'string', description: 'What to do next.' },
|
|
80
114
|
current: { type: 'string', description: 'Where the work stands right now.' },
|
|
@@ -98,29 +132,32 @@ export function parseCheckpoint(raw: unknown): CheckpointParse {
|
|
|
98
132
|
if (!text(field)) return { ok: false, error: `${key} must be a non-empty string` }
|
|
99
133
|
value[key] = clip(field)
|
|
100
134
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
value.decisions = decisions.map(clip)
|
|
135
|
+
for (const key of ['requirements', 'decisions'] as const) {
|
|
136
|
+
const field = input[key]
|
|
137
|
+
if (field === undefined) continue
|
|
138
|
+
if (!Array.isArray(field) || !field.every(text)) return { ok: false, error: `${key} must be an array of non-empty strings` }
|
|
139
|
+
value[key] = field.map(clip)
|
|
107
140
|
}
|
|
108
|
-
if (milestone !== undefined) {
|
|
109
|
-
if (typeof milestone !== 'boolean') return { ok: false, error: 'milestone must be a boolean' }
|
|
110
|
-
value.milestone = milestone
|
|
141
|
+
if (input.milestone !== undefined) {
|
|
142
|
+
if (typeof input.milestone !== 'boolean') return { ok: false, error: 'milestone must be a boolean' }
|
|
143
|
+
value.milestone = input.milestone
|
|
111
144
|
}
|
|
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' }
|
|
145
|
+
if (!value.goal && !value.next && !value.current && !value.decisions?.length && !value.requirements?.length) {
|
|
146
|
+
return { ok: false, error: 'record at least one of goal, requirements, decisions, next, current' }
|
|
114
147
|
}
|
|
115
148
|
return { ok: true, value }
|
|
116
149
|
}
|
|
117
150
|
|
|
118
151
|
export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger {
|
|
119
152
|
const decisions = [...ledger.notes.decisions, ...(input.decisions ?? [])]
|
|
153
|
+
// 再记一次的要求挪到最后:用户刚重申过,不该因为最早写进去而先被丢掉。
|
|
154
|
+
const added = input.requirements ?? []
|
|
155
|
+
const requirements = [...(ledger.notes.requirements ?? []).filter(requirement => !added.includes(requirement)), ...added]
|
|
120
156
|
return {
|
|
121
157
|
...ledger,
|
|
122
158
|
notes: {
|
|
123
159
|
goal: input.goal ?? ledger.notes.goal,
|
|
160
|
+
requirements: [...new Set(requirements)].slice(-MAX_REQUIREMENTS),
|
|
124
161
|
next: input.next ?? ledger.notes.next,
|
|
125
162
|
current: input.current ?? ledger.notes.current,
|
|
126
163
|
decisions: [...new Set(decisions)].slice(-MAX_DECISIONS),
|
|
@@ -128,20 +165,31 @@ export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger
|
|
|
128
165
|
}
|
|
129
166
|
}
|
|
130
167
|
|
|
131
|
-
|
|
168
|
+
type Kept = { requirements: readonly string[]; decisions: readonly string[]; files: readonly string[] }
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 排版成补回上下文的文本,超长时先丢最旧的决定、再丢最早改过的文件、最后才丢最旧的用户要求,仍超长才截断。
|
|
172
|
+
* 最后一条回复放在末尾,不算在 maxChars 里。
|
|
173
|
+
*/
|
|
132
174
|
export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
const reply = ledger.facts.lastReply
|
|
176
|
+
const notes = renderNotes(ledger, maxChars)
|
|
177
|
+
return reply ? `${notes}\n\n## Your last reply before compaction\nVerbatim; the user's next message may answer it.\n\n${reply}` : notes
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderNotes(ledger: Ledger, maxChars: number): string {
|
|
181
|
+
let kept: Kept = { requirements: ledger.notes.requirements ?? [], decisions: ledger.notes.decisions, files: ledger.facts.filesChanged }
|
|
182
|
+
let rendered = layoutLedger(ledger, kept)
|
|
183
|
+
while (rendered.length > maxChars) {
|
|
184
|
+
const key = (['decisions', 'files', 'requirements'] as const).find(k => kept[k].length)
|
|
185
|
+
if (!key) break
|
|
186
|
+
kept = { ...kept, [key]: kept[key].slice(1) }
|
|
187
|
+
rendered = layoutLedger(ledger, kept)
|
|
140
188
|
}
|
|
141
189
|
return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
|
|
142
190
|
}
|
|
143
191
|
|
|
144
|
-
function layoutLedger({ facts, notes }: Ledger,
|
|
192
|
+
function layoutLedger({ facts, notes }: Ledger, { requirements, decisions, files }: Kept): string {
|
|
145
193
|
const lines = [
|
|
146
194
|
LEDGER_MARKER,
|
|
147
195
|
'Generated by the SmartCompact plugin to keep continuity across compaction. It is not a message from the user.',
|
|
@@ -152,6 +200,10 @@ function layoutLedger({ facts, notes }: Ledger, decisions: readonly string[], fi
|
|
|
152
200
|
}
|
|
153
201
|
const omitted = (dropped: number, what: string) => (dropped ? `- …(${dropped} earlier ${what} omitted)` : undefined)
|
|
154
202
|
section('Goal', [notes.goal])
|
|
203
|
+
section('User requirements', [
|
|
204
|
+
omitted((notes.requirements?.length ?? 0) - requirements.length, 'requirements'),
|
|
205
|
+
...requirements.map(requirement => `- ${requirement}`),
|
|
206
|
+
])
|
|
155
207
|
section('Current state', [notes.current])
|
|
156
208
|
section('Next', [notes.next])
|
|
157
209
|
section('Latest results', [
|
|
@@ -17,6 +17,10 @@ export type Params = {
|
|
|
17
17
|
ledgerMaxChars: number
|
|
18
18
|
/** 用量到 L × 这个比例、笔记又没更新时,提醒模型记一下 */
|
|
19
19
|
nudgeAtShare: number
|
|
20
|
+
/** 超过 L、在等做完一件事时,上下文每涨这么多提醒一次模型做完一步就标完成 */
|
|
21
|
+
remindEveryTokens: number
|
|
22
|
+
/** 一轮结束、决定压缩后,用户空闲这么多秒没发消息才压缩;期间发了消息就取消,等下一次停顿 */
|
|
23
|
+
compactIdleSeconds: number
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
@@ -28,6 +32,8 @@ export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
|
28
32
|
errorStreakLimit: 3,
|
|
29
33
|
ledgerMaxChars: 6_000,
|
|
30
34
|
nudgeAtShare: 0.85,
|
|
35
|
+
remindEveryTokens: 100_000,
|
|
36
|
+
compactIdleSeconds: 180,
|
|
31
37
|
})
|
|
32
38
|
|
|
33
39
|
const MINIMUMS: Record<keyof Params, number> = {
|
|
@@ -39,6 +45,8 @@ const MINIMUMS: Record<keyof Params, number> = {
|
|
|
39
45
|
errorStreakLimit: 1,
|
|
40
46
|
ledgerMaxChars: 200,
|
|
41
47
|
nudgeAtShare: 0,
|
|
48
|
+
remindEveryTokens: 1,
|
|
49
|
+
compactIdleSeconds: 0,
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/** 用户覆盖的值逐项校验,不合法的项沿用默认值。 */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 一轮结束时决定压缩后,等用户空闲一会儿再执行。等待期间会话可能已经变了,到点时用这里的核对决定还压不压;
|
|
2
|
+
// 三个平台都用它,不再逐个事件去撤销。用户发新消息时撤销是另一回事(还在讨论),由适配层在收到消息时处理。
|
|
3
|
+
|
|
4
|
+
import type { Params } from './params.ts'
|
|
5
|
+
import type { SessionState } from './state.ts'
|
|
6
|
+
|
|
7
|
+
export type PendingCompaction = {
|
|
8
|
+
/** 决定压缩时的会话 ID */
|
|
9
|
+
session: string
|
|
10
|
+
/** 决定压缩时已经压缩过的次数;到点时变了,说明期间已经压缩过 */
|
|
11
|
+
compactions: number
|
|
12
|
+
/** 不早于这个时间(毫秒)执行 */
|
|
13
|
+
notBefore: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StaleReason = 'session-changed' | 'compacted-meanwhile' | 'paused'
|
|
17
|
+
|
|
18
|
+
export const planCompaction = (session: string, state: SessionState, now: number, params: Params): PendingCompaction => ({
|
|
19
|
+
session,
|
|
20
|
+
compactions: state.compactions,
|
|
21
|
+
notBefore: now + params.compactIdleSeconds * 1_000,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** 到点时核对:换了会话(如 /clear)、期间已经压缩过(用户的 /compact 或原生压缩)、或暂停了,这次就不压。 */
|
|
25
|
+
export function staleCompaction(pending: PendingCompaction, state: SessionState, session: string): StaleReason | undefined {
|
|
26
|
+
if (session !== pending.session) return 'session-changed'
|
|
27
|
+
if (state.compactions !== pending.compactions) return 'compacted-meanwhile'
|
|
28
|
+
if (state.paused) return 'paused'
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 用户发消息前要不要附一句用户看不到的提醒。两种提醒的文字、判断、状态和日志都在这里,适配层只负责注入。
|
|
2
|
+
|
|
3
|
+
import { decideAtTurnEnd, shouldNudge, shouldRemindMilestone } from './decide.ts'
|
|
4
|
+
import type { Lines } from './lines.ts'
|
|
5
|
+
import type { Params } from './params.ts'
|
|
6
|
+
import { type DecisionLogEntry, expectedAfterTokens, type SessionState } from './state.ts'
|
|
7
|
+
|
|
8
|
+
/** 快到允许压缩线时提醒模型更新笔记。 */
|
|
9
|
+
export const checkpointNudge = (tokens: number, tool: string): string =>
|
|
10
|
+
`SmartCompact: the context is at about ${Math.round(tokens / 1000)}k tokens and will be compacted at the next finished piece of work. ` +
|
|
11
|
+
`If decisions, user requirements or progress are not yet recorded, call ${tool} briefly; if a piece of work was just finished, set milestone: true. ` +
|
|
12
|
+
'If the user is waiting for an answer, answer first.'
|
|
13
|
+
|
|
14
|
+
/** 超过允许线、在等做完一件事时,提醒模型做完一步就标完成。 */
|
|
15
|
+
export const milestoneReminder = (tokens: number, tool: string): string =>
|
|
16
|
+
`SmartCompact: the context is at about ${Math.round(tokens / 1000)}k tokens and will be compacted once a piece of work is finished. ` +
|
|
17
|
+
`When you finish a step (a fix, a subtask, a passing test run), call ${tool} with milestone: true, even without a commit; ` +
|
|
18
|
+
'do not mark unfinished work as done. If the user is waiting for an answer, answer first.'
|
|
19
|
+
|
|
20
|
+
export type PromptReminder = { text: string; state: SessionState; log: Omit<DecisionLogEntry, 'at' | 'session'> }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 超过允许线、在等做完一件事时,提醒模型做完一步就标完成;否则快到允许线、笔记没更新时,提醒记笔记。
|
|
24
|
+
* 两种都算本周期提醒过,不重复提醒记笔记。不需要提醒时返回 undefined。
|
|
25
|
+
*/
|
|
26
|
+
export function promptReminder(
|
|
27
|
+
state: SessionState,
|
|
28
|
+
tokens: number | undefined,
|
|
29
|
+
lines: Lines,
|
|
30
|
+
params: Params,
|
|
31
|
+
tool: string,
|
|
32
|
+
): PromptReminder | undefined {
|
|
33
|
+
if (tokens === undefined) return undefined
|
|
34
|
+
// 用户发下一条消息前状态没变,判断和上一轮结束时一样。
|
|
35
|
+
const waiting = decideAtTurnEnd(
|
|
36
|
+
{ tokens, lines, work: state.work, expectedAfterTokens: expectedAfterTokens(state, params.defaultAfterTokens), paused: state.paused },
|
|
37
|
+
params,
|
|
38
|
+
)
|
|
39
|
+
if (shouldRemindMilestone(waiting, tokens, state.remindedAtTokens, params)) {
|
|
40
|
+
return {
|
|
41
|
+
text: milestoneReminder(tokens, tool),
|
|
42
|
+
state: { ...state, nudged: true, remindedAtTokens: tokens },
|
|
43
|
+
log: { event: 'reminder', action: 'milestone', reason: waiting.reason, tokens },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (shouldNudge({ tokens, lines, notesUpdated: state.notesUpdated, nudged: state.nudged, paused: state.paused }, params)) {
|
|
47
|
+
return {
|
|
48
|
+
text: checkpointNudge(tokens, tool),
|
|
49
|
+
state: { ...state, nudged: true },
|
|
50
|
+
log: { event: 'reminder', action: 'nudge', reason: 'notes-not-updated', tokens },
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
@@ -35,7 +35,7 @@ export function renderReport(input: {
|
|
|
35
35
|
`SmartCompact${state.paused ? '(已暂停)' : ''}`,
|
|
36
36
|
`- 当前用量 ${k(tokens)};允许压缩线 ${k(lines.allow)},原生压缩点 ${k(lines.ceiling)}`,
|
|
37
37
|
`- 本会话压缩 ${state.compactions} 次;最近一次压缩后约 ${k(state.measuredAfterTokens)}`,
|
|
38
|
-
`- 本会话决策:插件压缩 ${(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}`,
|
|
38
|
+
`- 本会话决策:插件压缩 ${(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},提醒记笔记 ${counts['reminder:nudge'] ?? 0},提醒标完成 ${counts['reminder:milestone'] ?? 0}`,
|
|
39
39
|
`- 信号:提交 ${seen.commit},测试通过 ${seen['tests-passed']},任务完成 ${seen['task-completed']},checkpoint 完成 ${seen.checkpoint}`,
|
|
40
40
|
`- 当前状态:${milestoneStatus(state.work)}${state.work.testsFailing ? ',测试失败中' : ''}${state.work.buildFailing ? ',构建失败中' : ''}`,
|
|
41
41
|
ratio === undefined
|
package/plugin/src/core/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 会话状态与决策日志的数据格式。读写由适配层负责,core 只定义格式。
|
|
2
2
|
|
|
3
|
-
import { EMPTY_LEDGER, type Ledger } from './ledger.ts'
|
|
3
|
+
import { type CheckpointInput, 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
|
|
|
@@ -15,8 +15,10 @@ export type SessionState = {
|
|
|
15
15
|
paused: boolean
|
|
16
16
|
/** 自上次压缩以来,模型是否通过 checkpoint 更新过笔记 */
|
|
17
17
|
notesUpdated: boolean
|
|
18
|
-
/**
|
|
18
|
+
/** 自上次压缩以来,是否提醒过模型(记笔记或标完成);提醒过就不再提醒记笔记 */
|
|
19
19
|
nudged: boolean
|
|
20
|
+
/** 本周期最近一次提醒模型标完成时的用量 */
|
|
21
|
+
remindedAtTokens?: number
|
|
20
22
|
/** 各种信号出现过的次数,用于确认识别是否生效 */
|
|
21
23
|
seen: SignalsSeen
|
|
22
24
|
usage: UsageTally
|
|
@@ -102,6 +104,7 @@ export function withCompaction(state: SessionState, before: number, summary: num
|
|
|
102
104
|
compactions: state.compactions + 1,
|
|
103
105
|
notesUpdated: false,
|
|
104
106
|
nudged: false,
|
|
107
|
+
remindedAtTokens: undefined,
|
|
105
108
|
usage: attributed ? recordCompaction(state.usage, before, summary) : state.usage,
|
|
106
109
|
}
|
|
107
110
|
}
|
|
@@ -125,11 +128,21 @@ export function cycleReads(state: SessionState): Pick<DecisionLogEntry, 'reads'
|
|
|
125
128
|
return state.reads ? { reads: state.reads.reads, rereads: state.reads.rereads } : {}
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
/** checkpoint 的日志:有没有标完成、用户要求有几条。 */
|
|
132
|
+
export function checkpointLog(input: CheckpointInput, ledger: Ledger): Omit<DecisionLogEntry, 'at' | 'session'> {
|
|
133
|
+
const requirements = ledger.notes.requirements?.length ?? 0
|
|
134
|
+
return {
|
|
135
|
+
event: 'checkpoint',
|
|
136
|
+
action: input.milestone ? 'milestone' : 'note',
|
|
137
|
+
...(requirements ? { requirements } : {}),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
export type DecisionLogEntry = {
|
|
129
142
|
at: string
|
|
130
143
|
/** 会话 ID,报告据此只统计当前会话 */
|
|
131
144
|
session?: string
|
|
132
|
-
event: 'setup' | 'turn-end' | 'mid-turn' | 'compacted' | 'checkpoint' | 'error'
|
|
145
|
+
event: 'setup' | 'turn-end' | 'mid-turn' | 'compacted' | 'checkpoint' | 'reminder' | 'error'
|
|
133
146
|
action?: string
|
|
134
147
|
reason?: string
|
|
135
148
|
tokens?: number
|
|
@@ -140,6 +153,8 @@ export type DecisionLogEntry = {
|
|
|
140
153
|
/** 压缩时:上次压缩以来读文件的次数,以及其中重读压缩前看过的文件的次数 */
|
|
141
154
|
reads?: number
|
|
142
155
|
rereads?: number
|
|
156
|
+
/** checkpoint 之后用户要求的条数 */
|
|
157
|
+
requirements?: number
|
|
143
158
|
trigger?: string
|
|
144
159
|
detail?: string
|
|
145
160
|
}
|