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

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.
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.
| Where | What |
|---|---|
| Summarizer instructions | Appends 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 summary | Goal (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 |
| Archive | The 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 clear | Removes this session's notes. /keep alone lists them |
/kept | Prints the card as it would be now and the archive folder |
| Toast | compacted — 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.
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
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
archive | true | Write the markdown archive at each compaction |
archiveDir | empty | Archive root; ~ is your home. Empty means ~/.claude/compact-keeper |
maxPrompts | 20 | How many of your latest prompts the card carries; 0 keeps none |
instructions | empty | Extra text for the summarizer, added after the mod's own paragraph |
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.$.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.<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:
/keep the real one.TodoWrite.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 теряет цель, ограничения, решения и пути к файлам, а историю до сжатия не посмотреть. Мод чинит это изнутри самого сжатия:
/keep, изменённые файлы и открытые задачи;~/.claude/compact-keeper/./keep <заметка> закрепляет заметку на сессию, /kept показывает, что сохранится.
MIT
hooks/register.ts 168 lines1import 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}
168hooks/logic.ts 398 lines1import 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