smartcompact 0.1.4 → 0.1.6

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 CHANGED
@@ -13,7 +13,7 @@
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
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.
@@ -80,7 +80,7 @@ Codex hooks cannot postpone a compaction in the middle of a turn, so SmartCompac
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
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
86
 
package/README.zh-CN.md CHANGED
@@ -13,7 +13,7 @@
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
  - **用进度笔记接上。** 改过的文件、最近的提交和测试结果、记下的决定,压缩后补回给模型。
@@ -80,7 +80,7 @@ Codex 的 hook 不能推迟一轮中间的压缩,所以插件在一轮结束
80
80
 
81
81
  ## 常见问题
82
82
 
83
- **怎么判断"做完一件事"?** 靠识别 agent 执行的命令。看的是上次压缩以来有没有做完过一件事,不要求正好在这一轮:一轮结束本身就是停顿点,非要等下一次提交,多半只是多花钱。提交只要真的执行了 `git commit` 且工具调用没报错就算;测试命令接了管道(`| tail`)又没开 `pipefail` 时退出码不可信,不算测试结果。
83
+ **怎么判断"做完一件事"?** 靠识别 agent 执行的命令。看的是上次压缩以来有没有做完过一件事,不要求正好在这一轮:一轮结束本身就是停顿点,非要等下一次提交,多半只是多花钱。但做完之后又开始改文件,就算在做下一件事,要等它做完。提交只要真的执行了 `git commit` 且工具调用没报错就算;测试命令接了管道(`| tail`)又没开 `pipefail` 时退出码不可信,不算测试结果。
84
84
 
85
85
  **压缩后模型会不会忘事?** 随口提到的细节有时会丢。进度笔记只记文件、提交、测试和模型主动记下的决定。用户在对话里随口交代的细节,靠压缩时要求摘要原样保留用户说过的具体信息。Pi 上这样做明显减少了丢失,但不能保证不丢。
86
86
 
@@ -12,6 +12,7 @@ import type { Lines } from '../../plugin/src/core/lines.ts'
12
12
  import { resolveParams } from '../../plugin/src/core/params.ts'
13
13
  import { countDecisions, renderReport } from '../../plugin/src/core/report.ts'
14
14
  import {
15
+ cycleReads,
15
16
  type DecisionLogEntry,
16
17
  expectedAfterTokens,
17
18
  growthSinceMilestone,
@@ -19,6 +20,7 @@ import {
19
20
  parseState,
20
21
  type SessionState,
21
22
  withCompaction,
23
+ withRead,
22
24
  withSample,
23
25
  withSignals,
24
26
  } from '../../plugin/src/core/state.ts'
@@ -163,8 +165,9 @@ export default function smartCompact(pi: ExtensionAPI) {
163
165
  if (event.toolName === CHECKPOINT_TOOL) return
164
166
  sample(ctx, lines(ctx))
165
167
  const text = event.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
166
- const { signals, ledger } = recognizePi({ toolName: event.toolName, input: event.input, isError: event.isError, text }, state.ledger)
168
+ const { signals, ledger, read } = recognizePi({ toolName: event.toolName, input: event.input, isError: event.isError, text }, state.ledger)
167
169
  state = withSignals({ ...state, ledger }, signals)
170
+ if (read) state = withRead(state, read)
168
171
  })
169
172
 
