smartcompact 0.1.4 → 0.1.5

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.
@@ -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,7 +14,7 @@ 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
@@ -26,5 +26,6 @@ export function recognizePi(outcome: PiToolOutcome, ledger: Ledger): { signals:
26
26
  signals.push(...classified.signals)
27
27
  next = classified.ledger
28
28
  }
29
- return { signals, ledger: next }
29
+ const read = outcome.toolName === 'read' && !outcome.isError && typeof path === 'string' && path ? path : undefined
30
+ return { signals, ledger: next, read }
30
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smartcompact",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -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 {
@@ -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
  }