SLOPSHOPPER

compact-keeper

Compaction without amnesia: the summarizer is told to keep your goal, constraints, decisions and paths verbatim, a card with your goal, prompts, pinned notes…

newcommandtoast
v0.1.0MITupdated 2026-10-06Jvrd97/claude-compact-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compact-keeper
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keep ⎿ compact-keeper: No notes pinned yet. ⎿ compact-keeper: ⎿ compact-keeper: Usage: /keep <note> pins a note that survives compaction; /keep clear removes them all. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

compact-keeper

A Claude Code mod that makes compaction keep what matters: your goal, your own words, the files touched and the open work, plus a full markdown copy of the conversation before it was compacted.

compacted — kept goal, 1 note, 14 prompts, 6 files · archive saved

Right after the summary, the model reads a card like this:

[compact-keeper] What the user asked for before this compaction, kept word for word ...

## Goal
Migrate the billing service to the new Stripe API, keep the v1 webhooks working

## Notes the user pinned with /keep
- Never push to main

## User prompts, oldest first (last 14)
- Do not touch the tests folder
- ...

## Files edited or created
- /repo/billing/api.py
- /repo/billing/stripe_v2.py

## Open todos
- [in_progress] Port refunds

## Full transcript before compaction
/Users/you/.claude/compact-keeper/-Users-you-repo/<session-id>/2026-10-06T12-00-00Z.md
Read it when a detail is missing from the summary.

Why

After a compaction Claude "forgets everything": the summary drops the goal, the constraints you set, the decisions and the file paths, and the conversation before it can't be viewed again.

Existing fixes work around compaction rather than inside it: claude-mem keeps memory across sessions, which is a different job, and PreCompact shell hooks can only run a script beside it. This mod hooks the compaction itself (session.compact), so it can change what the summarizer is told and what the compacted conversation holds.

What it does

WhereWhat
Summarizer instructionsAppends a paragraph asking it to keep the goal, constraints, decisions, file paths, commands and open work verbatim, plus your pinned notes. Text you typed after /compact stays first
Card after the summaryGoal (your first prompt that states a task), pinned notes, your last 20 prompts, files edited or created, the open items of the last todo list, the archive path
ArchiveThe conversation before compaction as markdown: each message's role and text, and the tools it called
/keep <note>Pins a note for this session. It goes into the summarizer instructions and the card at every compaction
/keep clearRemoves this session's notes. /keep alone lists them
/keptPrints the card as it would be now and the archive folder
Toastcompacted — kept goal, 14 prompts, 6 files · archive saved

A second compaction reads the previous card back, so the goal, the prompts and the files from before the first compaction carry on.

Install

You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-compact-keeper
/plugin install compact-keeper@compact-keeper

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-compact-keeper.git
claude --plugin-dir ./claude-compact-keeper

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
archivetrueWrite the markdown archive at each compaction
archiveDiremptyArchive root; ~ is your home. Empty means ~/.claude/compact-keeper
maxPrompts20How many of your latest prompts the card carries; 0 keeps none
instructionsemptyExtra text for the summarizer, added after the mod's own paragraph

How it works

  • session.compact fires for /compact, for auto-compaction and for a precompute. The mod acts only on the main conversation: a subagent's compaction (agentId set) passes through untouched.
  • Before the summary runs, the mod builds the card from the messages being compacted and adds its instructions. A precompute gets the instructions only, because it installs nothing.
  • After the summary succeeds, the mod writes the archive and puts the card into the compacted conversation right after the summary message. A skipped compaction (a PreCompact hook blocked it) gets no archive, no card and no toast.
  • Pinned notes live in the plugin's own store ($.store, a JSON file under your Claude Code config folder), keyed by session id, so they survive --continue and --resume. The store keeps the 100 most recent sessions.
  • Archives go to <archive root>/<project slug>/<session id>/<time>.md. The mod writes nowhere else and makes no network calls.

