See what a compaction takes away and what it leaves, pin facts that must survive it, and edit the summary afterwards.

A Claude Code mod that shows what a compaction takes away and what it leaves, lets the model pin facts that must survive it, and lets the model or you edit the summary afterwards.
It is a plugin of function hooks (Claude Code 2.1.288 or later). It hooks session.compact, the one event that carries the transcript being compacted and the result the engine produces.
From install to your first edited summary in six steps. What it does below is the reference for each part.
claude --version
You need 2.1.288 or later; claude update brings an older one up to date. There is nothing to build: Claude Code loads the TypeScript as it is, and the mod has no dependencies.
From the marketplace (the usual way). This repository is its own marketplace:
claude plugin marketplace add ewanlimr25/compact-lens
claude plugin install compact-lens@compact-lens
The first command adds the marketplace, and the second installs the mod for your user, in every session. Inside a session, /plugin install compact-lens --marketplace ewanlimr25/compact-lens does both. Then start a new session. The install notes that four options are not set yet: each has a default (see Options), so there is nothing you must set.
From a clone, to read or change the code:
git clone https://github.com/ewanlimr25/compact-lens.git ~/compact-lens
cd ~/compact-lens
claude plugin validate .
claude plugin test .
validate ends with ✔ Validation passed, and test with 0 fail. To load the clone in every session, add it to the env block of ~/.claude/settings.json, merging it into the block you may already have:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/compact-lens"
}
}
The value is one or more folders, absolute or starting with ~, separated by : (; on Windows); if it already names a folder, add :~/compact-lens to the end. Claude Code reads it when a session starts, in the terminal, the desktop app and claude -p alike, so start a new session. Only your user settings can set it, not a project's. For one session only, start Claude Code with claude --plugin-dir ~/compact-lens instead.
Install it one way only: loaded twice (from the marketplace and a clone, or from two clones), every hook runs twice.
Type /compact-lens. It opens the pane and answers:
context not measured yet; 0 compactions; 0 pinned
snapshots: /Users/you/.claude/compact-lens/<session-id>
The line under the prompt reads compact-lens: – · 0 compactions · 0 pinned, and shows the context fill once Claude has replied. If /compact-lens is an unknown command, see Troubleshooting.
Work as usual. When Claude Code compacts, on its own or because you ran /compact, the mod writes the first folder, ~/.claude/compact-lens/<session-id>/01/. Then:
/compact-lens show prints what the summary dropped (lost.md): your prompts word for word, the files and commands, and the paths and names the new context no longer mentions;/compact-lens show after prints what the context holds now, and show before the transcript that was compacted;/compact-lens list lists every compaction and its folder.Claude gets a note after the summary that names these files, so you can also ask it to look something up: "check lost.md for the exact command we ran".
/compact-lens edit. The summary goes into your prompt box under a dim first line, [compact-lens edit #01: …]. Keep that line.Prompt dropped by a hook: compact-lens: your edit is saved to …/01/summary.md …. That is expected: the edit was saved, and Claude never saw it as a prompt. The mod then puts /compact in the prompt box./compact. The mod answers it itself: it swaps the summary for your text, keeps every message after it, and runs no summariser. A toast says your edited summary is in place (#02), and the swap is recorded as folder 02/ with trigger apply.To check, ask Claude what its summary says about the part you changed.
An empty box at the third step cancels. /compact-lens cancel drops an edit waiting at the fourth, and a /compact with words after it (/compact focus on the API) compacts as usual and drops it too.
/compact-lens keep the staging database is db-stg-2; never touch prod
A pinned note goes into the summariser's instructions and, word for word, into the note after every later summary. Claude can pin facts too, with its keep tool; it is reminded to once the context passes 70% full. /compact-lens list shows the pins, and /compact-lens unpin <n> removes one.
From the marketplace:
claude plugin update compact-lens@compact-lens
then start a new session. From a clone, git pull in it: Claude Code watches the folder, so a running terminal session reloads the mod once the files stop changing, and other sessions load the new version when they start. Either way, snapshots and pinned notes are kept.
From the marketplace, claude plugin uninstall compact-lens@compact-lens (and claude plugin marketplace remove compact-lens to drop the marketplace too). From a clone, take the folder out of CLAUDE_CODE_PLUGIN_DIRS (or stop passing --plugin-dir). Then start a new session. The snapshots stay in ~/.claude/compact-lens/ until you delete them; the pinned notes and the compaction records are in ~/.claude/plugins/store/compact-lens_*.json.
before.md and before.json are the whole compacted transcript, tool results included, so anything the session read (a key in a .env file, say) is in them. They stay on your machine; don't commit or share the folder./compact-lens is an unknown command. The mod did not load. Check claude --version, then that claude plugin list shows compact-lens@compact-lens enabled, or, for a clone, that the path you gave holds .claude-plugin/plugin.json. When the module fails to load, a dim line in the session says why, and claude -p --plugin-dir ~/compact-lens "hi" prints the reason on stderr./compact summarised as usual instead of applying my edit. No edit was waiting: it was cancelled, the text matched the summary already in use, or /compact had words after it. While an edit waits, /compact-lens says so and the line under the prompt ends · /compact applies the edit.edit-unapplied-<time>.md. A newer compaction ran while you were editing. Run /compact-lens edit again to edit the current summary.At every compaction (/compact, the automatic one, or one a plugin asked for), the mod writes a folder under ~/.claude/compact-lens/<session-id>/<nn>/:
| File | What it holds |
|---|---|
before.md | the whole transcript that was compacted, one section per message, every tool call with its input and its result (results cut to 1,500 characters) |
before.json | the same, raw and uncut |
summary.md | the summary's text, exactly; edit it and apply it (below) |
after.md | what the context held right after: the summary and the kept messages |
lost.md | what the summary does not mention: the person's prompts verbatim, the slash commands they ran, files edited and read (by Read, and by cat, head, sed and the like in Bash), commands run, agents spawned, and the paths, hashes, URLs, code spans and constants of the compacted span that the context after it no longer holds |
meta.json | the record: trigger, time, message counts, token counts |
Right after the summary, the mod inserts one user-role note the model reads: the counts, the three paths, and the pinned notes verbatim. A session-scoped system-prompt section names the snapshot folder, the count of compactions and the two tools, so the orientation survives even when the note itself is compacted away later.
Ahead of a compaction, at the first model step where the context fill reaches warnAtPercent (default 70), the model gets one hidden note: the fill, where the snapshots will go, and that it can pin facts. Once per window; a compaction re-arms it.
Pinned notes. The model calls mcp__compact-lens__keep with a fact; you type /compact-lens keep <text>. Pinned notes go into the summariser's instructions ("keep these verbatim") and, whatever the summariser does, verbatim into the note after the summary.
Seeing it in the session. /compact-lens show prints the latest lost.md; /compact-lens show after, show summary 2, show before, show pinned print the others (cut at 20,000 characters; the file keeps the rest). The reply lands in the transcript, so the model reads it too.
Editing the summary afterwards, in the session. Run /compact-lens edit, or press "Edit summary" in the pane. The summary the conversation runs on goes into your prompt box under a dim header line. Edit it there, or press ctrl+g to edit it in your own editor, then press Enter: the mod catches that prompt before the model sees it and saves it to <nn>/summary.md, then puts /compact in the prompt box. Press Enter on that /compact to apply the edit. An empty box cancels; an edit left as it was applies nothing. If the box already holds a draft of yours, the mod leaves it alone and says so. An edit of an older summary, made before a newer compaction ran, is kept in <nn>/edit-unapplied-<time>.md rather than applied.
Editing the summary file. Edit <nn>/summary.md yourself, or ask the model to, then:
mcp__compact-lens__apply: when its turn ends, /compact goes into your prompt box;/compact-lens apply, or press "Apply summary.md" in the pane: /compact goes into the prompt box at once.Press Enter on /compact. While an edit waits, the mod answers a /compact with nothing after it itself: the current summary message is swapped for the file's text, every message after it is kept as it is, and no summariser runs. It is recorded as the next numbered folder with trigger apply. The status line says when an edit waits. /compact-lens cancel drops it; so does a /compact with instructions after it, or an automatic compaction, which compact as usual (the edit stays in its file). If the apply fails part way, nothing changes and the edit keeps waiting.
Why /compact and not a button that applies at once: the engine never shows a plugin's own $.session.compact() to that plugin's session.compact hook, so a mod cannot answer a compaction it asked for; the engine's summariser would run instead. Your /compact reaches the hook.
A compaction the mod did not see (one that ran while the mod was reloading or not loaded) is recorded when the mod next looks (at session start, and on edit, show, list, apply): its summary and what followed it, under trigger unseen. Its transcript was not seen, so before.md and lost.md say so; the session's own transcript file still holds it.
A precomputed summary (the engine drafting one ahead of time) is written to draft.md. If you edit it before the compaction lands and the engine reuses the draft, your edit becomes the summary.
The pane. /compact-lens opens it: the fill, the compactions, the pinned notes, and the edit, apply and refresh buttons. /compact-lens list prints the same as text. A status line under the prompt reads compact-lens: 72% · 1 compaction · 2 pinned, and adds · /compact applies the edit while an edit waits.
Subagent compactions pass through untouched.
With /plugin configure compact-lens@compact-lens in a session; a clone's are rows in /config, or pluginConfigs.compact-lens in settings:
| Option | Default | Meaning |
|---|---|---|
warnAtPercent | 70 | the fill at which the model gets the one note ahead of a compaction; 0 turns it off |
dir | "" | the snapshot root; empty means ~/.claude/compact-lens |
openPane | false | open the pane at session start (a wide terminal seats it) |
statusLine | true | the line under the prompt |
.claude-plugin/plugin.json the manifest, the options, the state contract's path
.claude-plugin/marketplace.json the repository as a marketplace of one, so it installs by name
hooks/hooks.json names the hooks module
hooks/register.tsx every call on `$`: the hooks, the compaction, the tools, the command, the pane
hooks/editor.ts the edit in the prompt box: its header, finding it, judging it (pure)
hooks/records.ts a compaction's record and the message helpers (pure)
hooks/texts.ts the command's help and the tools' descriptions (pure)
hooks/snapshot.ts SessionMessage[] → before.md / after.md (pure)
hooks/lost.ts the lost report (pure)
hooks/notes.ts the note, the warning, the system-prompt section (pure)
hooks/paths.ts the folder layout and the limits (pure)
types/index.d.ts the $.state contract
tests/ claude plugin test
LICENSE MIT
Every call on $ sits in hooks/register.tsx because the engine's validator follows $ only into functions declared in the hooks module itself, and reads the state atoms there alone.
claude plugin validate .
claude plugin test .
Type-check with the declarations the engine lays beside a loaded mod (.claude-plugin/types/), or with a tsconfig.json that includes the claude-code.d.ts the plugin-authoring skill writes.
MIT; see LICENSE.
hooks/register.tsx 800 lines1// The hooks module. Every call on `$` lives in this file (the engine's validator follows `$`
2// only into functions declared here); the pure parts are in the sibling modules.
3import { atom, read, update } from 'claude-code'
4import type {
5 EngineInterface, HookFailure, PromptSubmitInput, PromptSubmitResult, Register,
6 RenderInput, SessionCompactInput, SessionCompactResult, SessionMessage,
7} from 'claude-code'
8
9import type { CompactLensPin, CompactLensRecord } from '../types'
10import { buildEditDraft, editHeader, editReply, editStarted, fileStamp, isPersonsOwn, judgeEdit, looksLikeEdit, parseEditHeader } from './editor'
11import type { CurrentSummary, EditSubmission, NextSubmit, SavedEdit } from './editor'
12import { computeLost, renderLost } from './lost'
13import type { WarningFigures } from './notes'
14import { buildApplyNote, buildNote, figuresOf, fillPercent, buildPinnedDirective, buildPromptSection, buildWarning, mergeInstructions, plural, renderPinned } from './notes'
15import {
16 COMMAND, COMPACT_COMMAND, COMPACT_FILL, compactionFiles, draftFile, EDIT_CLEAR_DELAY_MS, EDIT_FILL_DELAY_MS, FREE_NAME_TRIES,
17 KEEP_TOOL, lensRoot, pad2, PANE_ID, pinnedFile, sessionDirOf,
18} from './paths'
19import type { CompactionFiles } from './paths'
20import type { NextCompact, RecordCompactionArgs } from './records'
21import { buildRecord, buildUnseenRecord, findRecordedSummary, heldLists, insertAfter, isRecordedSummary, replaceAt, summaryIn, userMessage } from './records'
22import { contextText, findSummaryIndex, renderTranscript, toJson, transcriptChars } from './snapshot'
23import {
24 APPLY_DESCRIPTION, ARGUMENT_HINT, EDIT_STARTED_TOAST, failureText, formatList, formatShown, formatStatus, HELP, KEEP_DESCRIPTION,
25 OFFER_TOAST, paneLine, parseShow, readConfig, RUN_COMPACT_TOAST, SHOWN_RECORDS, statusLineText, storeKey, unseenBefore, unseenLost,
26} from './texts'
27import type { Config } from './texts'
28
29// ---------------------------------------------------------------- state (declared here: the validator reads atoms in this file alone)
30
31const EMPTY_RECORDS: CompactLensRecord[] = []
32const EMPTY_PINS: CompactLensPin[] = []
33const NO_NUMBER: number | null = null
34const NO_TEXT: string | null = null
35
36const recordsAtom = atom({ plugin: 'compact-lens', key: 'compactions' } as const, EMPTY_RECORDS)
37const pinsAtom = atom({ plugin: 'compact-lens', key: 'pinned' } as const, EMPTY_PINS)
38const warnedAtAtom = atom({ plugin: 'compact-lens', key: 'warnedAt' } as const, NO_NUMBER)
39const pendingApplyAtom = atom({ plugin: 'compact-lens', key: 'pendingApply' } as const, false)
40const sessionDirAtom = atom({ plugin: 'compact-lens', key: 'sessionDir' } as const, NO_TEXT)
41const fillAtom = atom({ plugin: 'compact-lens', key: 'fill' } as const, NO_NUMBER)
42const draftAtom = atom({ plugin: 'compact-lens', key: 'draft' } as const, NO_TEXT)
43const editingAtom = atom({ plugin: 'compact-lens', key: 'editing' } as const, NO_NUMBER)
44
45// ---------------------------------------------------------------- config and small helpers
46
47/** A dim transcript line; the engine prints it under the plugin's name. */
48const log = ($: EngineInterface, line: string): void => $.ui.log(line)
49
50
51const isoNow = async ($: EngineInterface): Promise<string> => new Date(await $.clock.now()).toISOString()
52
53// ---------------------------------------------------------------- the store mirror
54
55/** Mirrors the session's pins and records to the store, so a resumed session finds them. */
56const persist = async ($: EngineInterface): Promise<void> => {
57 const id = await $.session.id()
58 const [pinned, compactions] = await Promise.all([read($, pinsAtom), read($, recordsAtom)])
59 await $.store.set(storeKey(id), { pinned, compactions })
60}
61
62/** Fills empty state from the store: a resumed session in a new process. */
63const restore = async ($: EngineInterface): Promise<void> => {
64 const { pinned, compactions } = heldLists(await $.store.get(storeKey(await $.session.id())))
65 if (compactions.length > 0 && (await read($, recordsAtom)).length === 0) await update($, recordsAtom, () => compactions)
66 if (pinned.length > 0 && (await read($, pinsAtom)).length === 0) await update($, pinsAtom, () => pinned)
67}
68
69// ---------------------------------------------------------------- pinned notes
70
71const mirrorPins = async ($: EngineInterface, pins: readonly CompactLensPin[]): Promise<void> => {
72 const dir = await read($, sessionDirAtom)
73 if (dir !== null) await $.fs.write(pinnedFile(dir), renderPinned(pins))
74 await persist($)
75}
76
77const pin = async ($: EngineInterface, text: string): Promise<{ id: number; count: number }> => {
78 const at = await isoNow($)
79 const pins = await update($, pinsAtom, list => [...list, { id: (list.at(-1)?.id ?? 0) + 1, text, at }])
80 await mirrorPins($, pins)
81 return { id: pins.at(-1)?.id ?? 0, count: pins.length }
82}
83
84const unpin = async ($: EngineInterface, id: number): Promise<boolean> => {
85 const before = await read($, pinsAtom)
86 if (!before.some(p => p.id === id)) return false
87 const pins = await update($, pinsAtom, list => list.filter(p => p.id !== id))
88 await mirrorPins($, pins)
89 return true
90}
91
92// ---------------------------------------------------------------- the apply of an edited summary
93
94// The engine never runs a plugin's own session.compact hook for a compaction that plugin asked for
95// with $.session.compact(): its summariser runs instead. So an apply rides on the person's /compact,
96// which the hook answers with the edited summary.
97
98/** Sets whether an edit waits, and redraws the status line, which says so. */
99const setPending = async ($: EngineInterface, isPending: boolean): Promise<void> => {
100 await update($, pendingApplyAtom, () => isPending)
101 await refreshStatus($)
102}
103
104const requestApply = ($: EngineInterface): Promise<void> => setPending($, true)
105
106// The model's apply offers /compact when its turn ends; a reload forgets it, and the status line still says an apply waits.
107let isOfferDue = false
108
109/** Puts `text` in the prompt box when the box is empty or holds only `replaced` (a caught prompt the engine put back); whether the box holds `text` now. */
110const fillFree = async ($: EngineInterface, text: string, replaced?: string): Promise<boolean> => {
111 const box = (await $.prompt.read()).text.trim()
112 const isFree = box === '' || (replaced !== undefined && box === replaced.trim())
113 if (!isFree || box === text.trim()) return box === text.trim()
114 return (await $.prompt.fill({ text, mode: 'replace' })).isFilled
115}
116
117/** Puts /compact in the prompt box when the box is free, else says to run it; never throws. */
118const offerCompact = async ($: EngineInterface, replaced?: string): Promise<void> => {
119 const isOffered = await fillFree($, COMPACT_FILL, replaced).catch(error => {
120 log($, `apply: ${COMPACT_COMMAND} did not go into the prompt box (${String(error)})`)
121 return false
122 })
123 $.ui.toast(isOffered ? OFFER_TOAST : RUN_COMPACT_TOAST)
124}
125
126const scheduleOffer = ($: EngineInterface, delayMs: number, replaced?: string): void => {
127 $.clock.after(delayMs, () => void offerCompact($, replaced))
128}
129
130/** The summary.md an apply would use, when it differs from the summary the conversation runs on; the reason otherwise. */
131const checkApplicable = async ($: EngineInterface): Promise<{ path: string } | string> => {
132 await syncUnseen($)
133 const [records, dir] = await Promise.all([read($, recordsAtom), read($, sessionDirAtom)])
134 const latest = records.at(-1)
135 if (latest === undefined || dir === null) return 'no compaction has run yet, so there is no summary to replace'
136 const path = compactionFiles(dir, latest.n).summary
137 if (!(await $.fs.exists(path))) return `${path} is missing`
138 const edited = (await $.fs.read(path)).trim()
139 if (edited === '') return `${path} is empty`
140 const inUse = await summaryNow($, records)
141 if (inUse === undefined || !isRecordedSummary(inUse, latest)) return `the summary of #${pad2(latest.n)} is not the one in use now, so there is nothing for ${path} to replace`
142 if (inUse.trim() === edited) return `${path} is the summary in use; edit it first`
143 return { path }
144}
145
146/** A compaction other than the person's /compact ran while an edit waited: the edit is of a summary no longer in use. */
147const supersedeApply = async ($: EngineInterface): Promise<void> => {
148 await setPending($, false)
149 const text = `a compaction ran before the edited summary was applied, so it was not; the edit stays in the previous folder's summary.md, and /${COMMAND} edit loads the new summary`
150 log($, text)
151 $.ui.toast(text)
152}
153
154// ---------------------------------------------------------------- a compaction the mod did not see
155
156/** The summary the conversation runs on: the engine puts it first in the next request; undefined before any compaction. */
157const summaryNow = async ($: EngineInterface, records: readonly CompactLensRecord[]): Promise<string | undefined> =>
158 summaryIn(await $.session.messages({ as: 'api' }), await $.session.messages(), records.map(r => r.summaryHead))
159
160/**
161 * Records the compaction the conversation's summary came from when the mod did not see it run (a
162 * reload, the mod not loaded): its summary and what follows, so it can be shown, edited and applied.
163 */
164const syncUnseen = ($: EngineInterface): Promise<void> => {
165 syncing ??= adoptUnseen($).finally(() => (syncing = undefined))
166 return syncing
167}
168
169// One look at a time: two at once (a command and its timer) would record the same compaction twice.
170let syncing: Promise<void> | undefined
171
172const adoptUnseen = async ($: EngineInterface): Promise<void> => {
173 const [records, dir] = await Promise.all([read($, recordsAtom), read($, sessionDirAtom)])
174 if (dir === null) return
175 const text = await summaryNow($, records)
176 if (text === undefined || records.some(r => isRecordedSummary(text, r))) return
177 const rows = await $.session.messages()
178 const index = rows.findIndex(m => m.role === 'user' && m.text === text)
179 const after = index < 0 ? [userMessage(text)] : rows.slice(index)
180 const n = records.length + 1
181 const files = compactionFiles(dir, n)
182 const at = await isoNow($)
183 await $.fs.write(files.before, unseenBefore(n, at))
184 await $.fs.write(files.summary, text)
185 await $.fs.write(files.lost, unseenLost(n, at))
186 await writeRecord($, buildUnseenRecord({ n, at, files, after, summary: text }), files, after)
187 log($, `compaction #${n} ran unseen; its summary is recorded in ${files.summary}`)
188}
189
190// ---------------------------------------------------------------- editing the summary in the prompt box
191
192/** The latest compaction's summary.md, one the mod did not see run included; the reason there is none to edit otherwise. */
193const currentSummary = async ($: EngineInterface): Promise<CurrentSummary | string> => {
194 await syncUnseen($)
195 const [records, dir] = await Promise.all([read($, recordsAtom), read($, sessionDirAtom)])
196 const latest = records.at(-1)
197 if (latest === undefined || dir === null) return 'no compaction has run yet, so there is no summary to edit'
198 const files = compactionFiles(dir, latest.n)
199 if (!(await $.fs.exists(files.summary))) return `${files.summary} is missing, so there is no summary to edit`
200 return { n: latest.n, files, text: await $.fs.read(files.summary) }
201}
202
203/** Puts the summary in the prompt box under its header and opens the edit; the reason when it did not go in. */
204const fillEditor = async ($: EngineInterface): Promise<string | undefined> => {
205 const current = await currentSummary($)
206 if (typeof current === 'string') return current
207 const box = await $.prompt.read()
208 if (box.text.trim() !== '') {
209 return parseEditHeader(box.text) === undefined
210 ? 'the prompt box holds a draft of yours; send or clear it, then ask for the edit again'
211 : 'an edit is already in the prompt box; Enter applies it, or empty the box and ask again to start over'
212 }
213 const header = editHeader(current.n)
214 const text = buildEditDraft(current.n, current.text)
215 const filled = await $.prompt.fill({ text, mode: 'replace', decorations: [{ start: 0, end: header.length, dimColor: true }] })
216 if (!filled.isFilled) {
217 const cause = filled.refusal === undefined ? '' : ` (${filled.refusal})`
218 return `the prompt box did not take the summary${cause}; edit ${current.files.summary}, run /${COMMAND} apply, then ${COMPACT_COMMAND}`
219 }
220 await update($, editingAtom, () => current.n)
221 return undefined
222}
223
224const reportEditFailure = ($: EngineInterface, failure: string): void => {
225 log($, `edit: ${failure}`)
226 $.ui.toast(failure)
227}
228
229/** Fills the box and says so; any failure is reported, never thrown. */
230const startEdit = async ($: EngineInterface, success?: string): Promise<void> => {
231 try {
232 const failure = await fillEditor($)
233 if (failure !== undefined) reportEditFailure($, failure)
234 else if (success !== undefined) $.ui.toast(success)
235 } catch (error) {
236 reportEditFailure($, `the summary could not go into the prompt box (${String(error)})`)
237 }
238}
239
240/** The command's way: a moment later, once the engine has emptied the box for the Enter that ran the command. */
241const scheduleEdit = ($: EngineInterface): void => {
242 $.clock.after(EDIT_FILL_DELAY_MS, () => void startEdit($))
243}
244
245const closeEdit = async ($: EngineInterface): Promise<void> => {
246 if ((await read($, editingAtom)) !== null) await update($, editingAtom, () => null)
247}
248
249/** Empties the box if the engine put the caught prompt back in it; anything else there is left alone. */
250const scheduleClear = ($: EngineInterface, submitted: string): void => {
251 $.clock.after(EDIT_CLEAR_DELAY_MS, () => void fillFree($, '', submitted).catch(error => log($, `edit: the prompt box was not emptied (${String(error)})`)))
252}
253
254/** A path for a kept-aside text that holds nothing yet: `<stem>.md`, else `<stem>-2.md`, and so on. */
255const freePath = async ($: EngineInterface, stem: string): Promise<string> => {
256 for (let i = 1; i <= FREE_NAME_TRIES; i += 1) {
257 const path = i === 1 ? `${stem}.md` : `${stem}-${i}.md`
258 if (!(await $.fs.exists(path))) return path
259 }
260 throw new Error(`no free name for ${stem}.md`)
261}
262
263/** The edit in a submitted prompt: by its header, or, while an edit is open, by the open summary's own lines. */
264const recognizeEdit = async ($: EngineInterface, text: string): Promise<EditSubmission | undefined> => {
265 const byHeader = parseEditHeader(text)
266 if (byHeader !== undefined) return byHeader
267 const [n, dir] = await Promise.all([read($, editingAtom), read($, sessionDirAtom)])
268 if (n === null || dir === null) return undefined
269 const path = compactionFiles(dir, n).summary
270 const summary = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
271 return looksLikeEdit(text, summary) ? { n, body: text.trim() } : undefined
272}
273
274/** A caught edit: saved to summary.md for the apply, kept aside when there is no summary or a newer one, or dropped when empty or unchanged. */
275const saveEdit = async ($: EngineInterface, edit: EditSubmission, isTurnRunning: boolean): Promise<SavedEdit> => {
276 const [records, dir] = await Promise.all([read($, recordsAtom), read($, sessionDirAtom)])
277 if (dir === null) throw new Error('the snapshot folder is not set')
278 const latestN = records.at(-1)?.n
279 const summaryPath = compactionFiles(dir, latestN ?? edit.n).summary
280 const verdict = judgeEdit(edit, latestN, await summaryNow($, records))
281 const keptPath = verdict === 'none' || verdict === 'stale' ? await freePath($, `${compactionFiles(dir, edit.n).dir}/edit-unapplied-${fileStamp(await isoNow($))}`) : ''
282 if (verdict === 'none' || verdict === 'stale') await $.fs.write(keptPath, edit.body)
283 const isApplyDropped = verdict === 'unchanged' && (await read($, pendingApplyAtom))
284 if (verdict === 'apply' || isApplyDropped) await $.fs.write(summaryPath, edit.body)
285 if (verdict === 'apply' || isApplyDropped) await setPending($, verdict === 'apply')
286 log($, `the edit of #${pad2(edit.n)} in the prompt box: ${verdict}`)
287 return { reply: editReply({ verdict, n: edit.n, latestN, summaryPath, keptPath, isTurnRunning, isApplyDropped }), isApply: verdict === 'apply' }
288}
289
290/** When the edit hook fails on an edit: its text kept in a file where possible, and where. */
291const rescueEdit = async ($: EngineInterface, text: string): Promise<string> => {
292 try {
293 const dir = await read($, sessionDirAtom)
294 if (dir === null) return ''
295 const path = await freePath($, `${dir}/edit-unsaved-${fileStamp(await isoNow($))}`)
296 await $.fs.write(path, text)
297 return ` Your text is kept at ${path}.`
298 } catch {
299 return ''
300 }
301}
302
303/**
304 * The person's Enter on an edit in the prompt box, caught before the model sees it. Any other prompt
305 * passes; one typed at the terminal closes an open edit, since it replaced the edit in the box.
306 */
307const handleSubmit = async ($: EngineInterface, e: PromptSubmitInput, next: NextSubmit): Promise<PromptSubmitResult> => {
308 if (!isPersonsOwn(e)) return next(e)
309 const edit = await recognizeEdit($, e.text)
310 if (edit === undefined) {
311 if (e.origin.kind === 'composer') await closeEdit($)
312 return next(e)
313 }
314 const saved = await saveEdit($, edit, e.turnId !== undefined)
315 await closeEdit($)
316 if (saved.isApply) scheduleOffer($, EDIT_CLEAR_DELAY_MS, e.text)
317 else scheduleClear($, e.text)
318 return { drop: saved.reply }
319}
320
321/**
322 * The edit hook failed: an edit, or any prompt while an edit is open, is kept out of the model's
323 * sight and saved aside, and the edit is closed, so a failure that persists costs one prompt, not all.
324 */
325const handleSubmitFailure = async ($: EngineInterface, e: PromptSubmitInput, failure: HookFailure, next: NextSubmit): Promise<PromptSubmitResult> => {
326 const reason = failureText(failure)
327 log($, `the edit hook failed: ${reason}`)
328 const isEditOpen = await read($, editingAtom).then(n => n !== null, () => true)
329 if (!isPersonsOwn(e) || (parseEditHeader(e.text) === undefined && !isEditOpen)) return next(e)
330 const kept = await rescueEdit($, e.text)
331 await closeEdit($).catch(error => log($, `edit: the open edit was not closed (${String(error)})`))
332 return { drop: `compact-lens: the edit could not be saved (${reason}), so nothing was applied.${kept} The prompt was not sent to the model.` }
333}
334
335// ---------------------------------------------------------------- the fill, the status line, the warning
336
337// The statusLine option, as register read it; a reload reads it again.
338let isStatusLineOn = true
339
340const refreshStatus = async ($: EngineInterface): Promise<void> => {
341 if (!isStatusLineOn) {
342 $.ui.status(undefined)
343 return
344 }
345 const [fill, records, pins, isPending] = await Promise.all([read($, fillAtom), read($, recordsAtom), read($, pinsAtom), read($, pendingApplyAtom)])
346 $.ui.status(statusLineText({ fill, records, pinCount: pins.length, isPending }))
347}
348
349/** The context fill as a percent of the window, from the free usage call; undefined before the first response. */
350const readFill = async ($: EngineInterface): Promise<number | undefined> => {
351 const percent = fillPercent((await $.session.usage()).context)
352 if (percent === undefined) return undefined
353 await update($, fillAtom, () => percent)
354 return percent
355}
356
357/**
358 * The fill and the auto-compaction line on one base: the breakdown's compaction window when the
359 * engine answers one (it may be smaller than the model's window), else the plain fill alone.
360 */
361const warningFigures = async ($: EngineInterface, percent: number): Promise<WarningFigures> => {
362 try {
363 return figuresOf((await $.session.usage({ breakdown: 'summary' })).context.breakdown, percent)
364 } catch {
365 return { percent, thresholdPercent: undefined }
366 }
367}
368
369/** After a main-loop model step: the fill, the status line, and the one warning per window. */
370const afterStep = async ($: EngineInterface, config: Config): Promise<void> => {
371 const percent = await readFill($)
372 await refreshStatus($)
373 if (percent === undefined || config.warnAtPercent <= 0 || percent < config.warnAtPercent) return
374 if ((await read($, warnedAtAtom)) !== null) return
375 await update($, warnedAtAtom, () => percent)
376 const sessionDir = (await read($, sessionDirAtom)) ?? '(unset)'
377 const figures = await warningFigures($, percent)
378 const text = buildWarning({ ...figures, sessionDir })
379 const failure = await appendNote($, text)
380 if (failure !== undefined) {
381 log($, `the warning note was not appended (${failure}); it is tried again at the next step`)
382 await update($, warnedAtAtom, () => null)
383 return
384 }
385 $.ui.toast(`context at ${figures.percent}%; the model was told where the snapshots go`)
386}
387
388/** Appends a hidden user-role note; the reason when it could not be. */
389const appendNote = async ($: EngineInterface, text: string): Promise<string | undefined> => {
390 try {
391 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
392 return appended.deny
393 } catch (error) {
394 return String(error)
395 }
396}
397
398// ---------------------------------------------------------------- compaction
399
400const writeRecord = async ($: EngineInterface, record: CompactLensRecord, files: CompactionFiles, after: readonly SessionMessage[]): Promise<void> => {
401 const title = `after — compaction #${record.n} (${record.trigger}) at ${record.at}`
402 await $.fs.write(files.after, renderTranscript(after, { title, lines: [`${after.length} messages; what the context held right after the compaction.`] }))
403 await $.fs.write(files.meta, JSON.stringify(record, null, 2))
404 await update($, recordsAtom, list => [...list, record])
405 if (record.trigger !== 'apply') {
406 await update($, warnedAtAtom, () => null)
407 await update($, fillAtom, () => null)
408 }
409 await persist($)
410}
411
412/** The draft the person edited, if draft.md differs from what the mod wrote there; undefined otherwise. */
413const readEditedDraft = async ($: EngineInterface, dir: string, remembered: string): Promise<string | undefined> => {
414 const path = draftFile(dir)
415 if (!(await $.fs.exists(path))) return undefined
416 const edited = (await $.fs.read(path)).trim()
417 return edited !== '' && edited !== remembered.trim() ? edited : undefined
418}
419
420const clearDraft = async ($: EngineInterface, dir: string): Promise<void> => {
421 if ((await read($, draftAtom)) === null) return
422 await $.fs.write(draftFile(dir), '')
423 await update($, draftAtom, () => null)
424}
425
426/**
427 * The person's edited draft, to use as the summary when the engine reused the draft it was edited
428 * from; an edit the engine passed over is kept beside the record, never wiped.
429 */
430const takeEditedDraft = async ($: EngineInterface, dir: string, files: CompactionFiles, engineText: string): Promise<string | undefined> => {
431 const remembered = await read($, draftAtom)
432 if (remembered === null) return undefined
433 const edited = await readEditedDraft($, dir, remembered)
434 await clearDraft($, dir)
435 if (edited === undefined) return undefined
436 if (engineText.trim() === remembered.trim()) return edited
437 const kept = `${files.dir}/draft-unused.md`
438 await $.fs.write(kept, edited)
439 log($, `draft.md was edited, but the engine's summary moved on; the edit is kept at ${kept}`)
440 return undefined
441}
442
443const snapshotBefore = async ($: EngineInterface, messages: readonly SessionMessage[], files: CompactionFiles, title: string, line: string): Promise<void> => {
444 await $.fs.write(files.before, renderTranscript(messages, { title, lines: [line] }))
445 await $.fs.write(files.beforeJson, toJson(messages))
446}
447
448/** The summary, the lost report, the record and the note: what every real compaction leaves. */
449const recordCompaction = async ($: EngineInterface, args: RecordCompactionArgs): Promise<SessionMessage[]> => {
450 const { n, trigger, at, files, before, withSummary, summaryIndex, pins } = args
451 const summary = withSummary[summaryIndex] ?? userMessage('')
452 const lost = computeLost(before, contextText(withSummary))
453 const record = buildRecord({ n, trigger, at, files, before, after: withSummary, summary: summary.text, lost, tokensBefore: args.tokensBefore, tokensAfter: args.tokensAfter })
454 await $.fs.write(files.summary, summary.text)
455 await $.fs.write(files.lost, renderLost(lost, { n, trigger, at, beforePath: files.before, tokensBefore: args.tokensBefore }))
456 const note = userMessage(buildNote({ record, files, pins, keptCount: withSummary.length - 1 }))
457 const messages = insertAfter(withSummary, summaryIndex, note)
458 await writeRecord($, record, files, messages)
459 return messages
460}
461
462/** A real compaction: snapshot what goes in, let the engine summarise, snapshot what comes out, insert the note. */
463const handleReal = async ($: EngineInterface, e: SessionCompactInput, next: NextCompact, dir: string): Promise<SessionCompactResult> => {
464 const [records, pins] = await Promise.all([read($, recordsAtom), read($, pinsAtom)])
465 const n = records.length + 1
466 const files = compactionFiles(dir, n)
467 const at = await isoNow($)
468 const trigger = e.trigger === 'precompute' ? 'auto' : e.trigger
469 const beforeLine = `${e.messages.length} messages, ${transcriptChars(e.messages)} characters; the whole transcript the engine compacted.`
470 await snapshotBefore($, e.messages, files, `before — compaction #${n} (${trigger}) at ${at}`, beforeLine)
471
472 const instructions = mergeInstructions(e.instructions, buildPinnedDirective(pins))
473 const result = await next(instructions === undefined ? e : { ...e, instructions })
474 if (result.skip !== undefined) {
475 log($, `compaction #${n} skipped: ${result.skip}`)
476 return result
477 }
478 const summaryIndex = findSummaryIndex(result.messages)
479 if (summaryIndex < 0) {
480 log($, `compaction #${n}: no summary message found in the result; nothing recorded beyond ${files.before}`)
481 return result
482 }
483 const engineSummary = result.messages[summaryIndex] ?? userMessage('')
484 const edited = await takeEditedDraft($, dir, files, engineSummary.text)
485 const withSummary = replaceAt(result.messages, summaryIndex, edited === undefined ? engineSummary : userMessage(edited))
486 const messages = await recordCompaction($, { n, trigger, at, files, before: e.messages, withSummary, summaryIndex, pins, tokensBefore: result.tokensBefore, tokensAfter: result.tokensAfter })
487
488 const draftNote = edited === undefined ? '' : ', the edited draft.md used as the summary'
489 log($, `compaction #${n} (${trigger}): ${e.messages.length} → ${messages.length} messages${draftNote}; snapshots in ${files.dir}`)
490 $.ui.toast(`compaction #${n} recorded in ${files.dir}`)
491 return { ...result, messages }
492}
493
494/** A precomputed summary: keep its draft on disk so it can be read, or edited, before the compaction lands. */
495const handlePrecompute = async ($: EngineInterface, e: SessionCompactInput, next: NextCompact, dir: string): Promise<SessionCompactResult> => {
496 const result = await next(e)
497 if (result.skip !== undefined) return result
498 const index = findSummaryIndex(result.messages)
499 const text = result.messages[index]?.text
500 if (index < 0 || text === undefined) return result
501 const remembered = await read($, draftAtom)
502 const inProgress = remembered === null ? undefined : await readEditedDraft($, dir, remembered)
503 if (inProgress !== undefined) {
504 log($, `a summary was precomputed again, but ${draftFile(dir)} holds an edit; it was left as it is`)
505 return result
506 }
507 await $.fs.write(draftFile(dir), text)
508 await update($, draftAtom, () => text)
509 log($, `a summary was precomputed ahead of the compaction; its draft is at ${draftFile(dir)}`)
510 return result
511}
512
513/** An apply: the edited summary.md replaces the current summary; no summariser runs. A skip drops the waiting edit and says why. */
514const handleApply = async ($: EngineInterface, e: SessionCompactInput, dir: string): Promise<SessionCompactResult> => {
515 const skip = async (reason: string): Promise<SessionCompactResult> => {
516 await setPending($, false)
517 return { skip: `compact-lens: ${reason}; run ${COMPACT_COMMAND} again to compact as usual` }
518 }
519 const records = await read($, recordsAtom)
520 const latest = records.at(-1)
521 if (latest === undefined) return skip('nothing to apply, no compaction has run yet')
522 const current = compactionFiles(dir, latest.n)
523 const edited = (await $.fs.exists(current.summary)) ? (await $.fs.read(current.summary)).trim() : ''
524 if (edited === '') return skip(`${current.summary} is missing or empty, so nothing was applied`)
525 const index = findRecordedSummary(e.messages, latest)
526 if (index < 0) return skip(`the summary of #${pad2(latest.n)} is not in the conversation now, so the edit was not applied; it stays in ${current.summary}`)
527 const old = e.messages[index] ?? userMessage('')
528 if (old.text.trim() === edited) return skip(`${current.summary} is the summary in use, so there was nothing to apply`)
529
530 const n = records.length + 1
531 const files = compactionFiles(dir, n)
532 const at = await isoNow($)
533 const withSummary = replaceAt(e.messages, index, userMessage(edited))
534 const lost = computeLost([old], contextText(withSummary))
535 const record = buildRecord({ n, trigger: 'apply', at, files, before: e.messages, after: withSummary, summary: edited, lost })
536 await snapshotBefore($, e.messages, files, `before — apply #${n} at ${at}`, 'The transcript as it stood when the edited summary replaced the summary.')
537 await $.fs.write(files.summary, edited)
538 await $.fs.write(files.lost, renderLost(lost, { n, trigger: 'apply', at, beforePath: current.summary }))
539 const messages = insertAfter(withSummary, index, userMessage(buildApplyNote(record, files)))
540 await writeRecord($, record, files, messages)
541 await setPending($, false)
542 log($, `apply #${n}: the summary was replaced from ${current.summary}; snapshots in ${files.dir}`)
543 $.ui.toast(`your edited summary is in place (#${pad2(n)})`)
544 return { messages }
545}
546
547// Set while the hook answers an apply: if it throws, its .catch must skip, never hand the compaction to the summariser.
548let isApplying = false
549
550/** The session.compact hook: the engine's compactions are recorded; the person's /compact with an edit waiting is answered by the mod. */
551const handleCompact = async ($: EngineInterface, e: SessionCompactInput, next: NextCompact): Promise<SessionCompactResult> => {
552 if (e.agentId !== undefined) return next(e)
553 if (!Array.isArray(e.messages)) {
554 log($, `a compaction arrived without its messages (trigger ${String(e.trigger)}); passed through`)
555 return next(e)
556 }
557 const dir = await read($, sessionDirAtom)
558 if (dir === null) return next(e)
559 if (e.trigger === 'precompute') return handlePrecompute($, e, next, dir)
560 const isPending = await read($, pendingApplyAtom)
561 if (isPending && e.trigger === 'manual' && (e.instructions ?? '').trim() === '') {
562 isApplying = true
563 const applied = await handleApply($, e, dir)
564 isApplying = false
565 return applied
566 }
567 const result = await handleReal($, e, next, dir)
568 if (isPending && result.skip === undefined) await supersedeApply($)
569 return result
570}
571
572// ---------------------------------------------------------------- the slash command
573
574const statusText = async ($: EngineInterface): Promise<string> => {
575 await syncUnseen($)
576 const [fill, records, pins] = await Promise.all([read($, fillAtom), read($, recordsAtom), read($, pinsAtom)])
577 const [dir, isPending] = await Promise.all([read($, sessionDirAtom), read($, pendingApplyAtom)])
578 return formatStatus({ fill, records, pinCount: pins.length, dir, isPending })
579}
580
581const listText = async ($: EngineInterface): Promise<string> => {
582 await syncUnseen($)
583 const [records, pins, dir] = await Promise.all([read($, recordsAtom), read($, pinsAtom), read($, sessionDirAtom)])
584 return formatList(records, pins, dir)
585}
586
587/** `show [what] [n]`: a snapshot file printed as the reply, so neither the person nor the model has to open it. */
588const showText = async ($: EngineInterface, tail: string): Promise<string> => {
589 const request = parseShow(tail)
590 if (typeof request === 'string') return request
591 await syncUnseen($)
592 const [records, dir] = await Promise.all([read($, recordsAtom), read($, sessionDirAtom)])
593 const n = request.n ?? records.at(-1)?.n
594 if (dir === null) return 'the snapshot folder is not set'
595 if (request.kind !== 'pinned' && n === undefined) return 'no compaction has run yet, so there is nothing to show'
596 const path = request.kind === 'pinned' ? pinnedFile(dir) : compactionFiles(dir, n ?? 0)[request.kind]
597 return (await $.fs.exists(path)) ? formatShown(path, await $.fs.read(path)) : `${path} does not exist`
598}
599
600/** `apply`: the edited summary.md readied, and /compact offered to apply it. */
601const applyText = async ($: EngineInterface): Promise<string> => {
602 const applicable = await checkApplicable($)
603 if (typeof applicable === 'string') return `nothing to apply: ${applicable}.`
604 await requestApply($)
605 scheduleOffer($, EDIT_FILL_DELAY_MS)
606 return `${applicable.path} is ready. Press Enter on ${COMPACT_COMMAND}, which goes into the prompt box now: the mod answers it with the edited summary, and no summariser runs.`
607}
608
609const cancelText = async ($: EngineInterface): Promise<string> => {
610 if (!(await read($, pendingApplyAtom))) return 'no apply is waiting.'
611 await setPending($, false)
612 return `the waiting apply is dropped; the edit stays in its summary.md, and /${COMMAND} apply readies it again.`
613}
614
615/** `/compact-lens [verb] [rest]`: the person's side of the mod. The engine prints each reply under the plugin's name. */
616const runCommand = async ($: EngineInterface, args: string): Promise<{ text: string }> => {
617 const trimmed = args.trim()
618 const verb = trimmed.split(/\s+/)[0] ?? ''
619 const tail = trimmed.slice(verb.length).trim()
620
621 switch (verb) {
622 case '':
623 case 'open':
624 case 'status': {
625 await $.ui.open({ id: PANE_ID, title: 'Compact Lens', focus: true })
626 return { text: await statusText($) }
627 }
628 case 'edit': {
629 const current = await currentSummary($)
630 if (typeof current === 'string') return { text: `${current}.` }
631 scheduleEdit($)
632 return { text: editStarted(current.n) }
633 }
634 case 'show':
635 return { text: await showText($, tail) }
636 case 'list':
637 return { text: await listText($) }
638 case 'keep':
639 case 'pin': {
640 if (tail === '') return { text: `keep needs a note: /${COMMAND} keep <text>` }
641 const { id, count } = await pin($, tail)
642 return { text: `pinned note ${id} (${count} pinned). It is kept verbatim through every compaction of this session.` }
643 }
644 case 'unpin': {
645 const id = Number(tail)
646 if (!Number.isInteger(id)) return { text: `unpin needs a number: /${COMMAND} unpin <n>` }
647 return { text: (await unpin($, id)) ? `note ${id} removed.` : `no pinned note ${id}.` }
648 }
649 case 'apply':
650 return { text: await applyText($) }
651 case 'cancel':
652 return { text: await cancelText($) }
653 default:
654 return { text: HELP }
655 }
656}
657
658// ---------------------------------------------------------------- the pane
659
660const applyFromPane = async ($: EngineInterface): Promise<void> => {
661 const applicable = await checkApplicable($)
662 if (typeof applicable === 'string') {
663 $.ui.toast(`nothing to apply: ${applicable}`)
664 return
665 }
666 await requestApply($)
667 await offerCompact($)
668}
669
670/** The pane: the fill, the compactions, the pinned notes and the two actions. */
671const renderPane = async ($: EngineInterface, e: RenderInput<'Pane'>) => {
672 const { Box, Text, Button } = $.ui.resolve(e)
673 const [records, pins, fill] = await Promise.all([read($, recordsAtom), read($, pinsAtom), read($, fillAtom)])
674 const [dir, pending] = await Promise.all([read($, sessionDirAtom), read($, pendingApplyAtom)])
675 const shown = records.slice(-SHOWN_RECORDS)
676
677 return (
678 <Box flexDirection="column" gap={1}>
679 <Box flexDirection="column">
680 <Text bold>Compact Lens</Text>
681 <Text dimColor>
682 context {fill === null ? '–' : `${fill}%`} · {plural(records.length, 'compaction')} · {pins.length} pinned
683 {pending ? ` · ${COMPACT_COMMAND} applies the edit` : ''}
684 </Text>
685 <Text dimColor wrap="truncate-start">
686 {dir ?? '(unset)'}
687 </Text>
688 </Box>
689 <Box flexDirection="column">
690 <Text bold>Compactions</Text>
691 {shown.length === 0 && <Text dimColor>none yet</Text>}
692 {shown.map(r => (
693 <Text wrap="truncate-end">{paneLine(r)}</Text>
694 ))}
695 </Box>
696 <Box flexDirection="column">
697 <Text bold>Pinned</Text>
698 {pins.length === 0 && <Text dimColor>none; the model pins with {KEEP_TOOL}, you with /{COMMAND} keep</Text>}
699 {pins.map(p => (
700 <Text wrap="wrap">
701 {p.id}. {p.text}
702 </Text>
703 ))}
704 </Box>
705 <Box gap={1}>
706 <Button key="edit" label="Edit summary" hotkey="e" onPress={() => void startEdit($, EDIT_STARTED_TOAST)} />
707 <Button key="apply" label="Apply summary.md" hotkey="a" onPress={() => void applyFromPane($).catch(error => log($, `apply: ${String(error)}`))} />
708 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void readFill($)} />
709 <Button key="close" label="Close" role="dismiss" onPress={() => void $.ui.close({ id: PANE_ID })} />
710 </Box>
711 </Box>
712 )
713}
714
715// ---------------------------------------------------------------- registration
716
717export const register: Register = (on, options) => {
718 const config = readConfig(options)
719 isStatusLineOn = config.statusLine
720
721 on('session.start', async ($, e, next) => {
722 const home = (await $.env.get('HOME')) ?? ''
723 const dir = sessionDirOf(lensRoot(home, config.dir), await $.session.id())
724 await update($, sessionDirAtom, () => dir)
725 await restore($)
726 await syncUnseen($).catch(error => log($, `a compaction the mod did not see could not be recorded (${String(error)})`))
727 await $.tool.register({
728 name: 'keep',
729 description: KEEP_DESCRIPTION,
730 inputSchema: { type: 'object', properties: { note: { type: 'string', description: 'The fact to keep, verbatim.' } }, required: ['note'] },
731 })
732 await $.tool.register({ name: 'apply', description: APPLY_DESCRIPTION, inputSchema: { type: 'object', properties: {} } })
733 await $.command.register({
734 name: COMMAND,
735 description: 'Compact Lens: edit the summary, pin notes, open the pane.',
736 argumentHint: ARGUMENT_HINT,
737 })
738 await refreshStatus($)
739 if (config.openPane && e.isInteractive) void $.ui.open({ id: PANE_ID, title: 'Compact Lens' })
740 return next(e)
741 })
742
743 on('session.compact', ($, e, next) => handleCompact($, e, next)).catch(($, e, next) => {
744 if (!isApplying || e.trigger !== 'manual' || e.agentId !== undefined) {
745 log($, `the compaction hook failed and stood aside: ${failureText(next.error)}`)
746 return next(e)
747 }
748 isApplying = false
749 return { skip: `compact-lens: the edit could not be applied (${failureText(next.error)}), so nothing changed; it still waits: ${COMPACT_COMMAND} tries again, /${COMMAND} cancel drops it` }
750 })
751
752 on('turn.step', async function* ($, e, next) {
753 const result = yield* next(e)
754 if (e.agentId === undefined) await afterStep($, config)
755 return result
756 })
757
758 on('turn.complete', async ($, e, next) => {
759 const result = await next(e)
760 if (e.agentId === undefined && isOfferDue) {
761 isOfferDue = false
762 if (await read($, pendingApplyAtom)) await offerCompact($)
763 }
764 return result
765 })
766
767 on('tool.call', { tool: 'mcp__compact-lens__keep' }, async ($, e) => {
768 const note = typeof e.note === 'string' ? e.note.trim() : ''
769 if (note === '') return { deny: 'compact-lens: the note is empty' }
770 const { id, count } = await pin($, note)
771 return { result: `Pinned note ${id} (${count} pinned). It is kept verbatim through every compaction of this session.` }
772 }).catch(($, e, next) => ({ deny: `compact-lens: keep failed: ${failureText(next.error)}` }))
773
774 on('tool.call', { tool: 'mcp__compact-lens__apply' }, async $ => {
775 const applicable = await checkApplicable($)
776 if (typeof applicable === 'string') return { deny: `compact-lens: ${applicable}` }
777 await requestApply($)
778 isOfferDue = true
779 return {
780 result: `${applicable.path} is ready. The person applies it with ${COMPACT_COMMAND}: when this turn ends, ${COMPACT_COMMAND} goes into their prompt box, and the mod answers it with the edited summary (no summariser runs; the messages after the summary stay). Tell them to press Enter on it.`,
781 }
782 }).catch(($, e, next) => ({ deny: `compact-lens: apply failed: ${failureText(next.error)}` }))
783
784 on('command.run', { command: 'compact-lens' }, ($, e) => runCommand($, e.args)).catch(($, e, next) => ({
785 text: `the command failed: ${failureText(next.error)}`,
786 }))
787
788 on('prompt.submit', ($, e, next) => handleSubmit($, e, next)).catch(($, e, next) => handleSubmitFailure($, e, next.error, next))
789
790 on('prompt.compose', async ($, e, next) => {
791 const composed = await next(e)
792 if (!e.tools.includes(KEEP_TOOL)) return composed
793 const [dir, records, pins] = await Promise.all([read($, sessionDirAtom), read($, recordsAtom), read($, pinsAtom)])
794 const text = buildPromptSection({ sessionDir: dir ?? '(unset)', records, pinnedCount: pins.length })
795 return { sections: [...composed.sections, { id: 'compact-lens:lens', text, scope: 'session' }] }
796 })
797
798 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => renderPane($, e))
799}
800hooks/editor.ts 104 lines1// Editing the summary in the prompt box (pure): the header line, finding an edit in a submitted
2// prompt, judging it, and the replies.
3import type { PromptSubmitInput, PromptSubmitResult } from 'claude-code'
4
5import type { CompactionFiles } from './paths'
6import { COMMAND, COMPACT_COMMAND, EDIT_DISTINCT_LINE_CHARS, EDIT_HEAD_PROBE_CHARS, EDIT_MIN_DISTINCT_LINES, pad2 } from './paths'
7
8// The header's prefix, anything up to its closing bracket, and the end of its line: a trailing
9// space, or an editor that wraps the line, still matches; a one-line prompt that only starts with
10// the header does not.
11const HEADER = /^\s*\[compact-lens edit #(\d+)[^\]]{0,300}\][ \t]*(?:\r?\n|$)/
12
13/** The first line of the edit in the prompt box: which summary it is, and what Enter does. Short, so editors do not wrap it. */
14export const editHeader = (n: number): string => `[compact-lens edit #${pad2(n)}: Enter saves, ${COMPACT_COMMAND} applies, an empty box cancels]`
15
16export const buildEditDraft = (n: number, summary: string): string => `${editHeader(n)}\n${summary.trim()}`
17
18export type EditSubmission = { n: number; body: string }
19
20/** The summary an edit opens on: its compaction's number, folder and text. */
21export type CurrentSummary = { n: number; files: CompactionFiles; text: string }
22
23/** A caught edit's outcome: the line the person sees, and whether an apply now waits. */
24export type SavedEdit = { reply: string; isApply: boolean }
25
26export type NextSubmit = (e: PromptSubmitInput) => Promise<PromptSubmitResult>
27
28/** A prompt the person sent: typed at the terminal, or through Remote Control; never another plugin's or the SDK's. */
29export const isPersonsOwn = (e: PromptSubmitInput): boolean => e.origin.kind === 'composer' || e.origin.kind === 'bridge'
30
31/** The edit in a submitted prompt, found by its header line; undefined without one. */
32export const parseEditHeader = (text: string): EditSubmission | undefined => {
33 const match = HEADER.exec(text)
34 return match === null ? undefined : { n: Number(match[1]), body: text.slice(match[0].length).trim() }
35}
36
37/** The summary's lines long enough to tell it from another session's: the headings and boilerplate every summary shares are shorter, or few. */
38const distinctLines = (summary: string): string[] => [
39 ...new Set(summary.split('\n').map(line => line.trim()).filter(line => line.length >= EDIT_DISTINCT_LINE_CHARS)),
40]
41
42/**
43 * Whether a prompt without the header is the open edit of `summary`: it keeps at least half of the
44 * summary's distinct lines word for word, or, for a summary too short to have them, starts with its opening.
45 */
46export const looksLikeEdit = (text: string, summary: string): boolean => {
47 const lines = distinctLines(summary)
48 if (lines.length < EDIT_MIN_DISTINCT_LINES) {
49 const probe = summary.trim().slice(0, EDIT_HEAD_PROBE_CHARS)
50 return probe !== '' && text.trimStart().startsWith(probe)
51 }
52 const sent = new Set(text.split('\n').map(line => line.trim()))
53 return 2 * lines.filter(line => sent.has(line)).length >= lines.length
54}
55
56/**
57 * What becomes of a submitted edit: nothing when empty or unchanged, kept aside when there is no
58 * summary or a newer one, else saved for the apply. `current` is the summary.md the edit started from.
59 */
60export type EditVerdict = 'empty' | 'none' | 'stale' | 'unchanged' | 'apply'
61
62export const judgeEdit = (edit: EditSubmission, latestN: number | undefined, current?: string): EditVerdict => {
63 if (edit.body === '') return 'empty'
64 if (latestN === undefined) return 'none'
65 if (edit.n !== latestN) return 'stale'
66 return current !== undefined && edit.body === current.trim() ? 'unchanged' : 'apply'
67}
68
69/** An ISO time as a file-name stamp, to the millisecond: 2026-10-07T13:53:42.706Z → 20261007-135342-706. */
70export const fileStamp = (iso: string): string => iso.replace(/[-:]/g, '').replace('T', '-').replace('.', '-').slice(0, 19)
71
72export type EditReplyArgs = {
73 verdict: EditVerdict
74 n: number
75 latestN: number | undefined
76 summaryPath: string
77 keptPath: string
78 isTurnRunning: boolean
79 /** True when an unchanged edit dropped an apply that waited with other text. */
80 isApplyDropped?: boolean
81}
82
83const NOT_SENT = 'The prompt was not sent to the model.'
84
85/** The line the person sees in place of the prompt, which never reaches the model. */
86export const editReply = ({ verdict, n, latestN, summaryPath, keptPath, isTurnRunning, isApplyDropped = false }: EditReplyArgs): string => {
87 switch (verdict) {
88 case 'empty':
89 return `compact-lens: the summary in the box was empty, so nothing was applied. ${NOT_SENT}`
90 case 'none':
91 return `compact-lens: no compaction has run in this session, so there is no summary to replace. Your text is kept at ${keptPath}. ${NOT_SENT}`
92 case 'stale':
93 return `compact-lens: that edit was of compaction #${pad2(n)}, but #${pad2(latestN ?? 0)} is the latest now, so it was not applied. Your text is kept at ${keptPath}; /${COMMAND} edit loads the current summary. ${NOT_SENT}`
94 case 'unchanged':
95 return `compact-lens: the summary in the box is the one in use, so there is nothing to apply${isApplyDropped ? `; the apply that waited with other text is dropped, and ${summaryPath} holds the summary in use again` : ''}. ${NOT_SENT}`
96 case 'apply':
97 return `compact-lens: your edit is saved to ${summaryPath}. To apply it, press Enter on ${COMPACT_COMMAND} in the prompt box${isTurnRunning ? ' once the running turn has ended' : ''}: the mod answers that ${COMPACT_COMMAND} with your summary, so no summariser runs and the messages after the summary stay. ${NOT_SENT}`
98 }
99}
100
101/** The command's reply; the engine prints it under the plugin's name. */
102export const editStarted = (n: number): string =>
103 `the summary of compaction #${pad2(n)} goes into the prompt box now. Edit it there, or press ctrl+g to edit it in your editor. Keep the first line. Enter saves it, then ${COMPACT_COMMAND} applies it with no summariser; an empty box cancels.`
104hooks/lost.ts 256 lines1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3import { IDENTIFIER_CAP, LIST_CAP, PROMPT_CAP, PROMPT_LIST_CAP } from './paths'
4import { cut, estimateTokens, fmtTokens, isPrompt, stripReminders, transcriptChars } from './snapshot'
5
6export type LostIdentifier = { id: string; count: number }
7
8export type LostReport = {
9 messages: number
10 chars: number
11 tokens: number
12 toolUses: number
13 prompts: string[]
14 /** The slash commands the person ran, as `/name args`. */
15 slashCommands: string[]
16 filesEdited: string[]
17 filesRead: string[]
18 commands: string[]
19 agents: string[]
20 toolCounts: Array<{ tool: string; count: number }>
21 identifiersFound: number
22 identifiersKept: number
23 /** How many identifiers the context no longer mentions, uncapped. */
24 identifiersLostTotal: number
25 /** The first IDENTIFIER_CAP of them, most seen first. */
26 identifiersLost: LostIdentifier[]
27}
28
29const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
30const COMMAND_CAP = 160
31/** How many identifiers, by count, go through the substring merge. */
32const MERGE_CAP = 600
33const MIN_IDENTIFIER_CHARS = 4
34/** Each field of a tool input is mined up to this many characters (a pasted file, a data URI). */
35const MINED_FIELD_CAP = 20_000
36
37/**
38 * What counts as an identifier: a path, a file name, a git hash, a URL, a code span, a constant, a
39 * reference. The relative-path and file-name patterns start at a lookbehind, not `\b`: with `\b` a
40 * long dash- or dot-joined run (a data URI, a kebab chain) restarts the scan at every boundary and
41 * goes quadratic, past the hook's budget on 80k characters.
42 */
43const PATTERNS: readonly RegExp[] = [
44 /(?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]+)+/g,
45 /(?<![\w./-])[\w.-]+(?:\/[\w.-]+)+\.\w{1,8}\b/g,
46 /(?<![\w-])[\w-]+\.(?:tsx?|jsx?|mjs|cjs|py|md|json|ya?ml|toml|sh|go|rs|kt|java|css|html|sql|csv|parquet|pine|txt|lock)\b/g,
47 /\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/g,
48 /https?:\/\/[^\s)>\]"']+/g,
49 /`([^`\n]{3,80})`/g,
50 /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g,
51 /(?<![\w&#])#\d{2,6}\b/g,
52]
53
54/** What a sentence leaves after a path or a URL; a code span keeps its own closing characters. */
55const TRAILING_PUNCTUATION = /[.,:;'")\]]+$/
56
57const isWorth = (id: string): boolean => id.length >= MIN_IDENTIFIER_CHARS && !/^[\d.]+$/.test(id)
58
59const fieldText = (value: unknown): string => {
60 if (typeof value === 'string') return value.slice(0, MINED_FIELD_CAP)
61 try {
62 return JSON.stringify(value).slice(0, MINED_FIELD_CAP)
63 } catch {
64 return ''
65 }
66}
67
68/** A tool input's fields, each cut, so a path beside a pasted file is still seen. */
69const stringOfInput = (use: ToolUseSummary): string => Object.values(use.input).map(fieldText).join('\n')
70
71/** What was said in a message: its text (reminders stripped) and its tool inputs; never its tool results. */
72const spokenText = (m: SessionMessage): string =>
73 [m.role === 'user' ? stripReminders(m.text) : m.text, ...m.toolUses.map(stringOfInput)].join('\n')
74
75export const extractIdentifiers = (text: string): Map<string, number> => {
76 const counts = new Map<string, number>()
77 for (const pattern of PATTERNS) {
78 for (const match of text.matchAll(pattern)) {
79 const raw = match[1] ?? match[0].replace(TRAILING_PUNCTUATION, '')
80 if (!isWorth(raw)) continue
81 counts.set(raw, (counts.get(raw) ?? 0) + 1)
82 }
83 }
84 return counts
85}
86
87/** Folds an identifier into a longer one that contains it, so a path is listed once, not per segment. */
88export const mergeSubstrings = (counts: Map<string, number>): LostIdentifier[] => {
89 const byCount = [...counts.entries()]
90 .map(([id, count]) => ({ id, count }))
91 .sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
92 .slice(0, MERGE_CAP)
93 .sort((a, b) => b.id.length - a.id.length)
94 return byCount.reduce<LostIdentifier[]>((kept, one) => {
95 const at = kept.findIndex(k => k.id.includes(one.id))
96 return at < 0 ? [...kept, one] : kept.map((k, i) => (i === at ? { ...k, count: k.count + one.count } : k))
97 }, [])
98}
99
100const pathOf = (use: ToolUseSummary): string | undefined => {
101 const { file_path, notebook_path } = use.input
102 if (typeof file_path === 'string') return file_path
103 if (typeof notebook_path === 'string') return notebook_path
104 return undefined
105}
106
107const firstLine = (text: string): string => text.split('\n')[0] ?? ''
108
109const unique = (items: readonly (string | undefined)[], cap: number): string[] => {
110 const seen = new Set<string>()
111 const out: string[] = []
112 for (const item of items) {
113 if (item === undefined || item === '' || seen.has(item)) continue
114 seen.add(item)
115 out.push(item)
116 if (out.length >= cap) break
117 }
118 return out
119}
120
121/** A slash command's echo, in the person's name: `<command-name>/x</command-name>` and its arguments. */
122const COMMAND_ROW = /^<command-(?:name|message)>/
123/** Rows the engine writes in the person's name that the person did not type: a command's output, a skill's body. */
124const ENGINE_ROW =
125 /^(?:<local-command-(?:stdout|stderr|caveat)>|Caveat: The messages below were generated by the user while running local commands|Base directory for this skill:)/
126
127/** A command row as the person typed it: `/name args`. */
128const commandOf = (text: string): string => {
129 const name = /<command-name>\/?([^<]*)<\/command-name>/.exec(text)?.[1]?.trim() ?? '?'
130 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1]?.trim() ?? ''
131 return args === '' ? `/${name}` : `/${name} ${args}`
132}
133
134/** Plain readers: a Bash command led by one of these reads the files it names. */
135const READ_COMMANDS = new Set(['cat', 'head', 'tail', 'sed', 'less', 'more', 'bat', 'nl', 'wc', 'grep', 'rg', 'awk', 'diff', 'jq'])
136const MIN_PATH_CHARS = 4
137
138/** An argument that names a file: a path or a name with an extension, never a flag, a pattern, a variable, a folder or /dev. */
139const looksLikeFile = (word: string): boolean =>
140 word.length >= MIN_PATH_CHARS &&
141 !word.startsWith('-') &&
142 !word.startsWith('/dev/') &&
143 !word.endsWith('/') &&
144 !/["'`$*?<>(){}=\\]/.test(word) &&
145 (word.includes('/') || /\.[A-Za-z][\w]{0,7}$/.test(word))
146
147/** A here-document: from `<<` to its closing delimiter line, the rest of the opening line (a redirection) included. */
148const HEREDOC = /<<-?[ \t]*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g
149
150/** A segment's arguments up to its first redirection: what follows `>` is written, not read. */
151const beforeRedirect = (words: readonly string[]): readonly string[] => {
152 const at = words.findIndex(word => /[<>]/.test(word))
153 return at < 0 ? words : words.slice(0, at)
154}
155
156/** The files a Bash command reads with a plain reader (cat, head, sed …), as written; never a here-document's body, a redirection's target or `sed -i`'s file. Heuristic, like the rest. */
157export const bashReads = (command: string): string[] =>
158 command
159 .replace(HEREDOC, '')
160 .split(/&&|\|\||[;|\n]/)
161 .flatMap(segment => {
162 const [name = '', ...rest] = segment.trim().split(/\s+/)
163 if (!READ_COMMANDS.has(name) || (name === 'sed' && rest.some(word => word.startsWith('-i')))) return []
164 return beforeRedirect(rest).filter(looksLikeFile)
165 })
166
167const describeAgent = (use: ToolUseSummary): string => {
168 const description = typeof use.input.description === 'string' ? use.input.description : '(no description)'
169 return use.agentId === undefined ? description : `${description} (agent ${use.agentId})`
170}
171
172/** What the compacted span held that the context after it does not mention. Heuristic: candidates, not a judgement. */
173export const computeLost = (before: readonly SessionMessage[], afterText: string): LostReport => {
174 const uses = before.flatMap(m => m.toolUses)
175 const counts = extractIdentifiers(before.map(spokenText).join('\n'))
176 const merged = mergeSubstrings(counts)
177 const lostAll = merged.filter(one => !afterText.includes(one.id)).sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
178 const lost = lostAll.slice(0, IDENTIFIER_CAP)
179 const toolCounts = new Map<string, number>()
180 for (const use of uses) toolCounts.set(use.tool, (toolCounts.get(use.tool) ?? 0) + 1)
181 const chars = transcriptChars(before)
182 const typed = before.filter(isPrompt).map(m => stripReminders(m.text))
183
184 return {
185 messages: before.length,
186 chars,
187 tokens: estimateTokens(chars),
188 toolUses: uses.length,
189 prompts: typed
190 .filter(text => !COMMAND_ROW.test(text) && !ENGINE_ROW.test(text))
191 .map(text => cut(text, PROMPT_CAP))
192 .slice(0, PROMPT_LIST_CAP),
193 slashCommands: unique(
194 typed.filter(text => COMMAND_ROW.test(text)).map(text => cut(commandOf(text), PROMPT_CAP)),
195 LIST_CAP,
196 ),
197 filesEdited: unique(uses.filter(u => EDIT_TOOLS.has(u.tool)).map(pathOf), LIST_CAP),
198 filesRead: unique(
199 uses.flatMap(u => (u.tool === 'Read' ? [pathOf(u)] : u.tool === 'Bash' && typeof u.input.command === 'string' ? bashReads(u.input.command) : [])),
200 LIST_CAP,
201 ),
202 commands: unique(
203 uses.filter(u => u.tool === 'Bash').map(u => (typeof u.input.command === 'string' ? cut(firstLine(u.input.command), COMMAND_CAP) : undefined)),
204 LIST_CAP,
205 ),
206 agents: unique(uses.filter(u => u.tool === 'Agent').map(describeAgent), LIST_CAP),
207 toolCounts: [...toolCounts.entries()].map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count),
208 identifiersFound: merged.length,
209 identifiersKept: merged.length - lostAll.length,
210 identifiersLostTotal: lostAll.length,
211 identifiersLost: lost,
212 }
213}
214
215const list = (title: string, items: readonly string[]): string[] =>
216 items.length === 0 ? [] : [`## ${title} (${items.length})`, ...items.map(item => `- ${item}`), '']
217
218export type LostHeader = { n: number; trigger: string; at: string; beforePath: string; tokensBefore?: number }
219
220/** The size of the span: the engine's token count when it gave one, and the characters of its text and tool calls. */
221const sizeLine = (report: LostReport, tokensBefore: number | undefined): string =>
222 tokensBefore === undefined
223 ? `- messages compacted: ${report.messages}, about ${fmtTokens(report.tokens)} tokens (${report.chars} characters, at 4 a token)`
224 : `- messages compacted: ${report.messages}, ${fmtTokens(tokensBefore)} tokens by the engine's count; their text and tool calls are ${report.chars} characters (about ${fmtTokens(report.tokens)} tokens at 4 a token)`
225
226export const renderLost = (report: LostReport, header: LostHeader): string =>
227 [
228 `# lost — compaction #${header.n} (${header.trigger}) at ${header.at}`,
229 '',
230 "What the compacted span held (the person's prompts, the files, the commands, the agents: all of",
231 'them, whether or not the summary covers them), and the identifiers of the span that the context',
232 `after the compaction no longer mentions. Heuristic: candidates, not a judgement. The record is ${header.beforePath}.`,
233 '',
234 '## Numbers',
235 sizeLine(report, header.tokensBefore),
236 `- tool calls: ${report.toolUses}; the person's prompts: ${report.prompts.length}; slash commands: ${report.slashCommands.length}`,
237 `- identifiers found in the span: ${report.identifiersFound}; still mentioned: ${report.identifiersKept}; no longer mentioned: ${report.identifiersLostTotal}${report.identifiersLostTotal > report.identifiersLost.length ? ` (the first ${report.identifiersLost.length} listed)` : ''}`,
238 '',
239 ...(report.prompts.length === 0
240 ? []
241 : [`## The person's prompts, verbatim (cut at ${PROMPT_CAP} characters)`, ...report.prompts.map((p, i) => `${i + 1}. ${p.replace(/\n/g, '\n ')}`), '']),
242 ...list(`Slash commands the person ran (cut at ${PROMPT_CAP} characters)`, report.slashCommands),
243 ...list('Files edited or written', report.filesEdited),
244 ...list('Files read (by Read, and by cat, head, sed and the like in Bash, as written)', report.filesRead),
245 ...list('Commands run', report.commands),
246 ...list('Agents spawned', report.agents),
247 ...list(
248 'Tool calls by tool',
249 report.toolCounts.map(one => `${one.tool}: ${one.count}`),
250 ),
251 ...list(
252 'Identifiers the context no longer mentions (times seen in the span)',
253 report.identifiersLost.map(one => `\`${one.id}\` ×${one.count}`),
254 ),
255 ].join('\n')
256hooks/notes.ts 91 lines1import type { CompactLensPin, CompactLensRecord } from '../types'
2import type { CompactionFiles } from './paths'
3import { APPLY_TOOL, COMMAND, COMPACT_COMMAND, KEEP_TOOL } from './paths'
4import { estimateTokens, fmtTokens } from './snapshot'
5
6/** The summariser's instruction for the pinned notes; undefined with none. */
7export const buildPinnedDirective = (pins: readonly CompactLensPin[]): string | undefined =>
8 pins.length === 0
9 ? undefined
10 : ['Keep these pinned facts in the summary, verbatim and complete:', ...pins.map(p => `- ${p.text}`)].join('\n')
11
12export const mergeInstructions = (own: string | undefined, directive: string | undefined): string | undefined => {
13 const joined = [own, directive].filter((part): part is string => part !== undefined && part.trim() !== '').join('\n\n')
14 return joined === '' ? undefined : joined
15}
16
17const tokensIn = (record: CompactLensRecord): string =>
18 record.tokensBefore === null ? `~${fmtTokens(estimateTokens(record.charsBefore))} tokens, estimated` : `${fmtTokens(record.tokensBefore)} tokens`
19
20const tokensOut = (record: CompactLensRecord): string =>
21 record.tokensAfter === null ? `~${fmtTokens(estimateTokens(record.charsAfter))} tokens, estimated` : `${fmtTokens(record.tokensAfter)} tokens`
22
23export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
24
25const pinsBlock = (pins: readonly CompactLensPin[]): string[] =>
26 pins.length === 0 ? [] : ['Pinned notes (kept verbatim through every compaction):', ...pins.map(p => `${p.id}. ${p.text}`)]
27
28export type NoteArgs = { record: CompactLensRecord; files: CompactionFiles; pins: readonly CompactLensPin[]; keptCount: number }
29
30/** The user-role message inserted after a summary: the counts, the files and the pinned notes. */
31export const buildNote = ({ record, files, pins, keptCount }: NoteArgs): string =>
32 [
33 `<compact-lens n="${record.n}" trigger="${record.trigger}">`,
34 `Compaction #${record.n} (${record.trigger}) ran at ${record.at}. ${record.messagesBefore} messages (${tokensIn(record)}) went in; the summary above and ${keptCount} kept message${keptCount === 1 ? '' : 's'} (${tokensOut(record)}) came out.`,
35 'Files, written by compact-lens (read them with Read or search them with Grep when the summary lacks something):',
36 `- before.md ${files.before} the whole transcript that was compacted, every tool call with its input and result`,
37 `- lost.md ${files.lost} the span's ${plural(record.promptsInSpan, 'prompt')} verbatim and its ${plural(record.filesInSpan, 'file')}, and the ${plural(record.identifiersLost, 'identifier')} the context no longer mentions`,
38 `- summary.md ${files.summary} the summary's text; edit it with Edit, then call ${APPLY_TOOL} and ask the person to press Enter on ${COMPACT_COMMAND}, which the mod answers with the edited text in place of the summary above (after an apply, the newest folder's summary.md is the one to edit); the person can edit it in the prompt box with /${COMMAND} edit`,
39 ...pinsBlock(pins),
40 `Pin a fact for the next compaction with ${KEEP_TOOL}.`,
41 '</compact-lens>',
42 ].join('\n')
43
44/** The user-role message inserted after an apply: what changed. */
45export const buildApplyNote = (record: CompactLensRecord, files: CompactionFiles): string =>
46 [
47 `<compact-lens n="${record.n}" trigger="apply">`,
48 `The summary above was replaced at ${record.at} with an edited summary.md. The next apply reads ${files.summary}; the earlier summaries and transcripts stay under their own folders.`,
49 '</compact-lens>',
50 ].join('\n')
51
52/** The fill and the auto-compaction line, as percents of one window. */
53export type WarningFigures = { percent: number; thresholdPercent: number | undefined }
54
55/** The context fill as a percent of the window: the engine's own, or its tokens over the window; undefined before the first response. */
56export const fillPercent = (context: { percent?: number; tokens?: number; window: number }): number | undefined =>
57 context.percent ?? (context.tokens === undefined || context.window <= 0 ? undefined : Math.round((100 * context.tokens) / context.window))
58
59export type Breakdown = { percentage: number; autoCompactThreshold?: number; rawMaxTokens: number }
60
61/** The fill and the auto-compaction line on the breakdown's compaction window, which may be smaller than the model's; the plain fill alone without one. */
62export const figuresOf = (breakdown: Breakdown | undefined, percent: number): WarningFigures =>
63 breakdown === undefined || breakdown.autoCompactThreshold === undefined || breakdown.rawMaxTokens <= 0
64 ? { percent, thresholdPercent: undefined }
65 : { percent: Math.round(breakdown.percentage), thresholdPercent: Math.round((100 * breakdown.autoCompactThreshold) / breakdown.rawMaxTokens) }
66
67export type WarningArgs = { percent: number; thresholdPercent: number | undefined; sessionDir: string }
68
69/** The one hidden note the model gets when the context fill crosses the warning line. */
70export const buildWarning = ({ percent, thresholdPercent, sessionDir }: WarningArgs): string =>
71 [
72 '<compact-lens>',
73 `Context is at ${percent}% of the window${thresholdPercent === undefined ? '' : `; auto-compaction runs near ${thresholdPercent}%`}. When it compacts, the whole transcript is saved under ${sessionDir}/ and the summary comes with a note of what it dropped (before.md, lost.md, summary.md). If a detail must survive verbatim (a decision, a path, a number, a name), pin it now with ${KEEP_TOOL}.`,
74 '</compact-lens>',
75 ].join('\n')
76
77export type SectionArgs = { sessionDir: string; records: readonly CompactLensRecord[]; pinnedCount: number }
78
79/** The session-scoped system-prompt section: where the snapshots are and what the tools do. */
80export const buildPromptSection = ({ sessionDir, records, pinnedCount }: SectionArgs): string => {
81 const latest = records.at(-1)
82 return [
83 '# Compact Lens',
84 `This session records every compaction under ${sessionDir}/<nn>/: before.md (the whole transcript that was compacted), lost.md (what the summary does not mention), summary.md (the summary's text, editable), after.md (what the context held right after). Compactions so far: ${records.length}${latest === undefined ? '' : `; latest: ${latest.dir} (${latest.trigger}, ${latest.at})`}. Pinned notes: ${pinnedCount}.`,
85 `Tools: ${KEEP_TOOL} pins a fact so it survives every compaction verbatim; ${APPLY_TOOL} readies the edited summary.md, and the person's next ${COMPACT_COMMAND} replaces the current summary with it (the mod answers that ${COMPACT_COMMAND}; no summariser runs). The person runs /${COMMAND} edit to edit the summary in the prompt box, /${COMMAND} show lost to print what a compaction dropped, /${COMMAND} for the pane, /${COMMAND} keep <text>.`,
86 ].join('\n')
87}
88
89export const renderPinned = (pins: readonly CompactLensPin[]): string =>
90 ['# pinned notes', '', ...(pins.length === 0 ? ['(none)'] : pins.map(p => `${p.id}. ${p.text} (${p.at})`)), ''].join('\n')
91hooks/paths.ts 80 lines1export const PLUGIN = 'compact-lens'
2export const PANE_ID = 'compact-lens'
3export const COMMAND = 'compact-lens'
4export const KEEP_TOOL = 'mcp__compact-lens__keep'
5export const APPLY_TOOL = 'mcp__compact-lens__apply'
6export const DEFAULT_FOLDER = '.claude/compact-lens'
7
8/** Each tool result in before.md and after.md is cut to this many characters. */
9export const TOOL_RESULT_CAP = 1500
10/** Each tool input in before.md and after.md is cut to this many characters. */
11export const TOOL_INPUT_CAP = 2000
12/** A message's own text in before.md and after.md is cut to this many characters. */
13export const TEXT_CAP = 20000
14/** Each of the person's prompts in lost.md is cut to this many characters. */
15export const PROMPT_CAP = 600
16/** How many prompts lost.md lists. */
17export const PROMPT_LIST_CAP = 100
18/** How many identifiers lost.md lists. */
19export const IDENTIFIER_CAP = 200
20/** How many files, commands or agents each list in lost.md holds. */
21export const LIST_CAP = 150
22/** How many characters of the summary a record keeps, to find the summary again. */
23export const SUMMARY_HEAD_CHARS = 400
24/** The person's command an apply rides on: with an edit waiting, the mod answers it with the edit and no summariser runs. */
25export const COMPACT_COMMAND = '/compact'
26/** What goes in the prompt box: the trailing space closes the slash-command typeahead, so Enter runs /compact and not /compact-lens. */
27export const COMPACT_FILL = `${COMPACT_COMMAND} `
28/** How the engine's summary opens: a summary the mod did not record is found by it. */
29export const ENGINE_SUMMARY_OPENING = 'This session is being continued from a previous conversation'
30/** How many characters of a file `/compact-lens show` prints; the rest stays in the file. */
31export const SHOW_CAP = 20000
32/** How long after the command, or the pane's button, the summary (or /compact) goes into the prompt box: the engine empties the box on Enter first. */
33export const EDIT_FILL_DELAY_MS = 250
34/** How long after a caught edit the mod looks at the prompt box, to put /compact there or empty it if the engine put the edit back. */
35export const EDIT_CLEAR_DELAY_MS = 300
36/** How many characters of a short summary's opening identify an edit whose header line was deleted. */
37export const EDIT_HEAD_PROBE_CHARS = 80
38/** A summary line this long or longer tells the summary from another session's: headings and boilerplate are shorter. */
39export const EDIT_DISTINCT_LINE_CHARS = 40
40/** Below this many distinct lines a summary is matched by its opening instead. */
41export const EDIT_MIN_DISTINCT_LINES = 4
42/** How many numbered names a kept-aside edit tries before giving up on a free one. */
43export const FREE_NAME_TRIES = 100
44
45export type CompactionFiles = {
46 dir: string
47 before: string
48 beforeJson: string
49 summary: string
50 after: string
51 lost: string
52 meta: string
53}
54
55export const pad2 = (n: number): string => String(n).padStart(2, '0')
56
57export const expandHome = (path: string, home: string): string =>
58 path === '~' ? home : path.startsWith('~/') ? `${home}${path.slice(1)}` : path
59
60export const lensRoot = (home: string, dirOption: string): string =>
61 dirOption.trim() === '' ? `${home}/${DEFAULT_FOLDER}` : expandHome(dirOption.trim(), home)
62
63export const sessionDirOf = (root: string, sessionId: string): string => `${root}/${sessionId}`
64
65export const compactionFiles = (sessionDir: string, n: number): CompactionFiles => {
66 const dir = `${sessionDir}/${pad2(n)}`
67 return {
68 dir,
69 before: `${dir}/before.md`,
70 beforeJson: `${dir}/before.json`,
71 summary: `${dir}/summary.md`,
72 after: `${dir}/after.md`,
73 lost: `${dir}/lost.md`,
74 meta: `${dir}/meta.json`,
75 }
76}
77
78export const pinnedFile = (sessionDir: string): string => `${sessionDir}/pinned.md`
79export const draftFile = (sessionDir: string): string => `${sessionDir}/draft.md`
80hooks/records.ts 148 lines1// The record of a compaction and the message helpers the compaction hook uses (pure).
2import type { SessionCompactInput, SessionCompactResult, SessionMessage } from 'claude-code'
3
4import type { CompactLensPin, CompactLensRecord } from '../types'
5import type { LostReport } from './lost'
6import type { CompactionFiles } from './paths'
7import { ENGINE_SUMMARY_OPENING, SUMMARY_HEAD_CHARS } from './paths'
8import { estimateTokens, fmtTokens, transcriptChars } from './snapshot'
9
10export type NextCompact = (e: SessionCompactInput) => Promise<SessionCompactResult>
11
12export const userMessage = (text: string): SessionMessage => ({ role: 'user', text, toolUses: [] })
13
14const isPlainUser = (m: SessionMessage | undefined): boolean =>
15 m !== undefined && m.role === 'user' && (m.toolResults === undefined || m.toolResults.length === 0)
16
17/** A summary's length and FNV-1a hash: two summaries that share their opening still differ here. */
18export const hashOf = (text: string): string => {
19 let hash = 0x811c9dc5
20 for (let i = 0; i < text.length; i += 1) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193) >>> 0
21 return `${text.length}-${hash.toString(16)}`
22}
23
24/** Whether `text` is the summary a record names: its opening, and its whole text where the record holds a hash. */
25export const isRecordedSummary = (text: string, record: CompactLensRecord): boolean =>
26 record.summaryHead !== '' && text.startsWith(record.summaryHead) && (record.summaryHash === undefined || record.summaryHash === hashOf(text))
27
28/** Where a record's summary stands in a compaction's input: the first plain user message that is it; -1 when absent. */
29export const findRecordedSummary = (messages: readonly SessionMessage[], record: CompactLensRecord): number =>
30 messages.findIndex(m => isPlainUser(m) && isRecordedSummary(m.text, record))
31
32/** A message of the next request, in the Messages API's shape: `$.session.messages({ as: 'api' })`. */
33export type ApiLike = { role: string; content: ReadonlyArray<{ type: string; [field: string]: unknown }> }
34
35/** The text blocks of the next request's opening user messages, before the model's first reply: where the engine puts the last summary. */
36const openingTexts = (api: readonly ApiLike[]): string[] => {
37 const end = api.findIndex(m => m.role !== 'user')
38 return (end < 0 ? api : api.slice(0, end)).flatMap(m =>
39 m.content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])),
40 )
41}
42
43/**
44 * The summary the conversation runs on. It is found in the next request, where the engine puts the
45 * last summary first: the first opening text block that opens as the engine's summaries do, or with
46 * a recorded summary's head (an applied edit may open otherwise), so a prompt sent later is never
47 * taken for one. Its text is the transcript's own message that block holds, the longest such, in
48 * case the request merged the note after it into the same block; the block itself without one.
49 */
50export const summaryIn = (api: readonly ApiLike[], rows: readonly SessionMessage[], heads: readonly string[]): string | undefined => {
51 const opensAsSummary = (text: string): boolean => text.startsWith(ENGINE_SUMMARY_OPENING) || heads.some(head => head !== '' && text.startsWith(head))
52 const block = openingTexts(api).find(opensAsSummary)
53 if (block === undefined) return undefined
54 const held = rows.filter(m => isPlainUser(m) && m.text !== '' && opensAsSummary(m.text) && block.startsWith(m.text))
55 return held.reduce<string | undefined>((longest, m) => (longest === undefined || m.text.length > longest.length ? m.text : longest), undefined) ?? block
56}
57
58export const replaceAt = (messages: readonly SessionMessage[], index: number, message: SessionMessage): SessionMessage[] =>
59 messages.map((one, i) => (i === index ? message : one))
60
61export const insertAfter = (messages: readonly SessionMessage[], index: number, message: SessionMessage): SessionMessage[] => [
62 ...messages.slice(0, index + 1),
63 message,
64 ...messages.slice(index + 1),
65]
66
67export type RecordArgs = {
68 n: number
69 trigger: CompactLensRecord['trigger']
70 at: string
71 files: CompactionFiles
72 before: readonly SessionMessage[]
73 after: readonly SessionMessage[]
74 summary: string
75 lost: LostReport
76 tokensBefore?: number
77 tokensAfter?: number
78}
79
80export const buildRecord = (args: RecordArgs): CompactLensRecord => ({
81 n: args.n,
82 trigger: args.trigger,
83 at: args.at,
84 dir: args.files.dir,
85 messagesBefore: args.before.length,
86 messagesAfter: args.after.length,
87 charsBefore: transcriptChars(args.before),
88 charsAfter: transcriptChars(args.after),
89 tokensBefore: args.tokensBefore ?? null,
90 tokensAfter: args.tokensAfter ?? null,
91 summaryHead: args.summary.slice(0, SUMMARY_HEAD_CHARS),
92 summaryHash: hashOf(args.summary),
93 promptsInSpan: args.lost.prompts.length,
94 filesInSpan: args.lost.filesEdited.length + args.lost.filesRead.length,
95 identifiersLost: args.lost.identifiersLostTotal,
96})
97
98/** What every real compaction's record is built from: the transcript in, the result with its summary, the pins. */
99export type RecordCompactionArgs = {
100 n: number
101 trigger: CompactLensRecord['trigger']
102 at: string
103 files: CompactionFiles
104 before: readonly SessionMessage[]
105 withSummary: readonly SessionMessage[]
106 summaryIndex: number
107 pins: readonly CompactLensPin[]
108 tokensBefore?: number
109 tokensAfter?: number
110}
111
112/** The pins and records a store entry holds; empty lists for anything else. */
113export const heldLists = (held: unknown): { pinned: CompactLensPin[]; compactions: CompactLensRecord[] } => {
114 const { pinned, compactions } = held !== null && typeof held === 'object' ? (held as { pinned?: unknown; compactions?: unknown }) : {}
115 return {
116 pinned: Array.isArray(pinned) ? (pinned as CompactLensPin[]) : [],
117 compactions: Array.isArray(compactions) ? (compactions as CompactLensRecord[]) : [],
118 }
119}
120
121export type UnseenArgs = { n: number; at: string; files: CompactionFiles; after: readonly SessionMessage[]; summary: string }
122
123/** The record of a compaction the mod did not see run: its summary and what follows it, nothing of what went in. */
124export const buildUnseenRecord = ({ n, at, files, after, summary }: UnseenArgs): CompactLensRecord => ({
125 n,
126 trigger: 'unseen',
127 at,
128 dir: files.dir,
129 messagesBefore: 0,
130 messagesAfter: after.length,
131 charsBefore: 0,
132 charsAfter: transcriptChars(after),
133 tokensBefore: null,
134 tokensAfter: null,
135 summaryHead: summary.slice(0, SUMMARY_HEAD_CHARS),
136 summaryHash: hashOf(summary),
137 promptsInSpan: 0,
138 filesInSpan: 0,
139 identifiersLost: 0,
140})
141
142/** A record's tokens in and out: the engine's counts, or the estimate where it gave none. */
143export const tokensOf = (record: CompactLensRecord): string => {
144 const before = record.tokensBefore ?? estimateTokens(record.charsBefore)
145 const after = record.tokensAfter ?? estimateTokens(record.charsAfter)
146 return `${fmtTokens(before)} → ${fmtTokens(after)}`
147}
148hooks/snapshot.ts 85 lines1import type { SessionMessage, ToolUseSummary } from 'claude-code'
2
3import { TEXT_CAP, TOOL_INPUT_CAP, TOOL_RESULT_CAP } from './paths'
4
5/** Cuts `text` to `cap` characters, saying how much was cut. */
6export const cut = (text: string, cap: number): string =>
7 text.length <= cap ? text : `${text.slice(0, cap)}\n… [cut: ${text.length - cap} more characters]`
8
9/** Drops the engine's system-reminder blocks from a user message's text. */
10export const stripReminders = (text: string): string =>
11 text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
12
13/** True for a message the person typed: a user message with text and no tool results. */
14export const isPrompt = (m: SessionMessage): boolean =>
15 m.role === 'user' && (m.toolResults === undefined || m.toolResults.length === 0) && stripReminders(m.text) !== ''
16
17export const inputText = (use: ToolUseSummary): string => {
18 try {
19 return JSON.stringify(use.input, null, 1)
20 } catch {
21 return String(use.input)
22 }
23}
24
25/** The characters a message puts in the context: its text, its tool inputs and its tool results. */
26export const messageChars = (m: SessionMessage): number =>
27 m.text.length +
28 m.toolUses.reduce((sum, use) => sum + inputText(use).length, 0) +
29 (m.toolResults ?? []).reduce((sum, result) => sum + result.text.length, 0)
30
31export const transcriptChars = (messages: readonly SessionMessage[]): number =>
32 messages.reduce((sum, m) => sum + messageChars(m), 0)
33
34/** A rough token count: four characters a token. */
35export const estimateTokens = (chars: number): number => Math.round(chars / 4)
36
37export const fmtTokens = (n: number): string =>
38 n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
39
40/** The text the context holds after a compaction: every message's text and tool results. */
41export const contextText = (messages: readonly SessionMessage[]): string =>
42 messages.flatMap(m => [m.text, ...(m.toolResults ?? []).map(r => r.text)]).join('\n')
43
44const renderToolUse = (use: ToolUseSummary): string => {
45 const lines = [`### tool ${use.tool} (${use.tool_use_id})`, '```json', cut(inputText(use), TOOL_INPUT_CAP), '```']
46 if (use.text !== undefined) {
47 lines.push(`result${use.isError === true ? ' (error)' : ''}:`, '```', cut(use.text, TOOL_RESULT_CAP), '```')
48 }
49 return lines.join('\n')
50}
51
52const renderMessage = (m: SessionMessage, index: number): string => {
53 const parts = [`## ${index + 1} · ${m.role}`]
54 const results = m.toolResults ?? []
55 if (results.length > 0) {
56 parts.push(`(${results.length} tool result${results.length === 1 ? '' : 's'}, shown under the calls above)`)
57 }
58 const text = m.text.trim()
59 if (text !== '') parts.push(cut(text, TEXT_CAP))
60 for (const use of m.toolUses) parts.push(renderToolUse(use))
61 if (text === '' && m.toolUses.length === 0 && results.length === 0) parts.push('(no text: a thinking-only or empty message)')
62 return parts.join('\n\n')
63}
64
65export type TranscriptHeader = { title: string; lines: readonly string[] }
66
67/** A transcript as a readable markdown record, one section per message. */
68export const renderTranscript = (messages: readonly SessionMessage[], header: TranscriptHeader): string =>
69 [`# ${header.title}`, header.lines.join('\n'), ...messages.map(renderMessage)].join('\n\n') + '\n'
70
71/** The raw record, the engine's handles left out. */
72export const toJson = (messages: readonly SessionMessage[]): string =>
73 JSON.stringify(
74 messages.map(m => {
75 const { handle: _handle, ...rest } = m
76 return rest
77 }),
78 null,
79 1,
80 )
81
82/** The index of the summary in a compaction's result: the first user message with text and no tool results. */
83export const findSummaryIndex = (messages: readonly SessionMessage[]): number =>
84 messages.findIndex(m => m.role === 'user' && m.text.trim() !== '' && (m.toolResults ?? []).length === 0)
85hooks/texts.ts 140 lines1// The options, the command's help and replies, the tools' descriptions and the fixed texts (pure).
2// The engine prints a command's reply and a log line under the plugin's name, so neither repeats it.
3import type { HookFailure, PluginOptions } from 'claude-code'
4
5import type { CompactLensPin, CompactLensRecord } from '../types'
6import { plural } from './notes'
7import { COMMAND, COMPACT_COMMAND, compactionFiles, pad2, SHOW_CAP } from './paths'
8import { tokensOf } from './records'
9import { cut } from './snapshot'
10
11/** A failed hook's reason, as a `.catch` handler reads it. */
12export const failureText = (failure: HookFailure): string => failure.message ?? failure.kind
13
14/** The store key a session's pins and records are mirrored under. */
15export const storeKey = (sessionId: string): string => `session:${sessionId}`
16
17export type Config = { warnAtPercent: number; dir: string; openPane: boolean; statusLine: boolean }
18
19const DEFAULT_WARN_AT = 70
20
21/** How many compactions the pane lists, newest last. */
22export const SHOWN_RECORDS = 10
23
24export const readConfig = (options: PluginOptions): Config => ({
25 warnAtPercent: typeof options.warnAtPercent === 'number' ? options.warnAtPercent : DEFAULT_WARN_AT,
26 dir: typeof options.dir === 'string' ? options.dir : '',
27 openPane: options.openPane === true,
28 statusLine: options.statusLine !== false,
29})
30
31// Toasts show under the plugin's name too.
32export const EDIT_STARTED_TOAST = `the summary is in the prompt box; edit it there and keep the first line. Enter saves it, then ${COMPACT_COMMAND} applies it`
33export const OFFER_TOAST = `${COMPACT_COMMAND} is in the prompt box; Enter applies the edited summary, with no summariser`
34export const RUN_COMPACT_TOAST = `run ${COMPACT_COMMAND} to apply the edited summary; the mod answers it with no summariser`
35
36export const HELP = [
37 `/${COMMAND} open the pane and show the status`,
38 `/${COMMAND} edit put the summary in the prompt box; Enter saves it, then ${COMPACT_COMMAND} applies it`,
39 `/${COMMAND} show [what] [n] print lost (the default), after, summary, before or pinned of compaction n`,
40 `/${COMMAND} list list the compactions, their folders and the pinned notes`,
41 `/${COMMAND} keep <text> pin a fact so it survives every compaction verbatim`,
42 `/${COMMAND} unpin <n> remove pinned note n`,
43 `/${COMMAND} apply ready an edited summary.md; ${COMPACT_COMMAND} then applies it`,
44 `/${COMMAND} cancel drop a waiting apply; the edit stays in its file`,
45].join('\n')
46
47export const ARGUMENT_HINT = '[edit | show [lost|after|summary|before|pinned] [n] | list | keep <text> | unpin <n> | apply | cancel]'
48
49export const KEEP_DESCRIPTION =
50 'Pin a fact so it survives every compaction of this session verbatim: a decision, a path, a number, a name, a rule. ' +
51 "The note is put into the summariser's instructions and written, word for word, into the note that follows every summary. Keep each note short and self-contained."
52
53export const APPLY_DESCRIPTION =
54 'Ready the edited summary.md of the latest compaction (its path is in the note after the summary and in the Compact Lens section) to replace the current summary. ' +
55 `Edit the file first with Edit, then call this. The replacement rides on the person's next ${COMPACT_COMMAND}, which the mod answers with the edited text and no summariser call: ` +
56 `when this turn ends ${COMPACT_COMMAND} is put in their prompt box, so tell them to press Enter on it. The transcript after the summary is kept as it is.`
57
58/** What a waiting apply reads as, in the status reply and the pane. */
59export const PENDING_TEXT = `an edited summary waits: ${COMPACT_COMMAND} with nothing after it applies it, /${COMMAND} cancel drops it`
60
61export type StatusLineFields = { fill: number | null; records: readonly CompactLensRecord[]; pinCount: number; isPending: boolean }
62
63/** The line under the prompt; it names the plugin itself, as nothing else there does. */
64export const statusLineText = ({ fill, records, pinCount, isPending }: StatusLineFields): string =>
65 `compact-lens: ${fill === null ? '–' : `${fill}%`} · ${plural(records.length, 'compaction')} · ${pinCount} pinned${isPending ? ` · ${COMPACT_COMMAND} applies the edit` : ''}`
66
67export type StatusFields = { fill: number | null; records: readonly CompactLensRecord[]; pinCount: number; dir: string | null; isPending: boolean }
68
69/** The command's status reply: the fill, the counts, the folder and the latest compaction. */
70export const formatStatus = ({ fill, records, pinCount, dir, isPending }: StatusFields): string => {
71 const latest = records.at(-1)
72 const fillText = fill === null ? 'not measured yet' : `${fill}%`
73 return [
74 `context ${fillText}; ${plural(records.length, 'compaction')}; ${pinCount} pinned`,
75 ...(isPending ? [PENDING_TEXT] : []),
76 `snapshots: ${dir ?? '(unset)'}`,
77 ...(latest === undefined
78 ? []
79 : [`latest: #${latest.n} (${latest.trigger}) at ${latest.at}: ${latest.messagesBefore} → ${latest.messagesAfter} messages, ${latest.dir}`]),
80 ].join('\n')
81}
82
83const listLine = (r: CompactLensRecord, dir: string | null): string => {
84 const files = compactionFiles(dir ?? '', r.n)
85 return r.trigger === 'unseen'
86 ? ` #${r.n} unseen, noticed ${r.at}: the mod did not see it run, so its summary alone is recorded; ${files.summary}`
87 : ` #${r.n} ${r.trigger} ${r.at}: ${r.messagesBefore} → ${r.messagesAfter} messages; ${files.before}; the span held ${r.promptsInSpan} prompts and ${r.filesInSpan} files; ${r.identifiersLost} identifiers no longer mentioned`
88}
89
90/** The command's list reply: every compaction with its folder and counts, then the pinned notes. */
91export const formatList = (records: readonly CompactLensRecord[], pins: readonly CompactLensPin[], dir: string | null): string =>
92 [
93 `compactions (${records.length}):`,
94 ...(records.length === 0 ? [' none yet'] : []),
95 ...records.map(r => listLine(r, dir)),
96 `pinned (${pins.length}):`,
97 ...(pins.length === 0 ? [' none'] : pins.map(p => ` ${p.id}. ${p.text}`)),
98 ].join('\n')
99
100/** One compaction's line in the pane. */
101export const paneLine = (r: CompactLensRecord): string =>
102 r.trigger === 'unseen'
103 ? `#${pad2(r.n)} unseen, noticed ${r.at.slice(11, 19)}: summary only ${r.dir}`
104 : `#${pad2(r.n)} ${r.trigger} ${r.at.slice(11, 19)} ${r.messagesBefore}→${r.messagesAfter} msgs ${tokensOf(r)} ${r.dir}`
105
106export const SHOW_KINDS = ['lost', 'after', 'summary', 'before', 'pinned'] as const
107export type ShowKind = (typeof SHOW_KINDS)[number]
108export type ShowRequest = { kind: ShowKind; n: number | undefined }
109
110const isShowKind = (word: string): word is ShowKind => (SHOW_KINDS as readonly string[]).includes(word)
111const isNumberWord = (word: string): boolean => /^#?\d+$/.test(word)
112
113/** `show [what] [n]`, in either order; the reason when the words are neither. */
114export const parseShow = (tail: string): ShowRequest | string => {
115 const words = tail.split(/\s+/).filter(word => word !== '')
116 const kind = words.find(isShowKind)
117 const number = words.find(isNumberWord)
118 const other = words.find(word => word !== kind && word !== number)
119 if (other !== undefined) return `show takes one of ${SHOW_KINDS.join(', ')} and a compaction number, as in /${COMMAND} show lost 2`
120 return { kind: kind ?? 'lost', n: number === undefined ? undefined : Number(number.replace('#', '')) }
121}
122
123/** A file printed as the command's reply: its path, then its text, cut at SHOW_CAP. */
124export const formatShown = (path: string, text: string): string => `${path}\n\n${cut(text.trimEnd(), SHOW_CAP)}`
125
126/** before.md of a compaction the mod did not see run. */
127export const unseenBefore = (n: number, at: string): string =>
128 [
129 `# before — compaction #${n} (unseen), noticed at ${at}`,
130 '',
131 'compact-lens did not see this compaction run, so the transcript that went in was not saved here and',
132 "nothing could be compared; the session's own transcript file (under ~/.claude/projects/) still holds it.",
133 'summary.md holds its summary, and after.md the conversation from that summary on, as the mod found it.',
134 '',
135 ].join('\n')
136
137/** lost.md of a compaction the mod did not see run. */
138export const unseenLost = (n: number, at: string): string =>
139 [`# lost — compaction #${n} (unseen), noticed at ${at}`, '', 'Not computed: the mod did not see what went into this compaction. See before.md.', ''].join('\n')
140types/index.d.ts 56 lines1/**
2 * Who ran a recorded compaction: the engine's triggers, an `apply` of an edited summary, or
3 * `unseen`: one the mod did not see run (it was not listening), recorded from its summary afterwards.
4 */
5export type CompactLensTrigger = 'manual' | 'auto' | 'plugin' | 'apply' | 'unseen'
6
7/** One recorded compaction of this session: its number, its folder and its counts. */
8export type CompactLensRecord = {
9 n: number
10 trigger: CompactLensTrigger
11 at: string
12 dir: string
13 /** How many messages went into the compaction. */
14 messagesBefore: number
15 /** How many the engine's compaction produced (the summary and the kept messages), before the mod's note. */
16 messagesAfter: number
17 charsBefore: number
18 charsAfter: number
19 tokensBefore: number | null
20 tokensAfter: number | null
21 /** The first 400 characters of the summary's text, to find the summary again. */
22 summaryHead: string
23 /** The whole summary's length and hash, to tell it from another with the same opening; absent in records from before 0.3.0. */
24 summaryHash?: string
25 /** How many of the person's prompts the compacted span held (listed verbatim in lost.md). */
26 promptsInSpan: number
27 /** How many files the span edited, wrote or read (listed in lost.md). */
28 filesInSpan: number
29 /** How many identifiers of the span the context after the compaction no longer mentions. */
30 identifiersLost: number
31}
32
33/** A fact pinned with the keep tool: kept verbatim through every compaction. */
34export type CompactLensPin = { id: number; text: string; at: string }
35
36declare module 'claude-code' {
37 interface PluginState {
38 'compact-lens': {
39 compactions: CompactLensRecord[]
40 pinned: CompactLensPin[]
41 /** The fill percent at which the warning was appended; null until it is, and again after a compaction. */
42 warnedAt: number | null
43 /** True while an edited summary.md waits for the person's /compact to replace the current summary. */
44 pendingApply: boolean
45 /** This session's snapshot folder, set at session.start. */
46 sessionDir: string | null
47 /** The last context fill the mod read, as a percent. */
48 fill: number | null
49 /** The summary the engine precomputed, as written to draft.md; null when none. */
50 draft: string | null
51 /** The compaction whose summary is open for editing in the prompt box; null when none. */
52 editing: number | null
53 }
54 }
55}
56