smartcompact 0.1.5 → 0.1.7
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 +5 -5
- package/README.zh-CN.md +5 -5
- package/adapters/pi/index.ts +29 -22
- package/adapters/pi/recognize.ts +1 -0
- package/package.json +1 -1
- package/plugin/README.md +4 -4
- package/plugin/src/core/decide.ts +17 -2
- package/plugin/src/core/ledger.ts +52 -21
- package/plugin/src/core/params.ts +4 -0
- package/plugin/src/core/reminder.ts +54 -0
- package/plugin/src/core/report.ts +6 -2
- package/plugin/src/core/signals.ts +9 -2
- package/plugin/src/core/state.ts +18 -3
package/README.md
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
Every request re-reads the whole context, so a long context makes every step expensive. Native auto-compaction waits until the context is nearly full (close to 1M tokens in Claude Code, up to 800k in Codex if configured), and it ignores timing, so it often lands in the middle of a task. SmartCompact works with Claude Code (CLI and desktop app), [Pi](https://github.com/earendil-works/pi) and the Codex CLI:
|
|
15
15
|
|
|
16
|
-
- **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).
|
|
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
17
|
- **Never in the middle of work.** It only decides when a turn ends, never mid-turn, and it waits while tests or the build are failing or errors keep repeating.
|
|
18
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, and the user requirements and decisions the agent recorded are handed back to the model after compaction.
|
|
20
20
|
- **No pointless compactions.** It skips compaction when it would not free enough space.
|
|
21
21
|
|
|
22
22
|
## Results
|
|
@@ -75,14 +75,14 @@ Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompac
|
|
|
75
75
|
|
|
76
76
|
## Seeing what it did
|
|
77
77
|
|
|
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.
|
|
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 the user's requirements and decisions, which come back with the ledger after compaction.
|
|
79
79
|
- Codex: `node ~/.codex/plugins/cache/smartcompact/smartcompact/<version>/adapters/codex/report.ts <session ID>` (Node.js 22.18+); records are kept in `~/.codex/smartcompact/`.
|
|
80
80
|
|
|
81
81
|
## FAQ
|
|
82
82
|
|
|
83
|
-
**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. 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.
|
|
83
|
+
**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
84
|
|
|
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.
|
|
85
|
+
**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
86
|
|
|
87
87
|
**Does it change Claude Code's own settings?** No. Native auto-compaction stays where it is (close to 1M). Versions up to 0.1.2 moved it to about 260k by setting `CLAUDE_CODE_AUTO_COMPACT_WINDOW` in the session (which child processes inherited) and also compacted there without a finished piece of work. 0.1.3 removed both: a long task is no longer cut off at a fixed size. A session opened on 0.1.2 or earlier keeps the old setting until you start a new one.
|
|
88
88
|
|
package/README.zh-CN.md
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
agent 每发一次请求,都要把整段上下文重新读一遍,上下文越长,每一步越贵。原生的自动压缩要等到快满了才触发(Claude Code 接近 100 万,Codex 可以配到 80 万),而且不看时机,常常发生在一件事做到一半的时候。SmartCompact 支持 Claude Code(终端和桌面 App)、[Pi](https://github.com/earendil-works/pi) 和 Codex 命令行:
|
|
15
15
|
|
|
16
|
-
- **挑对时机压缩。** 上下文超过**允许压缩线**(默认 20
|
|
16
|
+
- **挑对时机压缩。** 上下文超过**允许压缩线**(默认 20 万)以后,只要上次压缩以来做完过一件事(一次提交、测试通过、任务完成),之后又没开始改文件,就在这一轮结束时压缩。
|
|
17
17
|
- **不在做到一半时压缩。** 只在一轮结束时判断,从不在一轮中间主动压缩;测试失败、构建失败或连续报错时先等着。
|
|
18
18
|
- **不设自己的上限。** 一直没做完一件事,多半是在干一件大活,这时正需要大上下文。插件不去打断,压缩按平台原生规则来,和没装插件时一样。
|
|
19
|
-
- **用进度笔记接上。**
|
|
19
|
+
- **用进度笔记接上。** 改过的文件、最近的提交和测试结果、记下的用户要求和决定,压缩后补回给模型。
|
|
20
20
|
- **不做白费的压缩。** 预计腾不出足够空间时不压缩,避免刚压缩完又压缩。
|
|
21
21
|
|
|
22
22
|
## 实验结果
|
|
@@ -75,14 +75,14 @@ Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束
|
|
|
75
75
|
|
|
76
76
|
## 查看效果
|
|
77
77
|
|
|
78
|
-
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report`)查看允许压缩线、原生压缩点、识别到的阶段节点和成本估算;`/smartcompact pause` / `resume` 暂停或恢复当前会话。模型还可以调用 `checkpoint`
|
|
78
|
+
- Claude Code 和 Pi:`/smartcompact`(或 `/smartcompact report`)查看允许压缩线、原生压缩点、识别到的阶段节点和成本估算;`/smartcompact pause` / `resume` 暂停或恢复当前会话。模型还可以调用 `checkpoint` 工具记下用户的要求和决定,压缩后和进度笔记一起补回。
|
|
79
79
|
- Codex:`node ~/.codex/plugins/cache/smartcompact/smartcompact/<版本>/adapters/codex/report.ts <会话 ID>`(Node.js 22.18 及以上),记录在 `~/.codex/smartcompact/`。
|
|
80
80
|
|
|
81
81
|
## 常见问题
|
|
82
82
|
|
|
83
|
-
**怎么判断"做完一件事"?** 靠识别 agent
|
|
83
|
+
**怎么判断"做完一件事"?** 靠识别 agent 执行的命令。看的是上次压缩以来有没有做完过一件事,不要求正好在这一轮:一轮结束本身就是停顿点,非要等下一次提交,多半只是多花钱。但做完之后又开始改文件,就算在做下一件事,要等它做完。提交只要真的执行了 `git commit` 且工具调用没报错就算;测试命令接了管道(`| tail`)又没开 `pipefail` 时退出码不可信,不算测试结果。
|
|
84
84
|
|
|
85
|
-
**压缩后模型会不会忘事?**
|
|
85
|
+
**压缩后模型会不会忘事?** 随口提到的细节有时会丢。进度笔记只记文件、提交、测试和模型主动记下的要求和决定;用户要求单独一栏,不会因为后来记的内容多了被挤掉,超过 30 条才丢最旧的。用户在对话里随口交代的细节,靠压缩时要求摘要原样保留用户说过的具体信息。Pi 上这样做明显减少了丢失,但不能保证不丢。
|
|
86
86
|
|
|
87
87
|
**会改 Claude Code 自己的设置吗?** 不会,原生自动压缩点保持原样(接近 100 万)。0.1.2 及以前的版本会在会话里设置 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`,把原生压缩点挪到约 26 万(子进程也会继承),并且到了那里不等做完一件事也压缩。0.1.3 去掉了这两点:大活不会再在固定大小处被打断。升级前就开着的会话还留着旧设置,新开一个会话就没有了。
|
|
88
88
|
|
package/adapters/pi/index.ts
CHANGED
|
@@ -6,12 +6,21 @@
|
|
|
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
|
+
renderLedger,
|
|
17
|
+
} from '../../plugin/src/core/ledger.ts'
|
|
11
18
|
import type { Lines } from '../../plugin/src/core/lines.ts'
|
|
12
19
|
import { resolveParams } from '../../plugin/src/core/params.ts'
|
|
20
|
+
import { promptReminder } from '../../plugin/src/core/reminder.ts'
|
|
13
21
|
import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
|
|
14
22
|
import {
|
|
23
|
+
checkpointLog,
|
|
15
24
|
cycleReads,
|
|
16
25
|
type DecisionLogEntry,
|
|
17
26
|
expectedAfterTokens,
|
|
@@ -30,16 +39,14 @@ import { recognizePi } from './recognize.ts'
|
|
|
30
39
|
const STATE_ENTRY = 'smartcompact-state'
|
|
31
40
|
const LOG_ENTRY = 'smartcompact-log'
|
|
32
41
|
const LEDGER_MESSAGE = 'smartcompact-ledger'
|
|
33
|
-
const
|
|
42
|
+
const REMINDER_MESSAGE = 'smartcompact-reminder'
|
|
34
43
|
const CHECKPOINT_TOOL = 'smartcompact_checkpoint'
|
|
35
44
|
/** pi 内置的默认 reserveTokens,即不配置时原生压缩点离满窗的距离 */
|
|
36
45
|
const PI_DEFAULT_RESERVE = 16_384
|
|
37
46
|
/** 压缩后除了保留的最近内容,还有系统提示、工具和摘要,粗估这么多 */
|
|
38
47
|
const PI_AFTER_OVERHEAD = 10_000
|
|
39
48
|
|
|
40
|
-
const
|
|
41
|
-
`SmartCompact: the context is at about ${Math.round(tokens / 1000)}k tokens and will be compacted at the next finished piece of work. ` +
|
|
42
|
-
`If decisions or progress are not yet recorded, call ${CHECKPOINT_TOOL} briefly. If the user is waiting for an answer, answer first.`
|
|
49
|
+
const field = CHECKPOINT_INPUT_SCHEMA.properties
|
|
43
50
|
|
|
44
51
|
export default function smartCompact(pi: ExtensionAPI) {
|
|
45
52
|
pi.registerFlag('smartcompact-allow-line', { description: 'SmartCompact 允许压缩线(token)', type: 'string' })
|
|
@@ -125,24 +132,22 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
125
132
|
pi.registerTool({
|
|
126
133
|
name: CHECKPOINT_TOOL,
|
|
127
134
|
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.',
|
|
135
|
+
description: CHECKPOINT_DESCRIPTION,
|
|
131
136
|
parameters: Type.Object({
|
|
132
|
-
goal: Type.Optional(Type.String({ description:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
goal: Type.Optional(Type.String({ description: field.goal.description })),
|
|
138
|
+
requirements: Type.Optional(Type.Array(Type.String(), { description: field.requirements.description })),
|
|
139
|
+
decisions: Type.Optional(Type.Array(Type.String(), { description: field.decisions.description })),
|
|
140
|
+
next: Type.Optional(Type.String({ description: field.next.description })),
|
|
141
|
+
current: Type.Optional(Type.String({ description: field.current.description })),
|
|
142
|
+
milestone: Type.Optional(Type.Boolean({ description: field.milestone.description })),
|
|
137
143
|
}),
|
|
138
144
|
async execute(_toolCallId, input) {
|
|
139
145
|
const parsed = parseCheckpoint(input)
|
|
140
146
|
if (!parsed.ok) return { content: [{ type: 'text', text: `Not recorded: ${parsed.error}` }], details: {} }
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
])
|
|
147
|
+
const ledger = applyCheckpoint(state.ledger, parsed.value)
|
|
148
|
+
state = withSignals({ ...state, ledger, notesUpdated: true }, [{ kind: 'checkpoint', milestone: parsed.value.milestone === true }])
|
|
144
149
|
persist()
|
|
145
|
-
log(
|
|
150
|
+
log(checkpointLog(parsed.value, ledger))
|
|
146
151
|
return { content: [{ type: 'text', text: 'Recorded. Continue with the work.' }], details: {} }
|
|
147
152
|
},
|
|
148
153
|
})
|
|
@@ -172,10 +177,12 @@ export default function smartCompact(pi: ExtensionAPI) {
|
|
|
172
177
|
|
|
173
178
|
pi.on('before_agent_start', async (_event, ctx) => {
|
|
174
179
|
const current = lines(ctx)
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
state =
|
|
178
|
-
|
|
180
|
+
const reminder = current && promptReminder(state, tokensOf(ctx), current, params(ctx), CHECKPOINT_TOOL)
|
|
181
|
+
if (!reminder) return
|
|
182
|
+
state = reminder.state
|
|
183
|
+
persist()
|
|
184
|
+
log(reminder.log)
|
|
185
|
+
return { message: { customType: REMINDER_MESSAGE, content: reminder.text, display: false } }
|
|
179
186
|
})
|
|
180
187
|
|
|
181
188
|
// agent_settled:pi 不会再自动继续,此时压缩不会打断工作。
|
package/adapters/pi/recognize.ts
CHANGED
|
@@ -20,6 +20,7 @@ export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals:
|
|
|
20
20
|
const { path, command } = outcome.input
|
|
21
21
|
if (EDIT_TOOLS.has(outcome.toolName) && !outcome.isError && typeof path === 'string' && path) {
|
|
22
22
|
next = recordFileChanged(next, path)
|
|
23
|
+
signals.push({ kind: 'file-changed' })
|
|
23
24
|
}
|
|
24
25
|
if (outcome.toolName === 'bash' && typeof command === 'string' && command) {
|
|
25
26
|
const classified = classifyCommand(command, outcome.isError, outcome.text, next)
|
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 compacts at the end of a turn if a commit, passing tests or a completed task happened since the last compaction. It waits while tests or the build are failing or errors repeat. It sets no limit of its own: if no piece of work gets finished, the session keeps its context and Claude Code's native auto-compaction applies as usual. A short progress ledger (files changed, latest commit and test result, recorded decisions) is handed back to the model after compaction.
|
|
3
|
+
SmartCompact compacts long Claude Code sessions earlier, at a pause after a piece of work is finished, so every later request re-reads a smaller context. Once the context passes 200k tokens, it compacts at the end of a turn if a commit, passing tests or a completed task happened since the last compaction. It waits while tests or the build are failing or errors repeat. It sets no limit of its own: if no piece of work gets finished, the session keeps its context and Claude Code's native auto-compaction applies as usual. A short progress ledger (files changed, latest commit and test result, recorded user requirements and decisions) is handed back to the model 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. 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.
|
|
13
13
|
|
|
14
14
|
### What each hook does
|
|
15
15
|
|
|
@@ -19,7 +19,7 @@ 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
|
|
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
23
|
- **Turn complete**: decides whether to compact. When it should, it compacts the session. Where a session cannot compact directly (headless sessions such as the desktop app), it runs the `/compact` command itself with SmartCompact's compaction instructions; it runs no other commands.
|
|
24
24
|
- **Compaction**: never blocks or changes a compaction itself. After any compaction of the main session it appends the progress ledger to the compacted context. Subagent compactions are left alone.
|
|
25
25
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// 两个决策函数:一轮结束时要不要主动压缩;一轮进行中原生自动压缩来问时放不放行(只有原生压缩点能提前的平台用到,目前是 pi)。
|
|
2
|
+
// 另有两个判断供 reminder.ts 用:要不要提醒记笔记、要不要提醒标完成。
|
|
2
3
|
// 读不到用量时一律不干预,按原生行为来。
|
|
3
4
|
|
|
4
5
|
import type { Lines } from './lines.ts'
|
|
@@ -17,7 +18,7 @@ export type DecisionInput = {
|
|
|
17
18
|
|
|
18
19
|
export type TurnEndDecision =
|
|
19
20
|
| { action: 'none'; reason: 'paused' | 'no-reading' | 'below-line' }
|
|
20
|
-
| { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'waiting-for-milestone' }
|
|
21
|
+
| { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'changed-since-milestone' | 'waiting-for-milestone' }
|
|
21
22
|
| { action: 'compact'; reason: MilestoneSource }
|
|
22
23
|
|
|
23
24
|
export type MidTurnDecision =
|
|
@@ -36,7 +37,9 @@ export function decideAtTurnEnd(input: DecisionInput, params: Params): TurnEndDe
|
|
|
36
37
|
const hard = hardSignal(work, params)
|
|
37
38
|
if (hard) return { action: 'wait', reason: hard }
|
|
38
39
|
if (!freesEnough(tokens, input, params)) return { action: 'wait', reason: 'insufficient-benefit' }
|
|
39
|
-
if (work.milestone) return { action: 'compact', reason: work.milestone }
|
|
40
|
+
if (work.milestone && !work.changedSinceMilestone) return { action: 'compact', reason: work.milestone }
|
|
41
|
+
// 做完一件事之后又改了文件,已经在做下一件事,等它做完。
|
|
42
|
+
if (work.milestone) return { action: 'wait', reason: 'changed-since-milestone' }
|
|
40
43
|
// 不设兜底线:一直等不到做完一件事,多半是在做一件大活,正需要大上下文;交给原生在上限处压缩。
|
|
41
44
|
return { action: 'wait', reason: 'waiting-for-milestone' }
|
|
42
45
|
}
|
|
@@ -50,6 +53,18 @@ export function shouldNudge(
|
|
|
50
53
|
return input.tokens >= input.lines.allow * params.nudgeAtShare
|
|
51
54
|
}
|
|
52
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
|
+
|
|
53
68
|
export function decideMidTurn(input: DecisionInput, params: Params): MidTurnDecision {
|
|
54
69
|
const { tokens, lines, work } = input
|
|
55
70
|
if (input.paused) return { action: 'allow', reason: 'paused' }
|
|
@@ -11,6 +11,8 @@ export type LedgerFacts = {
|
|
|
11
11
|
|
|
12
12
|
export type LedgerNotes = {
|
|
13
13
|
goal?: string
|
|
14
|
+
/** 用户交代的要求和具体信息;单独存、只追加,再记一次的挪到最后,超过上限才丢最旧的。旧版本存下的笔记没有这一项 */
|
|
15
|
+
requirements?: string[]
|
|
14
16
|
decisions: string[]
|
|
15
17
|
next?: string
|
|
16
18
|
current?: string
|
|
@@ -37,8 +39,10 @@ export const COMPACT_INSTRUCTIONS =
|
|
|
37
39
|
'A SmartCompact progress ledger is attached after the summary; do not restate it.'
|
|
38
40
|
|
|
39
41
|
const MAX_FILES = 30
|
|
40
|
-
const MAX_DECISIONS =
|
|
42
|
+
const MAX_DECISIONS = 30
|
|
41
43
|
const MAX_FIELD_CHARS = 1_000
|
|
44
|
+
/** 用户要求只追加,超过这么多条才丢最旧的 */
|
|
45
|
+
const MAX_REQUIREMENTS = 30
|
|
42
46
|
|
|
43
47
|
export function recordFileChanged(ledger: Ledger, path: string): Ledger {
|
|
44
48
|
const files = [...ledger.facts.filesChanged.filter(file => file !== path), path].slice(-MAX_FILES)
|
|
@@ -63,6 +67,7 @@ export function recordTasksInProgress(ledger: Ledger, tasks: readonly string[]):
|
|
|
63
67
|
|
|
64
68
|
export type CheckpointInput = {
|
|
65
69
|
goal?: string
|
|
70
|
+
requirements?: string[]
|
|
66
71
|
decisions?: string[]
|
|
67
72
|
next?: string
|
|
68
73
|
current?: string
|
|
@@ -70,11 +75,28 @@ export type CheckpointInput = {
|
|
|
70
75
|
milestone?: boolean
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/** checkpoint 工具的说明,适配层注册工具时使用。 */
|
|
79
|
+
export const CHECKPOINT_DESCRIPTION =
|
|
80
|
+
'Record a short progress note that survives context compaction: the goal, requirements and specific facts the user stated, decisions made, ' +
|
|
81
|
+
'where the work stands, and what is next. ' +
|
|
82
|
+
'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. ' +
|
|
83
|
+
'Keep it brief; do not call it for trivial steps.'
|
|
84
|
+
|
|
73
85
|
/** checkpoint 工具的输入 schema,适配层注册工具时使用。 */
|
|
74
86
|
export const CHECKPOINT_INPUT_SCHEMA = {
|
|
75
87
|
type: 'object',
|
|
76
88
|
properties: {
|
|
77
89
|
goal: { type: 'string', description: 'The overall goal of the current work.' },
|
|
90
|
+
requirements: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: { type: 'string' },
|
|
93
|
+
description:
|
|
94
|
+
'Facts and requirements the user stated for this session: names of people and customers, dates, ticket or ID numbers, constraints, ' +
|
|
95
|
+
'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 ' +
|
|
96
|
+
'where possible; record them when the user states them. Not for progress, task status or commit IDs; those go in current and decisions. ' +
|
|
97
|
+
'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; ' +
|
|
98
|
+
'the later one wins. Long-term preferences belong in your own memory, not here.',
|
|
99
|
+
},
|
|
78
100
|
decisions: { type: 'array', items: { type: 'string' }, description: 'Decisions made since the last checkpoint.' },
|
|
79
101
|
next: { type: 'string', description: 'What to do next.' },
|
|
80
102
|
current: { type: 'string', description: 'Where the work stands right now.' },
|
|
@@ -98,29 +120,32 @@ export function parseCheckpoint(raw: unknown): CheckpointParse {
|
|
|
98
120
|
if (!text(field)) return { ok: false, error: `${key} must be a non-empty string` }
|
|
99
121
|
value[key] = clip(field)
|
|
100
122
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
value.decisions = decisions.map(clip)
|
|
123
|
+
for (const key of ['requirements', 'decisions'] as const) {
|
|
124
|
+
const field = input[key]
|
|
125
|
+
if (field === undefined) continue
|
|
126
|
+
if (!Array.isArray(field) || !field.every(text)) return { ok: false, error: `${key} must be an array of non-empty strings` }
|
|
127
|
+
value[key] = field.map(clip)
|
|
107
128
|
}
|
|
108
|
-
if (milestone !== undefined) {
|
|
109
|
-
if (typeof milestone !== 'boolean') return { ok: false, error: 'milestone must be a boolean' }
|
|
110
|
-
value.milestone = milestone
|
|
129
|
+
if (input.milestone !== undefined) {
|
|
130
|
+
if (typeof input.milestone !== 'boolean') return { ok: false, error: 'milestone must be a boolean' }
|
|
131
|
+
value.milestone = input.milestone
|
|
111
132
|
}
|
|
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' }
|
|
133
|
+
if (!value.goal && !value.next && !value.current && !value.decisions?.length && !value.requirements?.length) {
|
|
134
|
+
return { ok: false, error: 'record at least one of goal, requirements, decisions, next, current' }
|
|
114
135
|
}
|
|
115
136
|
return { ok: true, value }
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger {
|
|
119
140
|
const decisions = [...ledger.notes.decisions, ...(input.decisions ?? [])]
|
|
141
|
+
// 再记一次的要求挪到最后:用户刚重申过,不该因为最早写进去而先被丢掉。
|
|
142
|
+
const added = input.requirements ?? []
|
|
143
|
+
const requirements = [...(ledger.notes.requirements ?? []).filter(requirement => !added.includes(requirement)), ...added]
|
|
120
144
|
return {
|
|
121
145
|
...ledger,
|
|
122
146
|
notes: {
|
|
123
147
|
goal: input.goal ?? ledger.notes.goal,
|
|
148
|
+
requirements: [...new Set(requirements)].slice(-MAX_REQUIREMENTS),
|
|
124
149
|
next: input.next ?? ledger.notes.next,
|
|
125
150
|
current: input.current ?? ledger.notes.current,
|
|
126
151
|
decisions: [...new Set(decisions)].slice(-MAX_DECISIONS),
|
|
@@ -128,20 +153,22 @@ export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger
|
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
155
|
|
|
131
|
-
|
|
156
|
+
type Kept = { requirements: readonly string[]; decisions: readonly string[]; files: readonly string[] }
|
|
157
|
+
|
|
158
|
+
/** 排版成补回上下文的文本,超长时先丢最旧的决定、再丢最早改过的文件、最后才丢最旧的用户要求,仍超长才截断。 */
|
|
132
159
|
export function renderLedger(ledger: Ledger, maxChars: number): string {
|
|
133
|
-
let
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
rendered = layoutLedger(ledger,
|
|
160
|
+
let kept: Kept = { requirements: ledger.notes.requirements ?? [], decisions: ledger.notes.decisions, files: ledger.facts.filesChanged }
|
|
161
|
+
let rendered = layoutLedger(ledger, kept)
|
|
162
|
+
while (rendered.length > maxChars) {
|
|
163
|
+
const key = (['decisions', 'files', 'requirements'] as const).find(k => kept[k].length)
|
|
164
|
+
if (!key) break
|
|
165
|
+
kept = { ...kept, [key]: kept[key].slice(1) }
|
|
166
|
+
rendered = layoutLedger(ledger, kept)
|
|
140
167
|
}
|
|
141
168
|
return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
|
|
142
169
|
}
|
|
143
170
|
|
|
144
|
-
function layoutLedger({ facts, notes }: Ledger,
|
|
171
|
+
function layoutLedger({ facts, notes }: Ledger, { requirements, decisions, files }: Kept): string {
|
|
145
172
|
const lines = [
|
|
146
173
|
LEDGER_MARKER,
|
|
147
174
|
'Generated by the SmartCompact plugin to keep continuity across compaction. It is not a message from the user.',
|
|
@@ -152,6 +179,10 @@ function layoutLedger({ facts, notes }: Ledger, decisions: readonly string[], fi
|
|
|
152
179
|
}
|
|
153
180
|
const omitted = (dropped: number, what: string) => (dropped ? `- …(${dropped} earlier ${what} omitted)` : undefined)
|
|
154
181
|
section('Goal', [notes.goal])
|
|
182
|
+
section('User requirements', [
|
|
183
|
+
omitted((notes.requirements?.length ?? 0) - requirements.length, 'requirements'),
|
|
184
|
+
...requirements.map(requirement => `- ${requirement}`),
|
|
185
|
+
])
|
|
155
186
|
section('Current state', [notes.current])
|
|
156
187
|
section('Next', [notes.next])
|
|
157
188
|
section('Latest results', [
|
|
@@ -17,6 +17,8 @@ export type Params = {
|
|
|
17
17
|
ledgerMaxChars: number
|
|
18
18
|
/** 用量到 L × 这个比例、笔记又没更新时,提醒模型记一下 */
|
|
19
19
|
nudgeAtShare: number
|
|
20
|
+
/** 超过 L、在等做完一件事时,上下文每涨这么多提醒一次模型做完一步就标完成 */
|
|
21
|
+
remindEveryTokens: number
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
@@ -28,6 +30,7 @@ export const DEFAULT_PARAMS: Params = Object.freeze({
|
|
|
28
30
|
errorStreakLimit: 3,
|
|
29
31
|
ledgerMaxChars: 6_000,
|
|
30
32
|
nudgeAtShare: 0.85,
|
|
33
|
+
remindEveryTokens: 100_000,
|
|
31
34
|
})
|
|
32
35
|
|
|
33
36
|
const MINIMUMS: Record<keyof Params, number> = {
|
|
@@ -39,6 +42,7 @@ const MINIMUMS: Record<keyof Params, number> = {
|
|
|
39
42
|
errorStreakLimit: 1,
|
|
40
43
|
ledgerMaxChars: 200,
|
|
41
44
|
nudgeAtShare: 0,
|
|
45
|
+
remindEveryTokens: 1,
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** 用户覆盖的值逐项校验,不合法的项沿用默认值。 */
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /smartcompact report 的内容:当前状态、决策次数、信号次数、成本估算。只排版,不读写。
|
|
2
2
|
|
|
3
3
|
import type { Lines } from './lines.ts'
|
|
4
|
+
import type { WorkState } from './signals.ts'
|
|
4
5
|
import type { DecisionLogEntry, SessionState } from './state.ts'
|
|
5
6
|
import { savingsRatio } from './usage.ts'
|
|
6
7
|
|
|
@@ -18,6 +19,9 @@ export function countDecisions(entries: readonly DecisionLogEntry[]): DecisionCo
|
|
|
18
19
|
|
|
19
20
|
const k = (tokens: number | undefined): string => (tokens === undefined ? '—' : `${Math.round(tokens / 1000)}k`)
|
|
20
21
|
|
|
22
|
+
const milestoneStatus = (work: WorkState): string =>
|
|
23
|
+
!work.milestone ? '等待阶段节点' : work.changedSinceMilestone ? `上一件事已做完(${work.milestone}),之后又改了文件,等这件事做完` : `已到阶段节点(${work.milestone})`
|
|
24
|
+
|
|
21
25
|
export function renderReport(input: {
|
|
22
26
|
state: SessionState
|
|
23
27
|
lines: Lines
|
|
@@ -31,9 +35,9 @@ export function renderReport(input: {
|
|
|
31
35
|
`SmartCompact${state.paused ? '(已暂停)' : ''}`,
|
|
32
36
|
`- 当前用量 ${k(tokens)};允许压缩线 ${k(lines.allow)},原生压缩点 ${k(lines.ceiling)}`,
|
|
33
37
|
`- 本会话压缩 ${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}`,
|
|
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}`,
|
|
35
39
|
`- 信号:提交 ${seen.commit},测试通过 ${seen['tests-passed']},任务完成 ${seen['task-completed']},checkpoint 完成 ${seen.checkpoint}`,
|
|
36
|
-
`- 当前状态:${state.work
|
|
40
|
+
`- 当前状态:${milestoneStatus(state.work)}${state.work.testsFailing ? ',测试失败中' : ''}${state.work.buildFailing ? ',构建失败中' : ''}`,
|
|
37
41
|
ratio === undefined
|
|
38
42
|
? '- 成本估算:还没有读数'
|
|
39
43
|
: `- 成本估算:上下文成本约节省 ${Math.round(ratio * 100)}%(${state.usage.samples} 次读数,已扣除压缩本身的开销;对比的是没有插件、一直涨到原生压缩点的情况)${
|
|
@@ -9,6 +9,7 @@ export type Signal =
|
|
|
9
9
|
| { kind: 'task-completed' }
|
|
10
10
|
| { kind: 'checkpoint'; milestone: boolean }
|
|
11
11
|
| { kind: 'tool-result'; isError: boolean }
|
|
12
|
+
| { kind: 'file-changed' }
|
|
12
13
|
|
|
13
14
|
export type MilestoneSource = 'commit' | 'tests-passed' | 'task-completed' | 'checkpoint'
|
|
14
15
|
|
|
@@ -19,6 +20,8 @@ export type WorkState = {
|
|
|
19
20
|
milestone: MilestoneSource | null
|
|
20
21
|
/** 最近的阶段节点出现时的上下文用量,用来看它到压缩时隔了多远 */
|
|
21
22
|
milestoneTokens?: number
|
|
23
|
+
/** 最近的阶段节点之后又改过文件:已经开始下一件事,这个阶段节点不再算数 */
|
|
24
|
+
changedSinceMilestone?: boolean
|
|
22
25
|
testsFailing: boolean
|
|
23
26
|
buildFailing: boolean
|
|
24
27
|
errorStreak: number
|
|
@@ -26,6 +29,7 @@ export type WorkState = {
|
|
|
26
29
|
|
|
27
30
|
export const INITIAL_WORK_STATE: WorkState = Object.freeze({
|
|
28
31
|
milestone: null,
|
|
32
|
+
changedSinceMilestone: false,
|
|
29
33
|
testsFailing: false,
|
|
30
34
|
buildFailing: false,
|
|
31
35
|
errorStreak: 0,
|
|
@@ -50,7 +54,7 @@ export function milestoneOf(signal: Signal): MilestoneSource | null {
|
|
|
50
54
|
export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
51
55
|
const milestone = milestoneOf(signal)
|
|
52
56
|
// 失败之后又做完了一件事(比如提交、checkpoint),那段失败就算过去了,不再挡着压缩。
|
|
53
|
-
const next = milestone ? { ...state, milestone, testsFailing: false, buildFailing: false } : state
|
|
57
|
+
const next = milestone ? { ...state, milestone, changedSinceMilestone: false, testsFailing: false, buildFailing: false } : state
|
|
54
58
|
switch (signal.kind) {
|
|
55
59
|
case 'tests':
|
|
56
60
|
return { ...next, testsFailing: !signal.passed }
|
|
@@ -58,6 +62,9 @@ export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
|
58
62
|
return { ...next, buildFailing: !signal.passed }
|
|
59
63
|
case 'tool-result':
|
|
60
64
|
return { ...next, errorStreak: signal.isError ? state.errorStreak + 1 : 0 }
|
|
65
|
+
// 做完一件事之后又改文件,说明已经开始下一件事,要等它做完。
|
|
66
|
+
case 'file-changed':
|
|
67
|
+
return next.milestone ? { ...next, changedSinceMilestone: true } : next
|
|
61
68
|
default:
|
|
62
69
|
return next
|
|
63
70
|
}
|
|
@@ -65,7 +72,7 @@ export function applySignal(state: WorkState, signal: Signal): WorkState {
|
|
|
65
72
|
|
|
66
73
|
/** 压缩后清空阶段节点;失败这些仍然成立的事实保留。 */
|
|
67
74
|
export function afterCompaction(state: WorkState): WorkState {
|
|
68
|
-
return { ...state, milestone: null, milestoneTokens: undefined }
|
|
75
|
+
return { ...state, milestone: null, milestoneTokens: undefined, changedSinceMilestone: false }
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/** 表明工作做到一半的硬信号,没有则返回 null。 */
|
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
|
}
|