Checkpoint notes and fresh context windows that continue from them, with searchable history of earlier windows

Keep working across fresh context windows. The agent saves checkpoint notes, starts a new window that contains only a pointer to those notes, and can still search what happened in earlier windows.
/plugin install notes-compaction --marketplace ramtinJ95/claude-mods
notes: list, read, search, write and append on virtual paths. write_file replaces; append_to_file adds.new_context: ends the turn once its tool batch has run, then opens a new context window with the agent name, window IDs and recent note paths, and tells the agent to continue.history: lists, searches and reads earlier windows of this session, or a subagent's transcript. Loaded on demand.get_context_remaining: remaining tokens in the current window. Loaded on demand.Tool calls with no arguments return help. The notes tool's description explains how to keep checkpoints, so a fresh session needs no extra instructions.
The main conversation is /root; a subagent is /root/<agentId>. Relative note paths belong to the calling agent. <agent>/notes/<path> reads another agent's notes in the same session, so a parent reads what its subagent saved and a subagent reads its parent's checkpoint. Writes stay in the caller's own notes. history takes the same names as agent_name. Only the main conversation starts new windows.
A new window replaces the conversation with one marker message; no summary is generated. The full transcript stays in the session, which history reads.
get_context_remaining measures against the same limit.new_context./compact asks the agent to save a checkpoint, then opens a new window only if that turn completed with a saved note. If the latest turn already saved notes and no instructions were given, it opens the new window at once. Instructions after /compact become checkpoint guidance.new_context, or the checkpoint turn, cancels the rollover and keeps the old context./notes opens a panel with continuity status and storage counts. Changes save immediately; with the panel focused (ctrl+x tab), the bracketed letters press its buttons.
n): off by default. On, each new window keeps Claude Code's summary ahead of the window message, and /compact is Claude Code's own.p, then y to confirm): removes stored notes and fork records of sessions whose Claude Code transcript no longer exists, and subagent notes no remaining session names. The active session stays. Uses the rm command.Notes belong to one session: resuming keeps them, /clear starts empty. A fork (--fork-session, /branch) starts with the notes as they stood at the fork point and continues the window chain; afterwards the fork and its parent write independently.
Everything is plaintext JSON under $XDG_DATA_HOME/claude-notes-compaction/, defaulting to ~/.local/share: sessions/<session id>/ holds the main conversation's notes and window state, agents/<agentId>/ a subagent's notes, and revisions/ one record per write, which forks rebuild from.
history reads the newest 4096 messages Claude Code keeps for the session and says so once a session is longer.
hooks/register.ts 764 lines1import type {
2 EngineInterface,
3 Register,
4 RenderElement,
5 SessionCompacted,
6 SessionMessage,
7 ToolCallInput,
8 ToolCallResult,
9} from 'claude-code'
10import { MESSAGE_LIMIT, useHistory } from './history.ts'
11import {
12 agentIdOf,
13 agentName,
14 type Note,
15 noteFileName,
16 ROOT,
17 recentPaths,
18 resolvePath,
19 useNotes,
20 WRITE_ACTIONS,
21} from './notes.ts'
22import {
23 GUIDANCE,
24 newState,
25 nextWindow,
26 reminderFor,
27 remindersDone,
28 type SessionState,
29 type WindowIdentity,
30 windowMarker,
31 windowText,
32} from './windows.ts'
33
34const NOTES_TOOL = 'mcp__notes-compaction__notes'
35
36type Namespace = 'notes' | 'history'
37interface ActionSpec {
38 fields: readonly string[]
39 required?: readonly string[]
40 /** The notes field that holds a virtual path. */
41 pathField?: string
42}
43/** Each tool's actions: the fields they take and which are required. Help, filtering and checks derive from it. */
44const ACTIONS: Record<Namespace, Record<string, ActionSpec>> = {
45 notes: {
46 list_files_by_prefix: { fields: ['prefix', 'file_order', 'file_order_by', 'max_results'], pathField: 'prefix' },
47 read_file: { fields: ['path', 'start_line', 'stop_line'], required: ['path'], pathField: 'path' },
48 search_contents: {
49 fields: ['query', 'path_prefix', 'max_files', 'max_matches_per_file', 'recent_file_first'],
50 required: ['query'],
51 pathField: 'path_prefix',
52 },
53 append_to_file: { fields: ['path', 'text'], required: ['path', 'text'], pathField: 'path' },
54 write_file: { fields: ['path', 'text'], required: ['path', 'text'], pathField: 'path' },
55 },
56 history: {
57 list_windows: { fields: ['agent_name', 'limit', 'recent_first'] },
58 list_items: {
59 fields: ['agent_name', 'window_id', 'role', 'tool_name', 'limit', 'max_chars_per_item', 'recent_first'],
60 },
61 read_item: {
62 fields: ['agent_name', 'window_id', 'item_id', 'offset_chars', 'limit_chars'],
63 required: ['window_id', 'item_id'],
64 },
65 search_contents: {
66 fields: ['agent_name', 'window_id', 'role', 'tool_name', 'query', 'limit', 'recent_first'],
67 required: ['query'],
68 },
69 },
70}
71const actionNames = (namespace: Namespace) => ['help', ...Object.keys(ACTIONS[namespace])]
72const STRING = { type: 'string' }
73const POSITIVE = { type: 'integer', minimum: 1 }
74const NOTES_SCHEMA = {
75 type: 'object',
76 properties: {
77 action: { type: 'string', enum: actionNames('notes') },
78 path: STRING,
79 text: STRING,
80 prefix: STRING,
81 query: STRING,
82 path_prefix: STRING,
83 start_line: POSITIVE,
84 stop_line: POSITIVE,
85 max_results: POSITIVE,
86 max_files: POSITIVE,
87 max_matches_per_file: POSITIVE,
88 file_order: { type: 'string', enum: ['ascending', 'descending'] },
89 file_order_by: { type: 'string', enum: ['name', 'created_at', 'updated_at'] },
90 recent_file_first: { type: 'boolean' },
91 },
92 additionalProperties: false,
93}
94const HISTORY_SCHEMA = {
95 type: 'object',
96 properties: {
97 action: { type: 'string', enum: actionNames('history') },
98 agent_name: STRING,
99 item_id: STRING,
100 window_id: STRING,
101 query: STRING,
102 limit: POSITIVE,
103 limit_chars: POSITIVE,
104 max_chars_per_item: POSITIVE,
105 offset_chars: { type: 'integer', minimum: 0 },
106 recent_first: { type: 'boolean' },
107 role: { type: 'string', enum: ['user', 'assistant', 'tool'] },
108 tool_name: STRING,
109 },
110 additionalProperties: false,
111}
112const EMPTY_SCHEMA = { type: 'object', properties: {}, additionalProperties: false }
113const TOOLS = [
114 {
115 name: 'notes',
116 // The description carries the checkpoint guidance: prompt events do not reach this mod (see AGENTS.md).
117 description: `Cross-window notes. write_file replaces; append_to_file adds. Virtual paths: relative uses current agent; cross-agent uses <agent>/notes[/path], read-only. Call with no arguments for help.\n\n${GUIDANCE}`,
118 inputSchema: NOTES_SCHEMA,
119 isDeferred: false,
120 },
121 {
122 name: 'history',
123 description: 'Prior-window detail. Pass IDs unchanged. Search, never browse. Call with no arguments for help.',
124 inputSchema: HISTORY_SCHEMA,
125 isDeferred: true,
126 },
127 { name: 'new_context', description: 'Start a new context window', inputSchema: EMPTY_SCHEMA, isDeferred: false },
128 { name: 'get_context_remaining', description: 'Remaining context tokens', inputSchema: EMPTY_SCHEMA, isDeferred: true },
129]
130
131/** The tool's own arguments: tool.call's input also carries reserved keys beside them. */
132function args(e: object, schema: { properties: object }): Record<string, unknown> {
133 const keys = Object.keys(schema.properties)
134 return Object.fromEntries(Object.entries(e).filter(([key]) => keys.includes(key)))
135}
136
137/** Help-backed tools: no arguments, or action help, lists each action's fields; optional ones end in `?`. */
138function discovery(namespace: Namespace, params: Record<string, unknown>) {
139 const keys = Object.keys(params)
140 if (keys.length && params['action'] !== 'help') return
141 if (keys.some((key) => key !== 'action')) throw new Error('Help accepts only action: help')
142 return {
143 actions: Object.fromEntries([
144 ['help', ''],
145 ...Object.entries(ACTIONS[namespace]).map(([action, spec]) => [
146 action,
147 spec.fields.map((field) => (spec.required?.includes(field) ? field : `${field}?`)).join(' '),
148 ]),
149 ]),
150 }
151}
152
153/** The action's own fields, with its required ones present. */
154function relevant(namespace: Namespace, params: Record<string, unknown>) {
155 const spec = ACTIONS[namespace][String(params['action'])]
156 if (!spec) throw new Error('Unknown action')
157 const missing = (spec.required ?? []).filter((field) => typeof params[field] !== 'string' || !params[field])
158 if (missing.length) throw new Error(`Supply ${missing.join(' and ')}`)
159 const scoped = Object.fromEntries(
160 Object.entries(params).filter(([key]) => key === 'action' || spec.fields.includes(key)),
161 )
162 return { scoped, spec }
163}
164
165const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
166const result = (payload: unknown) => ({ result: JSON.stringify(payload) })
167const failure = (error: unknown) => ({ deny: message(error) })
168
169const CHECKPOINT_PROMPT =
170 '<context_window_reminder>\nContext checkpoint requested. Save the current state with notes, then finish your response. The new window opens after this turn. Do not call new_context. If saving fails, report the failure without rolling over.\n</context_window_reminder>'
171
172/** One successful root write: root's whole note set after it, its window, and the session's subagents. */
173interface Revision {
174 /** The writing session, so /notes can prune its records with it. */
175 session?: string
176 notes: Note[]
177 window?: WindowIdentity
178 agents?: string[]
179}
180/** $.fs.read takes at most 4 MiB, so a revision must stay below it to be restorable. */
181const REVISION_LIMIT = 4_000_000
182
183// Module state restarts on reload; the window identity itself is persisted per session.
184let storage: Promise<string> | undefined
185let sessionId: string | undefined
186let loaded: { dir: string; promise: Promise<{ dir: string; state: SessionState }> } | undefined
187let turn: { id: string; notes: 'none' | 'saved' | 'failed' } | undefined
188/** The window whose latest completed turn saved fresh notes. */
189let freshWindowId: string | undefined
190/** At most one rollover at a time: requested by new_context, a /compact checkpoint, or the /compact opening it. */
191let pending: { kind: 'requested' } | { kind: 'checkpoint'; windowId: string } | { kind: 'rolling' } | undefined
192/**
193 * Where Claude Code compacts on its own (an auto-compact window such as 400k), refreshed each turn.
194 * Reminders and remaining context measure against it, so checkpoints come before that compaction.
195 */
196let compactThreshold: number | undefined
197/** Parallel calls in one batch would otherwise read the same note before either append lands. */
198let notesQueue: Promise<unknown> = Promise.resolve()
199/** The /notes setting: run Claude Code's summary before each new window, as Pi's normal compaction does. */
200let normalCompaction = false
201/** What the /notes pane shows; a change redraws it. */
202let panel: { status: string[]; confirmPrune: boolean; message?: string } = { status: [], confirmPrune: false }
203const PANE = 'notes-compaction'
204
205// The engine follows $ only into functions declared in this file, so all I/O lives here.
206
207/** Storage outside ~/.claude, which may be a synced dotfiles directory. */
208function storageRoot($: EngineInterface): Promise<string> {
209 storage ??= dataHome($)
210 return storage
211}
212
213async function dataHome($: EngineInterface): Promise<string> {
214 return `${(await $.env.get('XDG_DATA_HOME')) || `${await $.env.get('HOME')}/.local/share`}/claude-notes-compaction`
215}
216
217/** Where Claude Code keeps this profile's transcripts, one folder per project. */
218async function transcriptsDir($: EngineInterface): Promise<string> {
219 return `${(await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`}/projects`
220}
221
222async function revisionPath($: EngineInterface, toolUseId: string): Promise<string> {
223 return `${await storageRoot($)}/revisions/${toolUseId}.json`
224}
225
226const rootNotesDir = (sessionDir: string) => `${sessionDir}/notes`
227
228/**
229 * Root notes live with their session. A subagent's live under its agent ID, which is unique,
230 * so a fork that carries the Agent call reaches the same notes.
231 */
232async function notesDir($: EngineInterface, agent: string): Promise<string> {
233 const id = agentIdOf(agent)
234 return id === undefined ? rootNotesDir((await session($)).dir) : `${await storageRoot($)}/agents/${id}/notes`
235}
236
237/** The session's state, loaded once; concurrent first calls share the load, and a failed one is retried. */
238async function session($: EngineInterface) {
239 sessionId ??= await $.session.id()
240 const dir = `${await storageRoot($)}/sessions/${sessionId}`
241 if (loaded?.dir !== dir) {
242 const promise = loadSession($, dir)
243 loaded = { dir, promise }
244 promise.catch(() => {
245 if (loaded?.promise === promise) loaded = undefined
246 })
247 }
248 return loaded.promise
249}
250
251/**
252 * A session without state is new, or a fork (`--fork-session`, `/branch`) whose transcript was
253 * copied from its parent. Like Pi's receipts, each successful root write saves a revision under its
254 * tool use ID, so the last write the copied transcript shows restores the notes as they stood at
255 * the fork point. A window marker after that write wins for the window identity.
256 */
257async function loadSession($: EngineInterface, dir: string) {
258 const path = `${dir}/state.json`
259 if (await $.fs.exists(path)) return { dir, state: JSON.parse(await $.fs.read(path)) as SessionState }
260 const messages = await $.session.messages()
261 const markers = messages.map(windowMarker)
262 const state = newState()
263 state.projects = await transcriptsDir($)
264 // A fork inherits the subagents its copied transcript launched, with their notes.
265 const launched = messages.flatMap((m) => m.toolUses.flatMap((use) => (use.agentId ? [agentName(use.agentId)] : [])))
266 if (launched.length) state.agents = [...new Set(launched)]
267 let restored: Revision | undefined
268 let restoredFrom: string | undefined
269 let markerWindow: WindowIdentity | undefined
270 let sawWrite = false
271 for (let i = messages.length - 1; i >= 0 && !restored; i--) {
272 const marker = markers[i]
273 if (marker && !markerWindow)
274 markerWindow = {
275 firstWindowId: marker.first ?? marker.current,
276 currentWindowId: marker.current,
277 ...(marker.previous ? { previousWindowId: marker.previous } : {}),
278 windowNumber: markers.filter(Boolean).length,
279 }
280 for (const use of [...messages[i]!.toolUses].reverse()) {
281 if (use.tool !== NOTES_TOOL || use.isError || !WRITE_ACTIONS.includes(use.input['action'])) continue
282 sawWrite = true
283 const file = await revisionPath($, use.tool_use_id)
284 if (!(await $.fs.exists(file))) continue
285 restored = JSON.parse(await $.fs.read(file)) as Revision
286 restoredFrom = use.tool_use_id
287 break
288 }
289 }
290 state.window = markerWindow ?? restored?.window ?? state.window
291 if (restored) {
292 await Promise.all(restored.notes.map((note) => writeNote($, rootNotesDir(dir), note)))
293 state.agents = [...new Set([...(state.agents ?? []), ...(restored.agents ?? [])])]
294 state.restoredFrom = restoredFrom
295 } else if (sawWrite || messages.length >= MESSAGE_LIMIT)
296 $.ui.toast("This session's earlier notes could not be restored. Notes start empty; earlier history remains.")
297 // Persist before use: history and notes may cite the first window's ID before anything else is saved.
298 await saveState($, dir, state)
299 return { dir, state }
300}
301
302async function saveState($: EngineInterface, dir: string, state: SessionState) {
303 await $.fs.write(`${dir}/state.json`, JSON.stringify(state))
304}
305
306async function writeNote($: EngineInterface, dir: string, note: Note) {
307 await $.fs.write(`${dir}/${await noteFileName(note.path)}`, JSON.stringify(note))
308}
309
310async function readNotes($: EngineInterface, dir: string): Promise<Map<string, Note>> {
311 if (!(await $.fs.exists(dir))) return new Map()
312 const files = (await $.fs.list(dir)).filter((entry) => entry.kind === 'file' && entry.name.endsWith('.json'))
313 const notes = await Promise.all(files.map(async (entry) => JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Note))
314 return new Map(notes.map((note) => [note.path, note]))
315}
316
317/** One note, for a read: its own file instead of the whole set. */
318async function readNote($: EngineInterface, dir: string, path: string): Promise<Map<string, Note>> {
319 const file = `${dir}/${await noteFileName(path)}`
320 if (!(await $.fs.exists(file))) return new Map()
321 const note = JSON.parse(await $.fs.read(file)) as Note
322 return new Map([[note.path, note]])
323}
324
325/**
326 * Whether a note path may name this agent: root, the caller, subagents that wrote notes here, or
327 * a subagent the transcript in view launched.
328 */
329async function isSessionAgent($: EngineInterface, self: string, agent: string): Promise<boolean> {
330 if (agent === ROOT || agent === self || (await session($)).state.agents?.includes(agent)) return true
331 return (await $.session.messages()).some((m) => m.toolUses.some((use) => use.agentId && agentName(use.agentId) === agent))
332}
333
334/**
335 * The window's opening message, after `kept` (normal compaction's summary, or nothing for a
336 * notes-only window); the conversation continues once it stands.
337 */
338async function openWindow($: EngineInterface, kept: readonly SessionMessage[] = []): Promise<SessionCompacted> {
339 const { dir, state } = await session($)
340 const ids = nextWindow(state.window)
341 const recent = recentPaths(await readNotes($, rootNotesDir(dir)), ROOT)
342 state.window = ids
343 state.reminded = []
344 await saveState($, dir, state)
345 $.clock.after(100, () => $.prompt.submit({ text: 'Continue.' }))
346 return {
347 messages: [
348 ...kept,
349 { role: 'user', text: windowText(ROOT, ids, recent), toolUses: [] },
350 { role: 'assistant', text: 'New context window opened.', toolUses: [] },
351 ],
352 }
353}
354
355/** Rollover runs between turns through /compact: the one route on which our session.compact hook answers. */
356function roll($: EngineInterface) {
357 pending = { kind: 'rolling' }
358 $.clock.after(100, async () => {
359 try {
360 await $.command.run({ command: 'compact' })
361 } catch (error) {
362 $.ui.toast(`Context rollover failed: ${message(error)}. The existing context remains.`)
363 } finally {
364 pending = undefined
365 }
366 })
367}
368
369async function refreshThreshold($: EngineInterface) {
370 const breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
371 compactThreshold = breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
372}
373
374async function contextUsed($: EngineInterface): Promise<{ limit: number; tokens?: number }> {
375 const { context } = await $.session.usage()
376 return { limit: Math.min(context.window, compactThreshold ?? context.window), tokens: context.tokens }
377}
378
379/**
380 * Characters of tool results since the model's last request. Measured usage stops at that request,
381 * so a large batch (several file reads) would otherwise cross the limit unseen.
382 */
383let batchChars = 0
384/** A rough, early-leaning conversion for code and prose alike. */
385const CHARS_PER_TOKEN = 3
386
387/** Context reminders ride on tool results, the one mid-turn channel to the model. Each fires once per window. */
388async function remind($: EngineInterface, agentId: string | undefined, called: ToolCallResult): Promise<ToolCallResult> {
389 if (agentId !== undefined || called.deny !== undefined) return called
390 batchChars += (called.text ?? (typeof called.result === 'string' ? called.result : JSON.stringify(called.result ?? ''))).length
391 try {
392 const { dir, state } = await session($)
393 if (remindersDone(state)) return called
394 const { limit, tokens } = await contextUsed($)
395 const estimate = tokens === undefined ? undefined : tokens + batchChars / CHARS_PER_TOKEN
396 const reminder = reminderFor(state, estimate === undefined || limit <= 0 ? undefined : (100 * estimate) / limit)
397 if (!reminder) return called
398 await saveState($, dir, state)
399 return { ...called, context: [...(called.context ?? []), reminder] }
400 } catch {
401 return called
402 }
403}
404
405function callNotes($: EngineInterface, e: ToolCallInput): Promise<ToolCallResult> {
406 const run = notesQueue.then(() => runNotes($, e))
407 notesQueue = run.catch(() => undefined)
408 return run
409}
410
411async function runNotes($: EngineInterface, e: ToolCallInput): Promise<ToolCallResult> {
412 const params = args(e, NOTES_SCHEMA)
413 const isWrite = WRITE_ACTIONS.includes(params['action'])
414 const main = e.agentId === undefined
415 try {
416 const help = discovery('notes', params)
417 if (help) return result(help)
418 const { scoped, spec } = relevant('notes', params)
419 const self = agentName(e.agentId)
420 const field = spec.pathField!
421 const target = resolvePath(scoped[field], self, spec.required?.includes(field) ?? false)
422 if (isWrite && target.agent !== self)
423 throw new Error(`Write only your own notes, with a relative path. ${target.agent}'s notes are read-only here.`)
424 if (!(await isSessionAgent($, self, target.agent)))
425 throw new Error(`${target.agent} is not an agent of this session. Use a relative path for your own notes.`)
426 const dir = await notesDir($, target.agent)
427 const notes = params['action'] === 'read_file' ? await readNote($, dir, target.path) : await readNotes($, dir)
428 const { payload, write } = useNotes(notes, { ...scoped, [field]: target.path }, await $.clock.now(), target.agent)
429 if (!write) return result(payload)
430 const { dir: sessionDir, state } = await session($)
431 // The revision is the receipt a fork rebuilds root notes from. Subagent notes need none: they
432 // live under the agent ID, which a fork shares. Its size is checked before anything changes.
433 notes.set(write.path, write)
434 const revision =
435 e.tool_use_id && main
436 ? JSON.stringify({
437 session: sessionId,
438 notes: [...notes.values()],
439 window: state.window,
440 agents: state.agents,
441 } satisfies Revision)
442 : undefined
443 if (revision && new TextEncoder().encode(revision).length > REVISION_LIMIT)
444 throw new Error('Notes exceed 4 MB in total. Shorten or replace existing notes, then save again.')
445 try {
446 await writeNote($, dir, write)
447 } catch {
448 throw new Error('Note could not be saved. Retry after local storage is available.')
449 }
450 if (revision)
451 try {
452 await $.fs.write(await revisionPath($, e.tool_use_id!), revision)
453 } catch {
454 throw new Error(
455 `${payload['path']} was saved, but forks of this session will not see it. Read it back and save it again with write_file.`,
456 )
457 }
458 if (!main && !state.agents?.includes(self)) {
459 state.agents = [...(state.agents ?? []), self]
460 await saveState($, sessionDir, state)
461 }
462 if (main && turn?.notes === 'none') turn.notes = 'saved'
463 return result(payload)
464 } catch (error) {
465 if (isWrite && main && turn) turn.notes = 'failed'
466 return failure(error)
467 }
468}
469
470async function callHistory($: EngineInterface, e: ToolCallInput): Promise<ToolCallResult> {
471 const params = args(e, HISTORY_SCHEMA)
472 try {
473 const help = discovery('history', params)
474 if (help) return result(help)
475 const { scoped } = relevant('history', params)
476 const name = scoped['agent_name']
477 const id = agentIdOf(typeof name === 'string' && name ? name : agentName(e.agentId))
478 if (id === undefined) {
479 const { state } = await session($)
480 return result(useHistory(await $.session.messages(), scoped, state.window.currentWindowId))
481 }
482 // A subagent's transcript is its own single window.
483 const found = await $.session.messages({ agentId: id })
484 if ('deny' in found)
485 throw new Error(`${agentName(id)}'s history is not available here. Omit agent_name to search your own.`)
486 return result(useHistory(found, scoped, `agent:${id}`))
487 } catch (error) {
488 return failure(error)
489 }
490}
491
492function showPanel($: EngineInterface, change: Partial<typeof panel>) {
493 panel = { ...panel, ...change }
494 $.ui.invalidate('ui.render')
495}
496
497async function childNames($: EngineInterface, dir: string, kind: 'file' | 'dir'): Promise<string[]> {
498 if (!(await $.fs.exists(dir))) return []
499 return (await $.fs.list(dir)).filter((entry) => entry.kind === kind).map((entry) => entry.name)
500}
501
502async function refreshPanel($: EngineInterface) {
503 const base = await storageRoot($)
504 const { dir, state } = await session($)
505 const notes = (await childNames($, rootNotesDir(dir), 'file')).length
506 const sessions = (await childNames($, `${base}/sessions`, 'dir')).length
507 const records = (await childNames($, `${base}/revisions`, 'file')).length
508 const { limit } = await contextUsed($)
509 showPanel($, {
510 status: [
511 `Continuity active · window ${state.window.windowNumber + 1} · ${notes} notes in this session`,
512 `Reminders at 85% and 90% of ${limit} tokens (${compactThreshold ? 'the auto-compact threshold' : 'the model window'})`,
513 `${sessions} sessions · ${records} fork records`,
514 ],
515 })
516}
517
518async function toggleNormalCompaction($: EngineInterface) {
519 normalCompaction = !normalCompaction
520 await $.store.set('settings', { normalCompaction })
521 showPanel($, { message: undefined })
522}
523
524/** A session this recently active stays: one that saves no transcript may still be running. */
525const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000
526
527/**
528 * Removes stored sessions of this profile whose Claude Code transcript is gone, with their fork
529 * records and the subagent notes no remaining session names. The active session, recently active
530 * ones, other profiles' sessions and records a remaining fork restored from stay.
531 */
532async function prune($: EngineInterface): Promise<string> {
533 const base = await storageRoot($)
534 const projects = await transcriptsDir($)
535 if (!(await $.fs.exists(projects))) return `No Claude Code transcripts found at ${projects}. Nothing was pruned.`
536 const live = new Set([sessionId])
537 for (const project of await childNames($, projects, 'dir'))
538 for (const file of await childNames($, `${projects}/${project}`, 'file'))
539 if (file.endsWith('.jsonl')) live.add(file.slice(0, -'.jsonl'.length))
540 const now = await $.clock.now()
541 const removed = new Set<string>()
542 const kept: SessionState[] = []
543 let retained = 0
544 for (const id of await childNames($, `${base}/sessions`, 'dir')) {
545 const dir = `${base}/sessions/${id}`
546 const entries = await $.fs.list(dir)
547 const stateFile = entries.find((entry) => entry.name === 'state.json')
548 const state = stateFile ? (JSON.parse(await $.fs.read(`${dir}/state.json`)) as SessionState) : undefined
549 const recent = entries.some((entry) => now - entry.mtimeMs < PRUNE_GRACE_MS)
550 if (live.has(id) || !state || state.projects !== projects || recent) {
551 if (state) kept.push(state)
552 if (!live.has(id)) retained++
553 } else removed.add(id)
554 }
555 const keptAgents = new Set(kept.flatMap((state) => state.agents ?? []))
556 const keptRecords = new Set(kept.flatMap((state) => (state.restoredFrom ? [`${state.restoredFrom}.json`] : [])))
557 const records: string[] = []
558 for (const file of await childNames($, `${base}/revisions`, 'file')) {
559 if (keptRecords.has(file)) continue
560 const owner = (JSON.parse(await $.fs.read(`${base}/revisions/${file}`)) as Revision).session
561 if (owner && removed.has(owner)) records.push(`${base}/revisions/${file}`)
562 }
563 const agents = (await childNames($, `${base}/agents`, 'dir')).filter((id) => !keptAgents.has(agentName(id)))
564 const paths = [
565 ...[...removed].map((id) => `${base}/sessions/${id}`),
566 ...records,
567 ...agents.map((id) => `${base}/agents/${id}`),
568 ]
569 const retainedNote = retained
570 ? ` Retained ${retained} sessions without a transcript that are recent, from another profile or of unknown origin.`
571 : ''
572 if (!paths.length) return `Nothing to prune.${retainedNote}`
573 for (let i = 0; i < paths.length; i += 200) {
574 let run: { exitCode: number; stderr: string }
575 try {
576 run = await $.process.run(['rm', '-rf', ...paths.slice(i, i + 200)])
577 } catch (error) {
578 return `Prune needs the rm command, which could not run: ${message(error)}. Nothing further was removed.`
579 }
580 if (run.exitCode !== 0) return `Prune stopped: ${run.stderr.trim() || `rm exited ${run.exitCode}`}.`
581 }
582 return `Pruned ${removed.size} sessions, ${records.length} fork records and ${agents.length} subagent note sets.${retainedNote}`
583}
584
585async function confirmPrune($: EngineInterface) {
586 showPanel($, { confirmPrune: false, message: 'Pruning…' })
587 let outcome: string
588 try {
589 outcome = await prune($)
590 } catch (error) {
591 outcome = `Prune failed: ${message(error)}`
592 }
593 showPanel($, { message: outcome })
594 await refreshPanel($)
595}
596
597export const register: Register = (on) => {
598 // First registered is outermost: this wraps every tool's result, the plugin's own included.
599 on('tool.call', async ($, e, next) => remind($, e.agentId, await next(e)))
600
601 on('session.start', async ($, e, next) => {
602 const started = await next(e)
603 const settings = (await $.store.get('settings')) as { normalCompaction?: boolean } | undefined
604 normalCompaction = settings?.normalCompaction === true
605 for (const tool of TOOLS) await $.tool.register(tool)
606 await $.command.register({ name: 'notes', description: 'Notes continuity status and settings' })
607 return started
608 })
609
610 // After new_context, the turn ends at its next model request, once the whole tool batch ran,
611 // as Pi's new_context ends the run; the rollover then follows the turn's completion.
612 // A note that failed this turn keeps the turn going, so the model sees the error; the rollover is then cancelled.
613 on('turn.step', async function* ($, e, next) {
614 if (e.agentId === undefined) batchChars = 0
615 if (e.agentId !== undefined || pending?.kind !== 'requested' || turn?.notes === 'failed') return yield* next(e)
616 const answer = 'Starting a new context window.'
617 yield { kind: 'text', index: 0, text: answer }
618 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
619 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: null }
620 })
621
622 // /clear ends this session in the same process, under a new ID.
623 on('session.end', async ($, e, next) => {
624 sessionId = loaded = turn = freshWindowId = pending = undefined
625 return next(e)
626 })
627
628 on('turn.start', async ($, e, next) => {
629 turn = { id: e.turnId, notes: 'none' }
630 // /config and /model can move the threshold between turns; the previous figure stands meanwhile.
631 refreshThreshold($).catch(() => undefined)
632 return next(e)
633 })
634
635 on('turn.complete', async ($, e, next) => {
636 const completed = await next(e)
637 if (e.agentId !== undefined) return completed
638 const windowId = (await session($)).state.window.currentWindowId
639 const fresh = e.reason === 'answer' && turn?.id === e.turnId && turn.notes === 'saved'
640 freshWindowId = fresh ? windowId : undefined
641 const was = pending
642 if (was?.kind === 'checkpoint') {
643 pending = undefined
644 if (e.isAborted || was.windowId !== windowId) $.ui.toast('Checkpoint cancelled. The old context remains.')
645 else if (fresh) roll($)
646 else
647 $.ui.toast(
648 'Rollover did not start. No fresh note was saved in the completed checkpoint. The old context remains.',
649 )
650 } else if (was?.kind === 'requested') {
651 pending = undefined
652 if (e.isAborted) $.ui.toast('Rollover cancelled by the interruption. The old context remains.')
653 else if (turn?.id === e.turnId && turn.notes === 'failed')
654 $.ui.toast('Rollover did not start: a note failed to save this turn. The old context remains.')
655 else roll($)
656 }
657 return completed
658 })
659
660 on('session.compact', async ($, e, next) => {
661 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
662 if (pending?.kind === 'rolling') {
663 if (!normalCompaction) return openWindow($)
664 const compacted = await next(e)
665 return compacted.skip ? compacted : openWindow($, compacted.messages)
666 }
667 // With normal compaction on, /compact is Claude Code's own, as in Pi.
668 if (e.trigger === 'manual' && !normalCompaction) {
669 if (pending) return { skip: 'A notes checkpoint or rollover is already pending.' }
670 const windowId = (await session($)).state.window.currentWindowId
671 const instructions = e.instructions?.trim()
672 if (!instructions && freshWindowId === windowId) return openWindow($)
673 pending = { kind: 'checkpoint', windowId }
674 const guidance = instructions ? `\n\nCheckpoint guidance:\n${e.instructions}` : ''
675 $.clock.after(100, () => $.prompt.submit({ text: CHECKPOINT_PROMPT + guidance }))
676 return { skip: 'Saving a notes checkpoint first. The new window opens after it completes.' }
677 }
678 // Other compaction stays Claude Code's own; reminders restart in the compacted context.
679 const compacted = await next(e)
680 if (!compacted.skip) {
681 const { dir, state } = await session($)
682 state.reminded = []
683 await saveState($, dir, state)
684 }
685 return compacted
686 }).catch(() => ({ skip: 'Context rollover failed. The existing context remains.' }))
687
688 on('tool.call', { tool: 'mcp__notes-compaction__notes' }, ($, e) => callNotes($, e))
689
690 on('tool.call', { tool: 'mcp__notes-compaction__history' }, ($, e) => callHistory($, e))
691
692 on('tool.call', { tool: 'mcp__notes-compaction__new_context' }, async ($, e) => {
693 if (e.agentId !== undefined) return { deny: 'Only the main conversation can start a new context window.' }
694 if (pending)
695 return result({
696 started: false,
697 message: 'A checkpoint or rollover is already pending. Save notes and finish your response',
698 })
699 pending = { kind: 'requested' }
700 return result({
701 started: true,
702 message: normalCompaction
703 ? 'Normal compaction will run before the new window opens'
704 : 'A new window will continue from your notes after this turn',
705 })
706 })
707
708 on('command.run', { command: 'notes' }, async ($, e) => {
709 if (e.args.trim()) return { text: 'Use /notes to open the notes panel.' }
710 showPanel($, { confirmPrune: false, message: undefined })
711 await refreshPanel($)
712 await $.ui.open({ id: PANE, title: 'Notes continuity' })
713 return { text: 'Notes panel opened.' }
714 })
715
716 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
717 const { Box, Text, Button } = $.ui.resolve(e)
718 const prune = panel.confirmPrune
719 ? h(
720 Box,
721 { flexDirection: 'row', gap: 1 },
722 h(
723 Button,
724 { key: 'confirm', hotkey: 'y', variant: 'primary', onPress: () => void confirmPrune($) },
725 'Confirm prune',
726 ),
727 h(Button, { key: 'cancel', hotkey: 'c', onPress: () => showPanel($, { confirmPrune: false }) }, 'Cancel'),
728 )
729 : h(
730 Button,
731 { key: 'prune', hotkey: 'p', onPress: () => showPanel($, { confirmPrune: true, message: undefined }) },
732 'Prune missing sessions',
733 )
734 return h(
735 Box,
736 { flexDirection: 'column' },
737 ...panel.status.map((line) => h(Text, null, line)),
738 h(
739 Button,
740 { key: 'normal', hotkey: 'n', onPress: () => void toggleNormalCompaction($) },
741 `Normal compaction before new windows: ${normalCompaction ? 'on' : 'off'}`,
742 ),
743 prune,
744 panel.confirmPrune &&
745 h(Text, { dimColor: true }, 'Removes stored notes of sessions whose Claude Code transcript no longer exists.'),
746 panel.message && h(Text, { dimColor: true }, panel.message),
747 h(Button, { key: 'close', hotkey: 'x', role: 'dismiss', onPress: () => void $.ui.close({ id: PANE }) }, 'Close'),
748 ) as RenderElement
749 })
750
751 on('tool.call', { tool: 'mcp__notes-compaction__get_context_remaining' }, async ($, e) => {
752 if (e.agentId !== undefined) return { deny: 'Context usage is reported for the main conversation only.' }
753 const { state } = await session($)
754 const { limit, tokens } = await contextUsed($)
755 const remainingTokens = typeof tokens === 'number' && limit > 0 ? Math.max(0, limit - tokens) : null
756 return result({
757 contextWindow: limit,
758 windowId: state.window.currentWindowId,
759 remainingTokens,
760 remainingPercent: remainingTokens === null ? null : Math.round((remainingTokens / limit) * 1000) / 10,
761 })
762 })
763}
764hooks/history.ts 146 lines1import type { SessionMessage } from 'claude-code'
2import { integer } from './notes.ts'
3import { windowMarker } from './windows.ts'
4
5interface HistoryItem {
6 window_id: string
7 item_id: string
8 role: 'user' | 'assistant' | 'tool'
9 tool_name?: string
10 content: string
11}
12
13/** Characters one read or listing answers with. */
14const RESULT_LIMIT = 8000
15
16/** FNV-1a: a short, synchronous content key. */
17function hash(text: string): string {
18 let h = 0x811c9dc5
19 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
20 return (h >>> 0).toString(16).padStart(8, '0')
21}
22
23/** $.session.messages() answers the newest 4096 messages; past that, positions shift and old rows drop. */
24export const MESSAGE_LIMIT = 4096
25
26/**
27 * The session's own transcript is the archive: compaction replaces what the model reads,
28 * not what $.session.messages() returns. Item IDs survive the sliding message limit:
29 * tool use IDs where a row has one, otherwise a content key.
30 */
31export function useHistory(
32 messages: readonly SessionMessage[],
33 params: Record<string, unknown>,
34 currentWindowId: string,
35): Record<string, unknown> {
36 const coverage =
37 messages.length >= MESSAGE_LIMIT ? { coverage: `Newest ${MESSAGE_LIMIT} messages only; older rows are unavailable` } : {}
38 return { ...answer(messages, params, currentWindowId), ...coverage }
39}
40
41function answer(
42 messages: readonly SessionMessage[],
43 params: Record<string, unknown>,
44 currentWindowId: string,
45): Record<string, unknown> {
46 // Duplicate content is numbered within its window, so one window's rows never renumber another's.
47 const seen = new Map<string, number>()
48 const stableId = (key: string) => {
49 const n = seen.get(`${windowId}:${key}`) ?? 0
50 seen.set(`${windowId}:${key}`, n + 1)
51 return n ? `${key}~${n}` : key
52 }
53 // Rows before the first boundary in view belong to the window it names as previous; with no
54 // boundary in view, every row belongs to the current window.
55 const markers = messages.map(windowMarker)
56 const first = markers.find((marker) => marker !== undefined)
57 let windowId = first ? (first.previous ?? currentWindowId) : currentWindowId
58 const windows = new Map<string, HistoryItem[]>([[windowId, []]])
59 const toolNames = new Map<string, string>()
60 for (const [index, message] of messages.entries()) {
61 const boundary = markers[index]
62 if (boundary) {
63 windowId = boundary.current
64 if (!windows.has(windowId)) windows.set(windowId, [])
65 continue
66 }
67 const items = windows.get(windowId)!
68 if (message.role === 'assistant') {
69 for (const use of message.toolUses) toolNames.set(use.tool_use_id, use.tool)
70 const calls = message.toolUses.map((use) => JSON.stringify({ tool: use.tool, arguments: use.input }))
71 const content = [message.text, ...calls].filter(Boolean).join('\n')
72 items.push({
73 window_id: windowId,
74 item_id: stableId(message.toolUses[0] ? `call-${message.toolUses[0].tool_use_id}` : `a-${hash(content)}`),
75 role: 'assistant',
76 ...(message.toolUses[0] ? { tool_name: message.toolUses[0].tool } : {}),
77 content,
78 })
79 continue
80 }
81 if (message.text)
82 items.push({ window_id: windowId, item_id: stableId(`u-${hash(message.text)}`), role: 'user', content: message.text })
83 for (const result of message.toolResults ?? []) {
84 const tool = toolNames.get(result.tool_use_id)
85 items.push({
86 window_id: windowId,
87 item_id: stableId(`result-${result.tool_use_id}`),
88 role: 'tool',
89 ...(tool ? { tool_name: tool } : {}),
90 content: result.text,
91 })
92 }
93 }
94
95 const action = params['action']
96 if (action === 'list_windows') {
97 let values = [...windows].filter(([, items]) => items.length)
98 if (params['recent_first'] === true) values = values.reverse()
99 const limit = integer(params['limit'], 20, 100)
100 return {
101 windows: values.slice(0, limit).map(([id, items]) => ({ window_id: id, item_count: items.length })),
102 truncated: values.length > limit,
103 }
104 }
105 if (action === 'read_item') {
106 const item = windows.get(String(params['window_id']))?.find((item) => item.item_id === params['item_id'])
107 if (!item) return { item: null }
108 const offset = integer(params['offset_chars'], 0, item.content.length, 0)
109 const limit = integer(params['limit_chars'], RESULT_LIMIT, RESULT_LIMIT)
110 const content = item.content.slice(offset, offset + limit)
111 return {
112 item: {
113 ...item,
114 content,
115 total_chars: item.content.length,
116 ...(offset + content.length < item.content.length ? { next_offset_chars: offset + content.length } : {}),
117 },
118 }
119 }
120 // list_items or search_contents
121 const query = String(params['query'])
122 let items = [...windows.values()]
123 .flat()
124 .filter(
125 (item) =>
126 (!params['window_id'] || item.window_id === params['window_id']) &&
127 (!params['role'] || item.role === params['role']) &&
128 (!params['tool_name'] || item.tool_name === params['tool_name']) &&
129 (action !== 'search_contents' || item.content.includes(query)),
130 )
131 if (params['recent_first'] === true) items = items.reverse()
132 const limit = integer(params['limit'], 10, 25)
133 const previewChars = integer(params['max_chars_per_item'], 1000, 1000)
134 const previews = []
135 let size = 0
136 for (const item of items) {
137 const { content, ...metadata } = item
138 const preview = { ...metadata, truncated_content: content.slice(0, previewChars), content_chars: content.length }
139 const chars = JSON.stringify(preview).length
140 if (previews.length >= limit || size + chars > RESULT_LIMIT) break
141 previews.push(preview)
142 size += chars
143 }
144 return { items: previews, truncated: previews.length < items.length }
145}
146hooks/notes.ts 174 lines1export interface Note {
2 path: string
3 text: string
4 createdAt: number
5 updatedAt: number
6}
7
8/** The main conversation's agent name; a subagent is `/root/<agentId>`. */
9export const ROOT = '/root'
10const AGENT_ID = '[A-Za-z0-9_-]+'
11
12export const WRITE_ACTIONS: readonly unknown[] = ['write_file', 'append_to_file']
13
14export function agentName(agentId: string | undefined): string {
15 return agentId === undefined ? ROOT : `${ROOT}/${agentId}`
16}
17
18/** agentName's inverse: undefined for root, the subagent's ID otherwise. */
19export function agentIdOf(name: string): string | undefined {
20 if (name === ROOT) return undefined
21 const id = name.slice(ROOT.length + 1)
22 if (!name.startsWith(`${ROOT}/`) || !new RegExp(`^${AGENT_ID}$`).test(id))
23 throw new Error(`Name agents as note paths do: ${ROOT} or ${ROOT}/<agentId>.`)
24 return id
25}
26
27export const notesPrefix = (agent: string) => `${agent}/notes/`
28
29/** Hashed file names keep long virtual paths inside file-name limits. */
30export async function noteFileName(path: string): Promise<string> {
31 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(path))
32 return `${[...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')}.json`
33}
34
35export function recentPaths(notes: ReadonlyMap<string, Note>, agent: string): string[] {
36 return [...notes.values()]
37 .sort((a, b) => b.updatedAt - a.updatedAt)
38 .slice(0, 5)
39 .map((note) => notesPrefix(agent) + note.path)
40}
41
42/**
43 * Virtual paths: relative belongs to the calling agent; `<agent>/notes[/path]` names any agent
44 * of this session. Root wins the ambiguity, so `/root/notes/notes/x` is root's `notes/x`.
45 */
46export function resolvePath(path: unknown, self: string, required: boolean): { agent: string; path: string } {
47 if (typeof path !== 'string' || !path.trim()) {
48 if (required) throw new Error('Supply a note path')
49 return { agent: self, path: '' }
50 }
51 let agent = self
52 let value = path
53 if (path.startsWith('/')) {
54 const match = new RegExp(`^(${ROOT}(?:/${AGENT_ID})??)/notes(?:/(.*))?$`).exec(path)
55 if (!match) throw new Error("Use a relative path for your own notes, or <agent>/notes/<path> for another agent's")
56 agent = match[1]!
57 value = match[2] ?? ''
58 }
59 if (
60 (required && !value) ||
61 value.split('/').some((part) => part === '..' || part === '.') ||
62 /[\0\r\n]/.test(value)
63 )
64 throw new Error('Use a virtual note path without dot segments or control characters')
65 return { agent, path: value }
66}
67
68export function integer(value: unknown, fallback: number, max: number, min = 1): number {
69 return typeof value === 'number' && Number.isInteger(value) ? Math.max(min, Math.min(value, max)) : fallback
70}
71
72/** Characters one read or search answers with. */
73const RESULT_LIMIT = 32_000
74
75/**
76 * Runs one action on one agent's notes. Required fields are present (the caller checks them) and
77 * path fields arrive resolved relative to `agent`, whose `<agent>/notes/` prefixes every path shown.
78 * Returns the action's JSON payload, and for a write the note the caller must persist.
79 */
80export function useNotes(
81 notes: ReadonlyMap<string, Note>,
82 params: Record<string, unknown>,
83 now: number,
84 agent: string,
85): { payload: Record<string, unknown>; write?: Note } {
86 const root = notesPrefix(agent)
87 const action = params['action']
88 if (WRITE_ACTIONS.includes(action)) {
89 const path = String(params['path'])
90 const previous = notes.get(path)
91 const next = action === 'append_to_file' ? (previous?.text ?? '') + String(params['text']) : String(params['text'])
92 if (new TextEncoder().encode(next).length > 1_000_000)
93 throw new Error('Note exceeds 1 MB. Split it into separate paths.')
94 return {
95 payload: { path: root + path, chars: next.length },
96 write: { path, text: next, createdAt: previous?.createdAt ?? now, updatedAt: now },
97 }
98 }
99 if (action === 'read_file') {
100 const path = String(params['path'])
101 const note = notes.get(path)
102 if (!note) return { payload: { path: root + path, file: null } }
103 const lines = note.text.split('\n')
104 const start = integer(params['start_line'], 1, lines.length + 1)
105 const stop = integer(params['stop_line'], lines.length, lines.length)
106 let text = ''
107 let next = start
108 while (next <= stop) {
109 const line = lines[next - 1] ?? ''
110 if (text.length + line.length > RESULT_LIMIT) {
111 if (!text)
112 throw new Error(`This note line exceeds ${RESULT_LIMIT} characters. Rewrite it as shorter lines to read it.`)
113 break
114 }
115 text += `${next > start ? '\n' : ''}${line}`
116 next++
117 }
118 return {
119 payload: {
120 path: root + path,
121 text,
122 start_line: start,
123 total_lines: lines.length,
124 ...(next <= stop ? { next_start_line: next } : {}),
125 },
126 }
127 }
128 if (action === 'list_files_by_prefix') {
129 const prefix = String(params['prefix'] ?? '')
130 const files = [...notes.values()].filter((note) => note.path.startsWith(prefix))
131 const by = params['file_order_by']
132 files.sort((a, b) =>
133 by === 'created_at'
134 ? a.createdAt - b.createdAt
135 : by === 'updated_at'
136 ? a.updatedAt - b.updatedAt
137 : a.path.localeCompare(b.path),
138 )
139 if (params['file_order'] === 'descending') files.reverse()
140 const maximum = integer(params['max_results'], 100, 100)
141 return {
142 payload: {
143 files: files
144 .slice(0, maximum)
145 .map((note) => ({ path: root + note.path, chars: note.text.length, updated_at: note.updatedAt })),
146 truncated: files.length > maximum,
147 },
148 }
149 }
150 // search_contents
151 const query = String(params['query'])
152 const prefix = String(params['path_prefix'] ?? '')
153 const files = [...notes.values()].filter((note) => note.path.startsWith(prefix))
154 if (params['recent_file_first'] === true) files.sort((a, b) => b.updatedAt - a.updatedAt)
155 const found: { path: string; matches: { line: number; text: string }[]; truncated: boolean }[] = []
156 let chars = 0
157 let truncated = false
158 for (const note of files) {
159 const all = note.text
160 .split('\n')
161 .flatMap((line, i) => (line.includes(query) ? [{ line: i + 1, text: line.slice(0, 1000) }] : []))
162 if (!all.length) continue
163 const matches = all.slice(0, integer(params['max_matches_per_file'], 10, 100))
164 const size = JSON.stringify(matches).length
165 if (found.length >= integer(params['max_files'], 20, 100) || chars + size > RESULT_LIMIT) {
166 truncated = true
167 break
168 }
169 found.push({ path: root + note.path, matches, truncated: matches.length < all.length })
170 chars += size
171 }
172 return { payload: { files: found, truncated } }
173}
174hooks/windows.ts 82 lines1export interface WindowIdentity {
2 firstWindowId: string
3 currentWindowId: string
4 previousWindowId?: string
5 windowNumber: number
6}
7export interface SessionState {
8 window: WindowIdentity
9 /** `<windowId>:reminder` and `<windowId>:urgent`, each sent once per window. */
10 reminded: string[]
11 /** Subagents that wrote notes in this session or that its transcript launched, kept beyond the sliding view. */
12 agents?: string[]
13 /** The Claude Code transcripts folder this session belongs to; prune judges only its own profile's sessions. */
14 projects?: string
15 /** The fork record this session was restored from, which prune keeps while the session stays. */
16 restoredFrom?: string
17}
18
19export const GUIDANCE = `<context_window_guidance>
20Keep one checkpoint per task at a stable notes path: request, constraints, decisions, progress, next steps, history IDs. Replace stale state; mark completion in place. Keep reusable findings and deferred ideas in separate topic notes; link, don't copy. Recording isn't permission to implement.
21
22Save changed state after substantial work, before replying, and before new_context or handoff. Skip routine or unchanged state. Include checkpoint paths in handoffs. After rollover, read the checkpoint, then linked notes as needed; history only for missing details.
23</context_window_guidance>`
24
25const WINDOW_OPEN = '<context_window>'
26const FIRST = 'First context window id: '
27const CURRENT = 'Current context window id: '
28const PREVIOUS = 'Previous context window id: '
29
30export function newState(): SessionState {
31 const id = crypto.randomUUID()
32 return { window: { firstWindowId: id, currentWindowId: id, windowNumber: 0 }, reminded: [] }
33}
34
35export function nextWindow(current: WindowIdentity): WindowIdentity {
36 return {
37 firstWindowId: current.firstWindowId,
38 currentWindowId: crypto.randomUUID(),
39 previousWindowId: current.currentWindowId,
40 windowNumber: current.windowNumber + 1,
41 }
42}
43
44export function windowText(agent: string, ids: WindowIdentity, recentNotes: readonly string[]): string {
45 const lines = [`Agent name: ${agent}`, `${FIRST}${ids.firstWindowId}`, `${CURRENT}${ids.currentWindowId}`]
46 if (ids.previousWindowId) lines.push(`${PREVIOUS}${ids.previousWindowId}`)
47 if (recentNotes.length) lines.push(`Recent notes: ${recentNotes.join(', ')}`)
48 return `${WINDOW_OPEN}\n${lines.join('\n')}\n</context_window>`
49}
50
51/** The window boundary windowText wrote, read back from a transcript message. */
52export function windowMarker(message: {
53 role: string
54 text: string
55}): { first?: string; current: string; previous?: string } | undefined {
56 if (message.role !== 'user' || !message.text.startsWith(WINDOW_OPEN)) return
57 const field = (prefix: string) =>
58 message.text
59 .split('\n')
60 .find((line) => line.startsWith(prefix))
61 ?.slice(prefix.length)
62 .trim()
63 const current = field(CURRENT)
64 return current ? { first: field(FIRST), current, previous: field(PREVIOUS) } : undefined
65}
66
67/** Both reminders went out in this window: nothing left to measure for. */
68export function remindersDone(state: SessionState): boolean {
69 return state.reminded.includes(`${state.window.currentWindowId}:urgent`)
70}
71
72export function reminderFor(state: SessionState, percentUsed: number | undefined): string | undefined {
73 if (percentUsed === undefined) return
74 const kind = percentUsed >= 90 ? 'urgent' : percentUsed >= 85 ? 'reminder' : undefined
75 const id = state.window.currentWindowId
76 if (!kind || state.reminded.includes(`${id}:${kind}`)) return
77 state.reminded.push(`${id}:reminder`)
78 if (kind === 'urgent') state.reminded.push(`${id}:urgent`)
79 const remaining = Math.round((100 - percentUsed) * 10) / 10
80 return `<context_window_reminder>\n${kind === 'urgent' ? 'Urgent: ' : ''}${remaining}% remaining. Checkpoint the active request, state and known history IDs in notes, then call new_context ${kind === 'urgent' ? 'now, before other work' : 'before continuing work'}.\n</context_window_reminder>`
81}
82