smartcompact 0.1.7 → 0.1.10
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 +6 -5
- package/README.zh-CN.md +6 -5
- package/adapters/pi/index.ts +69 -14
- package/package.json +1 -1
- package/plugin/README.md +9 -7
- package/plugin/src/core/ledger.ts +24 -7
- package/plugin/src/core/params.ts +4 -0
- package/plugin/src/core/pending.ts +30 -0
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,
|
|
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,10 +68,11 @@ 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
|
|
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,10 +68,11 @@ 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
|
|
package/adapters/pi/index.ts
CHANGED
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
CHECKPOINT_INPUT_SCHEMA,
|
|
14
14
|
COMPACT_INSTRUCTIONS,
|
|
15
15
|
parseCheckpoint,
|
|
16
|
+
recordLastReply,
|
|
16
17
|
renderLedger,
|
|
17
18
|
} from '../../plugin/src/core/ledger.ts'
|
|
18
19
|
import type { Lines } from '../../plugin/src/core/lines.ts'
|
|
19
20
|
import { resolveParams } from '../../plugin/src/core/params.ts'
|
|
21
|
+
import { planCompaction, staleCompaction } from '../../plugin/src/core/pending.ts'
|
|
20
22
|
import { promptReminder } from '../../plugin/src/core/reminder.ts'
|
|
21
23
|
import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
|
|
22
24
|
import {
|
|
@@ -48,14 +50,22 @@ const PI_AFTER_OVERHEAD = 10_000
|
|
|
48
50
|
|
|
49
51
|
const field = CHECKPOINT_INPUT_SCHEMA.properties
|
|
50
52
|
|
|
53
|
+
/** 没设置时用默认值;0 也是合法的设置。 */
|
|
54
|
+
const flagNumber = (value: unknown): number | undefined => (typeof value === 'string' && value.trim() ? Number(value) : undefined)
|
|
55
|
+
|
|
51
56
|
export default function smartCompact(pi: ExtensionAPI) {
|
|
52
57
|
pi.registerFlag('smartcompact-allow-line', { description: 'SmartCompact 允许压缩线(token)', type: 'string' })
|
|
53
58
|
pi.registerFlag('smartcompact-min-freed', { description: 'SmartCompact 最少腾出空间(token)', type: 'string' })
|
|
59
|
+
pi.registerFlag('smartcompact-idle-seconds', { description: 'SmartCompact 决定压缩后等用户空闲多少秒', type: 'string' })
|
|
54
60
|
|
|
55
61
|
let state: SessionState = INITIAL_SESSION_STATE
|
|
56
62
|
let saved = ''
|
|
57
63
|
/** 插件自己发起的压缩在 pi 里同样是 manual,据此和用户的 /compact 区分。 */
|
|
58
64
|
let ours = false
|
|
65
|
+
/** 这次运行给用户的最后一段文字;运行正常结束时记进笔记,压缩后原样带回。 */
|
|
66
|
+
let lastReply = ''
|
|
67
|
+
/** 一轮结束后等用户空闲再执行的压缩;用户发新消息、换会话或退出时作废,到点时再核对会话现状。 */
|
|
68
|
+
let idleCompaction: ReturnType<typeof setTimeout> | undefined
|
|
59
69
|
/** 当前模型的 reserveTokens 和 keepRecentTokens;读设置文件有开销,按会话和模型缓存。 */
|
|
60
70
|
let compaction: { reserve: number; keepRecent: number } | undefined
|
|
61
71
|
|
|
@@ -71,8 +81,9 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
71
81
|
// pi 的基础占用比 Claude 小得多:压缩后的大小按 pi 保留的最近内容估算,而不是用 core 的默认值。
|
|
72
82
|
const params = (ctx: ExtensionContext) =>
|
|
73
83
|
resolveParams({
|
|
74
|
-
allowLineTokens:
|
|
75
|
-
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')),
|
|
76
87
|
defaultAfterTokens: compactionSettings(ctx).keepRecent + PI_AFTER_OVERHEAD,
|
|
77
88
|
})
|
|
78
89
|
|
|
@@ -104,6 +115,14 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
104
115
|
|
|
105
116
|
const log = (entry: Omit<DecisionLogEntry, 'at' | 'session'>) => pi.appendEntry(LOG_ENTRY, { at: new Date().toISOString(), ...entry })
|
|
106
117
|
|
|
118
|
+
/** 撤销还在等用户空闲的压缩;返回是否真的撤销了。 */
|
|
119
|
+
const cancelIdleCompaction = (): boolean => {
|
|
120
|
+
if (!idleCompaction) return false
|
|
121
|
+
clearTimeout(idleCompaction)
|
|
122
|
+
idleCompaction = undefined
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
const report = (ctx: ExtensionContext): string => {
|
|
108
127
|
const current = lines(ctx)
|
|
109
128
|
if (!current) return 'SmartCompact 尚未读到上下文用量。'
|
|
@@ -123,6 +142,7 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
123
142
|
saved = JSON.stringify(state)
|
|
124
143
|
ours = false
|
|
125
144
|
compaction = undefined
|
|
145
|
+
cancelIdleCompaction()
|
|
126
146
|
})
|
|
127
147
|
|
|
128
148
|
pi.on('model_select', async () => {
|
|
@@ -176,6 +196,8 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
176
196
|
})
|
|
177
197
|
|
|
178
198
|
pi.on('before_agent_start', async (_event, ctx) => {
|
|
199
|
+
// 等待期间来了新消息,说明还在进行,这次不压,等下一次停顿。
|
|
200
|
+
if (cancelIdleCompaction()) log({ event: 'compacted', action: 'cancelled', reason: 'new-turn', trigger: 'plugin' })
|
|
179
201
|
const current = lines(ctx)
|
|
180
202
|
const reminder = current && promptReminder(state, tokensOf(ctx), current, params(ctx), CHECKPOINT_TOOL)
|
|
181
203
|
if (!reminder) return
|
|
@@ -185,10 +207,23 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
185
207
|
return { message: { customType: REMINDER_MESSAGE, content: reminder.text, display: false } }
|
|
186
208
|
})
|
|
187
209
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
: ''
|
|
216
|
+
})
|
|
217
|
+
|
|
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) }
|
|
190
222
|
const current = lines(ctx)
|
|
191
|
-
if (!current)
|
|
223
|
+
if (!current) {
|
|
224
|
+
persist()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
192
227
|
const tokens = sample(ctx, current)
|
|
193
228
|
persist()
|
|
194
229
|
const decision = decideAtTurnEnd(
|
|
@@ -197,15 +232,35 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
197
232
|
)
|
|
198
233
|
const sinceMilestoneTokens = decision.action === 'compact' ? growthSinceMilestone(state.work, tokens) : undefined
|
|
199
234
|
if (decision.action !== 'none') log({ event: 'turn-end', action: decision.action, reason: decision.reason, tokens, sinceMilestoneTokens })
|
|
200
|
-
if (decision.action !== 'compact'
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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()
|
|
209
264
|
})
|
|
210
265
|
|
|
211
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, 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. 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 paths of files read before and since the last compaction, 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,17 @@ 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
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
22
|
+
- **Session start after a compaction**: hands the progress ledger to the model as context the user does not see. Subagent compactions are left alone.
|
|
23
|
+
- **Prompt submit**: adds context to the turn, never changes the user's message. 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.
|
|
24
|
+
- **Turn start**: cancels a compaction still waiting for the user to go idle; any new turn counts, including one started by a slash command.
|
|
25
|
+
- **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, shown as `/compact by SmartCompact`, and supplies SmartCompact's compaction instructions when it runs; it runs no other commands.
|
|
26
|
+
- **Compaction**: records the size before and after. It changes only the `/compact` it queued itself: it fills in the instructions, and skips it if it is no longer needed (the user sent a message or the session already compacted). The user's own and native compactions run unchanged, and the result is never rewritten.
|
|
25
27
|
|
|
26
28
|
## Use
|
|
27
29
|
|
|
28
30
|
- Install: `/plugin marketplace add coffeecoproject/SmartCompact`, then `/plugin install smartcompact@smartcompact`. It takes effect in new sessions.
|
|
29
31
|
- `/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).
|
|
32
|
+
- Settings (optional, empty means default): `allowLineTokens` (200000), `minFreedTokens` (60000), `compactIdleSeconds` (180).
|
|
31
33
|
- Remove: `/plugin uninstall smartcompact@smartcompact`.
|
|
32
34
|
|
|
33
35
|
Full documentation, Pi and Codex versions: https://github.com/coffeecoproject/SmartCompact
|
|
@@ -7,6 +7,8 @@ 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 = {
|
|
@@ -25,7 +27,7 @@ export const EMPTY_LEDGER: Ledger = Object.freeze({
|
|
|
25
27
|
notes: Object.freeze({ decisions: [] }),
|
|
26
28
|
}) as Ledger
|
|
27
29
|
|
|
28
|
-
/**
|
|
30
|
+
/** 笔记文本的开头,让模型认出这是插件写的笔记,不是用户的话。 */
|
|
29
31
|
export const LEDGER_MARKER = '[SmartCompact progress ledger]'
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -36,13 +38,16 @@ export const COMPACT_INSTRUCTIONS =
|
|
|
36
38
|
'Preserve the current goal, decisions and their reasons, unfinished work, failed attempts, and exact file paths. ' +
|
|
37
39
|
'Also keep every specific fact the user stated, verbatim, even if the current work does not need it: ' +
|
|
38
40
|
'names of people and customers, dates and time windows, ticket or ID numbers, and constraints. ' +
|
|
39
|
-
'A SmartCompact progress ledger is attached after
|
|
41
|
+
'A SmartCompact progress ledger is attached after compaction; do not restate it.'
|
|
40
42
|
|
|
41
43
|
const MAX_FILES = 30
|
|
42
44
|
const MAX_DECISIONS = 30
|
|
43
45
|
const MAX_FIELD_CHARS = 1_000
|
|
44
46
|
/** 用户要求只追加,超过这么多条才丢最旧的 */
|
|
45
47
|
const MAX_REQUIREMENTS = 30
|
|
48
|
+
/** 带回的最后一条回复最多这么长,单独计算,不占笔记的长度;超长时留结尾,提问和待确认的提议通常在最后 */
|
|
49
|
+
const MAX_REPLY_CHARS = 3_000
|
|
50
|
+
const REPLY_CUT = '…(earlier part omitted)\n'
|
|
46
51
|
|
|
47
52
|
export function recordFileChanged(ledger: Ledger, path: string): Ledger {
|
|
48
53
|
const files = [...ledger.facts.filesChanged.filter(file => file !== path), path].slice(-MAX_FILES)
|
|
@@ -61,6 +66,13 @@ export function recordCommit(ledger: Ledger, summary: string): Ledger {
|
|
|
61
66
|
return { ...ledger, facts: { ...ledger.facts, lastCommit: { summary } } }
|
|
62
67
|
}
|
|
63
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
|
+
|
|
64
76
|
export function recordTasksInProgress(ledger: Ledger, tasks: readonly string[]): Ledger {
|
|
65
77
|
return { ...ledger, facts: { ...ledger.facts, tasksInProgress: [...tasks] } }
|
|
66
78
|
}
|
|
@@ -155,8 +167,17 @@ export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger
|
|
|
155
167
|
|
|
156
168
|
type Kept = { requirements: readonly string[]; decisions: readonly string[]; files: readonly string[] }
|
|
157
169
|
|
|
158
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* 排版成补回上下文的文本,超长时先丢最旧的决定、再丢最早改过的文件、最后才丢最旧的用户要求,仍超长才截断。
|
|
172
|
+
* 最后一条回复放在末尾,不算在 maxChars 里。
|
|
173
|
+
*/
|
|
159
174
|
export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
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 {
|
|
160
181
|
let kept: Kept = { requirements: ledger.notes.requirements ?? [], decisions: ledger.notes.decisions, files: ledger.facts.filesChanged }
|
|
161
182
|
let rendered = layoutLedger(ledger, kept)
|
|
162
183
|
while (rendered.length > maxChars) {
|
|
@@ -195,7 +216,3 @@ function layoutLedger({ facts, notes }: Ledger, { requirements, decisions, files
|
|
|
195
216
|
section('Files changed', [omitted(facts.filesChanged.length - files.length, 'files'), ...files.map(file => `- ${file}`)])
|
|
196
217
|
return lines.join('\n')
|
|
197
218
|
}
|
|
198
|
-
|
|
199
|
-
export function isLedgerText(value: string): boolean {
|
|
200
|
-
return value.startsWith(LEDGER_MARKER)
|
|
201
|
-
}
|
|
@@ -19,6 +19,8 @@ export type Params = {
|
|
|
19
19
|
nudgeAtShare: number
|
|
20
20
|
/** 超过 L、在等做完一件事时,上下文每涨这么多提醒一次模型做完一步就标完成 */
|
|
21
21
|
remindEveryTokens: number
|
|
22
|
+
/** 一轮结束、决定压缩后,用户空闲这么多秒没发消息才压缩;期间发了消息就取消,等下一次停顿 */
|
|
23
|
+
compactIdleSeconds: number
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
@@ -31,6 +33,7 @@ export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
|
31
33
|
ledgerMaxChars: 6_000,
|
|
32
34
|
nudgeAtShare: 0.85,
|
|
33
35
|
remindEveryTokens: 100_000,
|
|
36
|
+
compactIdleSeconds: 180,
|
|
34
37
|
})
|
|
35
38
|
|
|
36
39
|
const MINIMUMS: Record<keyof Params, number> = {
|
|
@@ -43,6 +46,7 @@ const MINIMUMS: Record<keyof Params, number> = {
|
|
|
43
46
|
ledgerMaxChars: 200,
|
|
44
47
|
nudgeAtShare: 0,
|
|
45
48
|
remindEveryTokens: 1,
|
|
49
|
+
compactIdleSeconds: 0,
|
|
46
50
|
}
|
|
47
51
|
|
|
48
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
|
+
}
|