170
173
  pi.on('before_agent_start', async (_event, ctx) => {
@@ -216,10 +219,11 @@ export default function smartCompact(pi: ExtensionAPI) {
216
219
  const fromPlugin = event.reason === 'manual' && ours
217
220
  ours = false
218
221
  const summaryTokens = Math.round(event.compactionEntry.summary.length / 4)
222
+ const reads = cycleReads(state)
219
223
  // 原生按阈值的压缩、溢出恢复和用户的 /compact,没有插件时也会发生;只有插件发起的压缩计入节省。
220
224
  state = withCompaction(state, event.compactionEntry.tokensBefore, summaryTokens, fromPlugin)
221
225
  persist()
222
- log({ event: 'compacted', trigger: fromPlugin ? 'plugin' : event.reason === 'threshold' ? 'auto' : event.reason, tokensBefore: event.compactionEntry.tokensBefore })
226
+ log({ event: 'compacted', trigger: fromPlugin ? 'plugin' : event.reason === 'threshold' ? 'auto' : event.reason, tokensBefore: event.compactionEntry.tokensBefore, ...reads })
223
227
  pi.sendMessage(
224
228
  { customType: LEDGER_MESSAGE, content: renderLedger(state.ledger, params(ctx).ledgerMaxChars), display: false },
225
229
  { deliverAs: ctx.isIdle() ? 'nextTurn' : 'steer' },
@@ -14,17 +14,19 @@ export type PiToolOutcome = {
14
14
 
15
15
  const EDIT_TOOLS = new Set(['edit', 'write'])
16
16
 
17
- export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals: Signal[]; ledger: Ledger } {
17
+ export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals: Signal[]; ledger: Ledger; read?: string } {
18
18
  const signals: Signal[] = [{ kind: 'tool-result', isError: outcome.isError }]
19
19
  let next = ledger
20
20
  const { path, command } = outcome.input
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)
26
27
  signals.push(...classified.signals)
27
28
  next = classified.ledger
28
29
  }
29
- return { signals, ledger: next }
30
+ const read = outcome.toolName === 'read' && !outcome.isError && typeof path === 'string' && path ? path : undefined
31
+ return { signals, ledger: next, read }
30
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartcompact",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Compact long agent sessions earlier, at a finished piece of work, to cut cost without losing results (Claude Code, pi, Codex)",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@ export type DecisionInput = {
17
17
 
18
18
  export type TurnEndDecision =
19
19
  | { action: 'none'; reason: 'paused' | 'no-reading' | 'below-line' }
20
- | { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'waiting-for-milestone' }
20
+ | { action: 'wait'; reason: HardSignal | 'insufficient-benefit' | 'changed-since-milestone' | 'waiting-for-milestone' }
21
21
  | { action: 'compact'; reason: MilestoneSource }
22
22
 
23
23
  export type MidTurnDecision =
@@ -36,7 +36,9 @@ export function decideAtTurnEnd(input: DecisionInput, params: Params): TurnEndDe
36
36
  const hard = hardSignal(work, params)
37
37
  if (hard) return { action: 'wait', reason: hard }
38
38
  if (!freesEnough(tokens, input, params)) return { action: 'wait', reason: 'insufficient-benefit' }
39
- if (work.milestone) return { action: 'compact', reason: work.milestone }
39
+ if (work.milestone && !work.changedSinceMilestone) return { action: 'compact', reason: work.milestone }
40
+ // 做完一件事之后又改了文件,已经在做下一件事,等它做完。
41
+ if (work.milestone) return { action: 'wait', reason: 'changed-since-milestone' }
40
42
  // 不设兜底线:一直等不到做完一件事,多半是在做一件大活,正需要大上下文;交给原生在上限处压缩。
41
43
  return { action: 'wait', reason: 'waiting-for-milestone' }
42
44
  }
@@ -128,9 +128,20 @@ export function applyCheckpoint(ledger: Ledger, input: CheckpointInput): Ledger
128
128
  }
129
129
  }
130
130
 
131
- /** 排版成补回上下文的文本,超长时截断。 */
131
+ /** 排版成补回上下文的文本,超长时先丢最旧的决定、再丢最早改过的文件,仍超长才截断。 */
132
132
  export function renderLedger(ledger: Ledger, maxChars: number): string {
133
- const { facts, notes } = ledger
133
+ let decisions = ledger.notes.decisions
134
+ let files = ledger.facts.filesChanged
135
+ let rendered = layoutLedger(ledger, decisions, files)
136
+ while (rendered.length > maxChars && (decisions.length || files.length)) {
137
+ if (decisions.length) decisions = decisions.slice(1)
138
+ else files = files.slice(1)
139
+ rendered = layoutLedger(ledger, decisions, files)
140
+ }
141
+ return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
142
+ }
143
+
144
+ function layoutLedger({ facts, notes }: Ledger, decisions: readonly string[], files: readonly string[]): string {
134
145
  const lines = [
135
146
  LEDGER_MARKER,
136
147
  'Generated by the SmartCompact plugin to keep continuity across compaction. It is not a message from the user.',
@@ -139,19 +150,19 @@ export function renderLedger(ledger: Ledger, maxChars: number): string {
139
150
  const rows = body.filter((row): row is string => Boolean(row))
140
151
  if (rows.length) lines.push('', `## ${title}`, ...rows)
141
152
  }
153
+ const omitted = (dropped: number, what: string) => (dropped ? `- …(${dropped} earlier ${what} omitted)` : undefined)
142
154
  section('Goal', [notes.goal])
143
155
  section('Current state', [notes.current])
144
156
  section('Next', [notes.next])
145
- section('Decisions', notes.decisions.map(decision => `- ${decision}`))
146
- section('Tasks in progress', facts.tasksInProgress.map(task => `- ${task}`))
147
- section('Files changed', facts.filesChanged.map(file => `- ${file}`))
148
157
  section('Latest results', [
149
158
  facts.lastTest && `- tests ${facts.lastTest.passed ? 'passed' : 'FAILED'}: ${facts.lastTest.command}`,
150
159
  facts.lastBuild && `- build ${facts.lastBuild.passed ? 'passed' : 'FAILED'}: ${facts.lastBuild.command}`,
151
160
  facts.lastCommit && `- last commit: ${facts.lastCommit.summary}`,
152
161
  ])
153
- const rendered = lines.join('\n')
154
- return rendered.length <= maxChars ? rendered : `${rendered.slice(0, maxChars - 20)}\n…(truncated)`
162
+ section('Tasks in progress', facts.tasksInProgress.map(task => `- ${task}`))
163
+ section('Decisions', [omitted(notes.decisions.length - decisions.length, 'decisions'), ...decisions.map(decision => `- ${decision}`)])
164
+ section('Files changed', [omitted(facts.filesChanged.length - files.length, 'files'), ...files.map(file => `- ${file}`)])
165
+ return lines.join('\n')
155
166
  }
156
167
 
157
168
  export function isLedgerText(value: string): boolean {
@@ -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
@@ -33,7 +37,7 @@ export function renderReport(input: {
33
37
  `- 本会话压缩 ${state.compactions} 次;最近一次压缩后约 ${k(state.measuredAfterTokens)}`,
34
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}`,
35
39
  `- 信号:提交 ${seen.commit},测试通过 ${seen['tests-passed']},任务完成 ${seen['task-completed']},checkpoint 完成 ${seen.checkpoint}`,
36
- `- 当前状态:${state.work.milestone ? `已到阶段节点(${state.work.milestone})` : '等待阶段节点'}${state.work.testsFailing ? ',测试失败中' : ''}${state.work.buildFailing ? ',构建失败中' : ''}`,
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。 */
@@ -20,8 +20,24 @@ export type SessionState = {
20
20
  /** 各种信号出现过的次数,用于确认识别是否生效 */
21
21
  seen: SignalsSeen
22
22
  usage: UsageTally
23
+ /** 读文件的统计;平台认不出读文件时没有这一项 */
24
+ reads?: ReadStats
23
25
  }
24
26
 
27
+ /** 用来看压缩后要花多少次读取重新熟悉代码。 */
28
+ export type ReadStats = {
29
+ /** 上次压缩之前读过或改过的文件 */
30
+ known: string[]
31
+ /** 上次压缩以来读过的文件 */
32
+ current: string[]
33
+ /** 上次压缩以来读文件的次数 */
34
+ reads: number
35
+ /** 其中读的是压缩前已经看过的文件 */
36
+ rereads: number
37
+ }
38
+
39
+ const MAX_KNOWN_FILES = 300
40
+
25
41
  export const INITIAL_SESSION_STATE: SessionState = Object.freeze({
26
42
  work: INITIAL_WORK_STATE,
27
43
  ledger: EMPTY_LEDGER,
@@ -73,8 +89,15 @@ export function withSample(state: SessionState, tokens: number, ceiling: number,
73
89
  * @param attributed 是否算作插件带来的压缩;用户手动 /compact 没有插件时也会发生,不计入节省
74
90
  */
75
91
  export function withCompaction(state: SessionState, before: number, summary: number, attributed = true): SessionState {
92
+ const reads = state.reads && {
93
+ known: [...new Set([...state.reads.known, ...state.reads.current, ...state.ledger.facts.filesChanged])].slice(-MAX_KNOWN_FILES),
94
+ current: [],
95
+ reads: 0,
96
+ rereads: 0,
97
+ }
76
98
  return {
77
99
  ...state,
100
+ ...(reads && { reads }),
78
101
  work: afterCompaction(state.work),
79
102
  compactions: state.compactions + 1,
80
103
  notesUpdated: false,
@@ -83,6 +106,25 @@ export function withCompaction(state: SessionState, before: number, summary: num
83
106
  }
84
107
  }
85
108
 
109
+ /** 记一次读文件:读的是上次压缩前看过的文件就算重读。 */
110
+ export function withRead(state: SessionState, path: string): SessionState {
111
+ const stats = state.reads ?? { known: [], current: [], reads: 0, rereads: 0 }
112
+ return {
113
+ ...state,
114
+ reads: {
115
+ known: stats.known,
116
+ current: [...stats.current.filter(file => file !== path), path].slice(-MAX_KNOWN_FILES),
117
+ reads: stats.reads + 1,
118
+ rereads: stats.rereads + (stats.known.includes(path) ? 1 : 0),
119
+ },
120
+ }
121
+ }
122
+
123
+ /** 压缩时记进日志的本周期读文件次数;平台认不出读文件时为空。 */
124
+ export function cycleReads(state: SessionState): Pick<DecisionLogEntry, 'reads' | 'rereads'> {
125
+ return state.reads ? { reads: state.reads.reads, rereads: state.reads.rereads } : {}
126
+ }
127
+
86
128
  export type DecisionLogEntry = {
87
129
  at: string
88
130
  /** 会话 ID,报告据此只统计当前会话 */
@@ -95,6 +137,9 @@ export type DecisionLogEntry = {
95
137
  tokensAfter?: number
96
138
  /** 决定压缩时,距离最近的阶段节点上下文涨了多少 */
97
139
  sinceMilestoneTokens?: number
140
+ /** 压缩时:上次压缩以来读文件的次数,以及其中重读压缩前看过的文件的次数 */
141
+ reads?: number
142
+ rereads?: number
98
143
  trigger?: string
99
144
  detail?: string
100
145
  }