The card is part of the compacted conversation, not a hidden reminder: it shows in the transcript as a user message. A row appended through $.session.append from inside the hook lands before the compact boundary and is dropped with the old messages, which is why the mod hands the card up with the compaction's own result.

What it can't do:

  • Force the summarizer to obey. The instructions make it more likely to keep your words; the card is the part that is guaranteed.
  • Pick the goal perfectly. It is the first typed prompt with at least 3 words and 12 characters that is not a slash command. If your first prompt was small talk, the goal is wrong; /keep the real one.
  • See tool results in the archive. It keeps text and the tool calls (name and target, for example the file path or the command), not what the tools returned.
  • Track todos from task tools other than TodoWrite.
  • Bring back a compaction that happened before the mod was installed.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

После сжатия контекста Claude теряет цель, ограничения, решения и пути к файлам, а историю до сжатия не посмотреть. Мод чинит это изнутри самого сжатия:

  • просит модель-суммаризатор сохранить цель, ограничения, решения и пути дословно;
  • сразу после саммари кладёт карточку: цель, ваши последние 20 промптов, заметки из /keep, изменённые файлы и открытые задачи;
  • сохраняет всю переписку до сжатия в markdown в ~/.claude/compact-keeper/.

/keep <заметка> закрепляет заметку на сессию, /kept показывает, что сохранится.

License

MIT

