SLOPSHOPPER

compact-lens

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

newpaneguardcommandtoaststatus
v0.3.0MITupdated 2026-10-08ewanlimr25/compact-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compact-lens
│ ┃ Compact Lens ✕ › fix the failing auth test and add an audit log call │ ┃ Compact Lens │ ┃ context – · 0 compactions · 0 pinned ● compact-lens: a compaction the mod did not see could not be recorde │ ┃ …rs/dev/.claude/compact-lens/preview-session ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Compactions ⏺ Update(src/auth.ts) │ ┃ none yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Pinned ⎿ 3 pass, 1 fail │ ┃ none; the model pins with │ ┃ mcp__compact-lens__keep, you with ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ /compact-lens keep │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Edit summary ] [ Apply summary.md ] [ Refr │ › /compact-lens │ ⎿ compact-lens: the command failed: undefined is not an object (ev │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ compact-lens: compact-lens: – · 0 compactions · 0 pinned

Draws

Pane · Compact Lens
Compact Lens context – · 0 compactions · 0 pinned /Users/dev/.claude/compact-lens/preview-session Compactions none yet Pinned none; the model pins with mcp__compact-lens__keep, you with /compact-lens keep [ Edit summary ] [ Apply summary.md ] [ Refresh ] [ Close ]
README

compact-lens

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.

Setting it up

From install to your first edited summary in six steps. What it does below is the reference for each part.

1. Check your Claude Code

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.

2. Install it

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.

3. Check that it loaded

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.

4. Your first compaction

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".

5. Edit the summary

  1. Run /compact-lens edit. The summary goes into your prompt box under a dim first line, [compact-lens edit #01: …]. Keep that line.
  2. Edit the text in the box, or press ctrl+g to edit it in your own editor.
  3. Press Enter. Claude Code shows 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.
  4. Press Enter on /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.

6. Pin what must survive

/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.

Updating

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.

Turning it off

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.

Good to know

  • The snapshots hold everything. 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.
  • Disk. A compaction of a long session writes a megabyte or two. A session's folder is safe to delete once you won't resume that session.

Troubleshooting

  • /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.
  • Everything happens twice (two notes after a summary, say). The mod is loaded twice: from the marketplace and a clone, or from two clones. Keep one.
  • Enter on my edit said "Prompt dropped by a hook". That is the save; see step 5.
  • /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.
  • My edit went to edit-unapplied-<time>.md. A newer compaction ran while you were editing. Run /compact-lens edit again to edit the current summary.

What it does

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>/:

FileWhat it holds
before.mdthe 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.jsonthe same, raw and uncut
summary.mdthe summary's text, exactly; edit it and apply it (below)
after.mdwhat the context held right after: the summary and the kept messages
lost.mdwhat 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.jsonthe 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:

  • the model calls mcp__compact-lens__apply: when its turn ends, /compact goes into your prompt box;
  • or you run /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.

Options

With /plugin configure compact-lens@compact-lens in a session; a clone's are rows in /config, or pluginConfigs.compact-lens in settings:

OptionDefaultMeaning
warnAtPercent70the 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
openPanefalseopen the pane at session start (a wide terminal seats it)
statusLinetruethe line under the prompt

Layout

.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.

Checking it

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.

License

MIT; see LICENSE.

Source 9 files
hooks/register.tsx 800 lines
1// 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}
800
hooks/editor.ts 104 lines
1// 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.`
104
hooks/lost.ts 256 lines
1import 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')
256
hooks/notes.ts 91 lines
1import 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')
91
hooks/paths.ts 80 lines
1export 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`
80
hooks/records.ts 148 lines
1// 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}
148
hooks/snapshot.ts 85 lines
1import 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)
85
hooks/texts.ts 140 lines
1// 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')
140
types/index.d.ts 56 lines
1/**
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