Source 2 files
hooks/register.ts 168 lines
1import type { EngineInterface, Register, SessionCompactInput, SessionCompactResult, SessionMessage } from 'claude-code'
2
3import {
4  archiveFolder,
5  archiveMarkdown,
6  archiveRoot,
7  buildCard,
8  cardText,
9  cleanNote,
10  fileStamp,
11  isEmptyCard,
12  notesIn,
13  previewText,
14  summarizerInstructions,
15  toastText,
16  withCard,
17  withNotes,
18} from './logic'
19
20const KEEP = 'keep'
21const KEPT = 'kept'
22const CLEAR = 'clear'
23const TOAST_MS = 8_000
24const DEFAULT_MAX_PROMPTS = 20
25
26type Settings = { archive: boolean; archiveDir: string; maxPrompts: number; extra: string }
27
28type Next = (e: SessionCompactInput) => Promise<SessionCompactResult>
29
30type Archived = { status: 'saved'; path: string } | { status: 'failed' | 'off' }
31
32const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
33
34async function notesOf($: EngineInterface, sessionId: string): Promise<string[]> {
35  return notesIn(await $.store.get('notes'), sessionId)
36}
37
38async function saveNotes($: EngineInterface, sessionId: string, notes: readonly string[]): Promise<void> {
39  const book = await $.store.get('notes')
40  await $.store.set('notes', withNotes(book, sessionId, notes, await $.clock.now()))
41}
42
43async function folderOf($: EngineInterface, settings: Settings, sessionId: string): Promise<string | null> {
44  const root = archiveRoot(settings.archiveDir, await $.env.get('HOME'))
45
46  return root === null ? null : archiveFolder(root, await $.session.cwd(), sessionId)
47}
48
49async function saveArchive(
50  $: EngineInterface,
51  e: SessionCompactInput,
52  settings: Settings,
53  sessionId: string,
54): Promise<Archived> {
55  if (!settings.archive) {
56    return { status: 'off' }
57  }
58
59  const folder = await folderOf($, settings, sessionId)
60
61  if (folder === null) {
62    $.ui.log('compact-keeper: no HOME and no archiveDir, so the transcript was not archived', { to: 'debug' })
63
64    return { status: 'failed' }
65  }
66
67  const at = await $.clock.now()
68  const path = `${folder}/${fileStamp(at)}.md`
69  const text = archiveMarkdown(e.messages, { sessionId, cwd: await $.session.cwd(), at, trigger: e.trigger })
70
71  try {
72    await $.fs.write(path, text)
73
74    return { status: 'saved', path }
75  } catch (error) {
76    $.ui.log(`compact-keeper: archive not written to ${path}: ${reasonOf(error)}`)
77
78    return { status: 'failed' }
79  }
80}
81
82async function compact($: EngineInterface, e: SessionCompactInput, next: Next, settings: Settings): Promise<SessionCompactResult> {
83  const sessionId = await $.session.id()
84  const notes = await notesOf($, sessionId)
85  const instructions = summarizerInstructions(e.instructions, settings.extra, notes)
86
87  // A precompute installs nothing: only its summary is kept for later, so it gets the instructions alone.
88  if (e.trigger === 'precompute') {
89    return next({ ...e, instructions })
90  }
91
92  const card = buildCard(e.messages, notes, settings.maxPrompts)
93  const result = await next({ ...e, instructions })
94
95  if (result.skip !== undefined) {
96    return result
97  }
98
99  const archived = await saveArchive($, e, settings, sessionId)
100  const archivePath = archived.status === 'saved' ? archived.path : null
101  $.ui.toast(toastText(card, archived.status), { timeoutMs: TOAST_MS })
102
103  if (isEmptyCard(card) && archivePath === null) {
104    return result
105  }
106
107  // The card rides in the compacted conversation itself: a row appended from this hook would land
108  // before the compact boundary and be dropped with the rest of the old transcript.
109  const row: SessionMessage = { role: 'user', text: cardText(card, archivePath), toolUses: [] }
110
111  return { ...result, messages: withCard(result.messages, row) }
112}
113
114async function keep($: EngineInterface, args: string): Promise<string> {
115  const sessionId = await $.session.id()
116  const notes = await notesOf($, sessionId)
117  const note = args.trim()
118
119  if (note === '') {
120    const list = notes.length === 0 ? 'No notes pinned yet.' : notes.map(item => `- ${item}`).join('\n')
121
122    return `${list}\n\nUsage: /keep <note> pins a note that survives compaction; /keep clear removes them all.`
123  }
124
125  if (note === CLEAR) {
126    await saveNotes($, sessionId, [])
127
128    return `Cleared ${notes.length} pinned note${notes.length === 1 ? '' : 's'}.`
129  }
130
131  const cleaned = cleanNote(note)
132  const nextNotes = notes.includes(cleaned) ? notes : [...notes, cleaned]
133  await saveNotes($, sessionId, nextNotes)
134
135  return `Pinned (${nextNotes.length} in this session): ${cleaned}\nIt goes into the summary instructions and the card after every compaction.`
136}
137
138async function kept($: EngineInterface, settings: Settings): Promise<string> {
139  const sessionId = await $.session.id()
140  const card = buildCard(await $.session.messages(), await notesOf($, sessionId), settings.maxPrompts)
141  const folder = settings.archive ? await folderOf($, settings, sessionId) : null
142
143  return previewText(card, folder, settings.archive)
144}
145
146export const register: Register = (on, options) => {
147  const maxPrompts = Number(options.maxPrompts ?? DEFAULT_MAX_PROMPTS)
148  const settings: Settings = {
149    archive: options.archive !== false,
150    archiveDir: String(options.archiveDir ?? ''),
151    maxPrompts: Number.isFinite(maxPrompts) ? Math.max(0, Math.floor(maxPrompts)) : DEFAULT_MAX_PROMPTS,
152    extra: String(options.instructions ?? ''),
153  }
154
155  on('session.start', async ($, e, next) => {
156    await $.command.register({ name: KEEP, description: 'Pin a note that survives compaction (/keep clear removes all)', argumentHint: '<note> | clear' })
157    await $.command.register({ name: KEPT, description: 'Show what compact-keeper will keep at the next compaction' })
158
159    return next(e)
160  })
161
162  on('session.compact', ($, e, next) => (e.agentId === undefined ? compact($, e, next, settings) : next(e)))
163
164  on('command.run', { command: KEEP }, async ($, e) => ({ text: await keep($, e.args) }))
165
166  on('command.run', { command: KEPT }, async $ => ({ text: await kept($, settings) }))
167}
168
hooks/logic.ts 398 lines
1import type { SessionMessage } from 'claude-code'
2
3export const CARD_MARKER = '[compact-keeper]'
4export const SUMMARY_PREFIX = 'This session is being continued from a previous conversation'
5
6const MAX_PROMPT_CHARS = 300
7const MAX_GOAL_CHARS = 600
8const MAX_NOTE_CHARS = 500
9const MIN_GOAL_WORDS = 3
10const MIN_GOAL_CHARS = 12
11const MAX_TOOL_HINT_CHARS = 120
12const MAX_SESSIONS_KEPT = 100
13
14const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
15const TODO_TOOL = 'TodoWrite'
16
17// Rows the engine writes on the user's side that the person never typed.
18const ENGINE_ROW = /^<(command-[a-z]+|local-command-[a-z]+|system-reminder|task-notification|bash-[a-z]+|user-memory-input)>/
19const INTERRUPTED = /^\[Request interrupted/
20
21const SECTION = {
22  goal: 'Goal',
23  notes: 'Notes the user pinned with /keep',
24  prompts: 'User prompts, oldest first',
25  files: 'Files edited or created',
26  todos: 'Open todos',
27  archive: 'Full transcript before compaction',
28} as const
29
30export type Card = {
31  goal: string | null
32  notes: readonly string[]
33  prompts: readonly string[]
34  files: readonly string[]
35  todos: readonly string[]
36}
37
38export type Carried = Omit<Card, 'notes'>
39
40export type NoteBook = Record<string, { notes: string[]; at: number }>
41
42export const oneLine = (text: string, max: number): string => {
43  const flat = text.replace(/\s+/g, ' ').trim()
44
45  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
46}
47
48const isCard = (text: string): boolean => text.startsWith(CARD_MARKER)
49
50/** A user row the person typed: not a tool result, an engine row, a summary or a card. */
51export const isTypedPrompt = (message: SessionMessage): boolean => {
52  if (message.role !== 'user' || (message.toolResults?.length ?? 0) > 0) {
53    return false
54  }
55
56  const text = message.text.trim()
57
58  return (
59    text !== '' &&
60    !ENGINE_ROW.test(text) &&
61    !INTERRUPTED.test(text) &&
62    !text.startsWith(SUMMARY_PREFIX) &&
63    !isCard(text)
64  )
65}
66
67const isSlashCommand = (text: string): boolean => /^\/[A-Za-z]/.test(text)
68
69export const promptsOf = (messages: readonly SessionMessage[]): string[] =>
70  messages
71    .filter(isTypedPrompt)
72    .map(message => message.text.trim())
73    .filter(text => !isSlashCommand(text))
74    .map(text => oneLine(text, MAX_PROMPT_CHARS))
75
76/** The first prompt that states a task: no slash command, no "ok" or "go on". */
77export const goalOf = (messages: readonly SessionMessage[]): string | null => {
78  for (const message of messages) {
79    if (!isTypedPrompt(message)) {
80      continue
81    }
82
83    const text = message.text.trim()
84    const words = text.split(/\s+/).length
85
86    if (!isSlashCommand(text) && words >= MIN_GOAL_WORDS && text.length >= MIN_GOAL_CHARS) {
87      return oneLine(text, MAX_GOAL_CHARS)
88    }
89  }
90
91  return null
92}
93
94const pathOf = (input: Record<string, unknown>): string | null => {
95  const path = input.file_path ?? input.notebook_path
96
97  return typeof path === 'string' && path !== '' ? path : null
98}
99
100export const filesOf = (messages: readonly SessionMessage[]): string[] => {
101  const files: string[] = []
102
103  for (const message of messages) {
104    for (const use of message.toolUses) {
105      const path = FILE_TOOLS.has(use.tool) && use.isError !== true ? pathOf(use.input) : null
106
107      if (path !== null && !files.includes(path)) {
108        files.push(path)
109      }
110    }
111  }
112
113  return files
114}
115
116type Todo = { content: string; status: string }
117
118const isTodo = (value: unknown): value is Todo =>
119  typeof value === 'object' &&
120  value !== null &&
121  typeof (value as { content?: unknown }).content === 'string' &&
122  typeof (value as { status?: unknown }).status === 'string'
123
124/** The open items of the last TodoWrite; null when the transcript has none. */
125export const todosOf = (messages: readonly SessionMessage[]): string[] | null => {
126  let last: unknown[] | null = null
127
128  for (const message of messages) {
129    for (const use of message.toolUses) {
130      if (use.tool === TODO_TOOL && Array.isArray(use.input.todos)) {
131        last = use.input.todos
132      }
133    }
134  }
135
136  if (last === null) {
137    return null
138  }
139
140  return last
141    .filter(isTodo)
142    .filter(todo => todo.status !== 'completed')
143    .map(todo => `[${todo.status}] ${oneLine(todo.content, MAX_PROMPT_CHARS)}`)
144}
145
146const sectionsOf = (text: string): Map<string, string[]> => {
147  const sections = new Map<string, string[]>()
148  let current: string[] | null = null
149
150  for (const line of text.split('\n')) {
151    if (line.startsWith('## ')) {
152      current = []
153      sections.set(line.slice(3).replace(/\s*\(.*\)$/, '').trim(), current)
154    } else if (current !== null && line.trim() !== '') {
155      current.push(line)
156    }
157  }
158
159  return sections
160}
161
162const itemsOf = (lines: readonly string[] | undefined): string[] =>
163  (lines ?? []).filter(line => line.startsWith('- ')).map(line => line.slice(2))
164
165/** Reads back a card this mod wrote, so a second compaction keeps what the first one kept. */
166export const parseCard = (text: string): Carried | null => {
167  if (!isCard(text)) {
168    return null
169  }
170
171  const sections = sectionsOf(text)
172  const goal = (sections.get(SECTION.goal) ?? []).join(' ').trim()
173
174  return {
175    goal: goal === '' ? null : goal,
176    prompts: itemsOf(sections.get(SECTION.prompts)),
177    files: itemsOf(sections.get(SECTION.files)),
178    todos: itemsOf(sections.get(SECTION.todos)),
179  }
180}
181
182export const lastCarried = (messages: readonly SessionMessage[]): Carried | null => {
183  for (let index = messages.length - 1; index >= 0; index -= 1) {
184    const message = messages[index]
185    const carried = message?.role === 'user' ? parseCard(message.text.trim()) : null
186
187    if (carried !== null) {
188      return carried
189    }
190  }
191
192  return null
193}
194
195const unique = (items: readonly string[]): string[] => [...new Set(items)]
196
197/** What survives this compaction: the last card's contents plus what happened since. */
198export const buildCard = (messages: readonly SessionMessage[], notes: readonly string[], maxPrompts: number): Card => {
199  const carried = lastCarried(messages)
200  const prompts = [...(carried?.prompts ?? []), ...promptsOf(messages)]
201
202  return {
203    goal: carried?.goal ?? goalOf(messages),
204    notes,
205    prompts: maxPrompts > 0 ? prompts.slice(-maxPrompts) : [],
206    files: unique([...(carried?.files ?? []), ...filesOf(messages)]),
207    todos: todosOf(messages) ?? [...(carried?.todos ?? [])],
208  }
209}
210
211const listOf = (items: readonly string[]): string => items.map(item => `- ${item}`).join('\n')
212
213export const cardText = (card: Card, archivePath: string | null): string => {
214  const parts = [
215    `${CARD_MARKER} What the user asked for before this compaction, kept word for word by the compact-keeper mod. Where it disagrees with the summary above, this card is right.`,
216  ]
217
218  if (card.goal !== null) {
219    parts.push(`## ${SECTION.goal}\n${card.goal}`)
220  }
221
222  if (card.notes.length > 0) {
223    parts.push(`## ${SECTION.notes}\n${listOf(card.notes)}`)
224  }
225
226  if (card.prompts.length > 0) {
227    parts.push(`## ${SECTION.prompts} (last ${card.prompts.length})\n${listOf(card.prompts)}`)
228  }
229
230  if (card.files.length > 0) {
231    parts.push(`## ${SECTION.files}\n${listOf(card.files)}`)
232  }
233
234  if (card.todos.length > 0) {
235    parts.push(`## ${SECTION.todos}\n${listOf(card.todos)}`)
236  }
237
238  if (archivePath !== null) {
239    parts.push(`## ${SECTION.archive}\n${archivePath}\nRead it when a detail is missing from the summary.`)
240  }
241
242  return parts.join('\n\n')
243}
244
245/** What /kept prints: the card as it stands now, without the marker that would make it read as a real card. */
246export const previewText = (card: Card, folder: string | null, isArchiving: boolean): string => {
247  const body = isEmptyCard(card) ? 'Nothing to keep yet: no goal, prompts, notes, files or todos.' : cardText(card, null).split('\n\n').slice(1).join('\n\n')
248  const where = !isArchiving ? 'Archive: off' : folder === null ? 'Archive: no folder (HOME is not set)' : `Archive folder: ${folder}/`
249
250  return `At the next compaction compact-keeper keeps:\n\n${body}\n\n${where}`
251}
252
253export const isEmptyCard = (card: Card): boolean =>
254  card.goal === null && card.notes.length === 0 && card.prompts.length === 0 && card.files.length === 0 && card.todos.length === 0
255
256const KEEP_INSTRUCTIONS =
257  'Keep these verbatim, quoted rather than paraphrased: the user\'s goal; every constraint, preference and rule the user stated; decisions made and the reason for each; exact file paths, commands, identifiers and error messages; and the open work (unfinished tasks and the next step).'
258
259export const summarizerInstructions = (existing: string | undefined, extra: string, notes: readonly string[]): string => {
260  const pinned = notes.length > 0 ? `The user pinned these notes; keep each one word for word:\n${listOf(notes)}` : ''
261
262  return [existing?.trim() ?? '', KEEP_INSTRUCTIONS, pinned, extra.trim()].filter(part => part !== '').join('\n\n')
263}
264
265/** Puts the card right after the compaction's summary, or first when there is none. */
266export const withCard = (messages: readonly SessionMessage[], card: SessionMessage): SessionMessage[] => {
267  const summary = messages.findIndex(message => message.role === 'user' && message.text.trimStart().startsWith(SUMMARY_PREFIX))
268  const at = summary === -1 ? 0 : summary + 1
269
270  return [...messages.slice(0, at), card, ...messages.slice(at)]
271}
272
273const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
274
275export const toastText = (card: Card, archive: 'saved' | 'failed' | 'off'): string => {
276  const kept = [
277    card.goal === null ? null : 'goal',
278    card.notes.length > 0 ? plural(card.notes.length, 'note') : null,
279    plural(card.prompts.length, 'prompt'),
280    plural(card.files.length, 'file'),
281  ].filter((part): part is string => part !== null)
282  const tail = archive === 'off' ? '' : ` · archive ${archive}`
283
284  return `compacted — kept ${kept.join(', ')}${tail}`
285}
286
287export const projectSlug = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-')
288
289export const fileStamp = (now: number): string => new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-')
290
291/** The archive root: the setting with ~ expanded, or ~/.claude/compact-keeper; null without a home. */
292export const archiveRoot = (setting: string, home: string | undefined): string | null => {
293  const trimmed = setting.trim().replace(/\/+$/, '')
294
295  if (trimmed !== '' && !trimmed.startsWith('~')) {
296    return trimmed
297  }
298
299  if (home === undefined || home === '') {
300    return null
301  }
302
303  return trimmed === '' ? `${home}/.claude/compact-keeper` : `${home}${trimmed.slice(1)}`
304}
305
306export const archiveFolder = (root: string, cwd: string, sessionId: string): string => `${root}/${projectSlug(cwd)}/${sessionId}`
307
308const toolHint = (input: Record<string, unknown>): string => {
309  const target = input.file_path ?? input.notebook_path ?? input.path ?? input.command ?? input.pattern ?? input.url ?? input.description
310
311  return typeof target === 'string' && target !== '' ? ` \`${oneLine(target, MAX_TOOL_HINT_CHARS)}\`` : ''
312}
313
314const tagged = (text: string, tag: string): string | null => {
315  const found = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)
316
317  return found?.[1]?.trim() ?? null
318}
319
320/** Engine rows read as what they stand for: a slash command as typed, its output as text, the caveat dropped. */
321export const readableText = (text: string): string => {
322  const name = tagged(text, 'command-name')
323
324  if (name !== null) {
325    const args = tagged(text, 'command-args') ?? ''
326
327    return `\`${name}${args === '' ? '' : ` ${args}`}\``
328  }
329
330  const output = tagged(text, 'local-command-stdout')
331
332  if (output !== null) {
333    return `Command output:\n\n${output}`
334  }
335
336  return text.startsWith('<local-command-caveat>') ? '' : text
337}
338
339export type ArchiveMeta = { sessionId: string; cwd: string; at: number; trigger: string }
340
341export const archiveMarkdown = (messages: readonly SessionMessage[], meta: ArchiveMeta): string => {
342  const head = [
343    '# Transcript before compaction',
344    '',
345    `- Session: ${meta.sessionId}`,
346    `- Project: ${meta.cwd}`,
347    `- Compacted: ${new Date(meta.at).toISOString()} (${meta.trigger})`,
348    `- Messages: ${messages.length}`,
349  ]
350  const body: string[] = []
351
352  for (const message of messages) {
353    const text = readableText(message.text.trim())
354    const tools = message.toolUses.map(use => `- ${use.tool}${toolHint(use.input)}${use.isError === true ? ' (error)' : ''}`)
355
356    if (text === '' && tools.length === 0) {
357      continue
358    }
359
360    body.push(`## ${message.role === 'user' ? 'User' : 'Assistant'}`)
361
362    if (text !== '') {
363      body.push(text)
364    }
365
366    if (tools.length > 0) {
367      body.push(`Tools:\n${tools.join('\n')}`)
368    }
369  }
370
371  return [...head, '', '---', '', body.join('\n\n'), ''].join('\n')
372}
373
374export const cleanNote = (text: string): string => oneLine(text, MAX_NOTE_CHARS)
375
376export const notesIn = (book: unknown, sessionId: string): string[] => {
377  const entry = typeof book === 'object' && book !== null ? (book as NoteBook)[sessionId] : undefined
378
379  return Array.isArray(entry?.notes) ? entry.notes.filter(note => typeof note === 'string') : []
380}
381
382/** Sets one session's notes and drops the oldest sessions past the cap, so the store stays small. */
383export const withNotes = (book: unknown, sessionId: string, notes: readonly string[], at: number): NoteBook => {
384  const current: NoteBook = typeof book === 'object' && book !== null ? { ...(book as NoteBook) } : {}
385
386  if (notes.length === 0) {
387    delete current[sessionId]
388  } else {
389    current[sessionId] = { notes: [...notes], at }
390  }
391
392  const newest = Object.entries(current)
393    .sort(([, a], [, b]) => b.at - a.at)
394    .slice(0, MAX_SESSIONS_KEPT)
395
396  return Object.fromEntries(newest)
397}
398