SLOPSHOPPER

notes-compaction

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

newpaneguardcommandtoasttool
v0.2.1no licenseupdated 2026-10-10ramtinJ95/claude-mods/plugins/notes-compaction
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · notes-compaction
│ ┃ Notes continuity ✕ › fix the failing auth test and add an audit log call │ ┃ Continuity active · window 1 · 0 notes in │ ┃ this session ⏺ Read(src/auth.ts) │ ┃ Reminders at 85% and 90% of 200000 tokens ⎿ Read 6 lines │ ┃ (the model window) ⏺ Update(src/auth.ts) │ ┃ 0 sessions · 0 fork records ⎿ Added 2 lines, removed 1 line │ ┃ [ Normal compaction before new windows: off ⏺ Bash(bun test) │ ┃ [ Prune missing sessions ] ⎿ 3 pass, 1 fail │ ┃ [ Close ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /notes │ ⎿ notes-compaction: Notes panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Notes continuity
Continuity active · window 1 · 0 notes in this session Reminders at 85% and 90% of 200000 tokens (the model window) 0 sessions · 0 fork records [ Normal compaction before new windows: off ] [ Prune missing sessions ] [ Close ]
README

notes-compaction

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.

Install

/plugin install notes-compaction --marketplace ramtinJ95/claude-mods

What the agent gets

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

Agents

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.

Windows and compaction

A new window replaces the conversation with one marker message; no summary is generated. The full transcript stays in the session, which history reads.

  • Reminders ride on tool results at 85% and 90% of the context Claude Code compacts at, once each per window. With an auto-compact window set (for example 400k), that is its threshold, so the checkpoint comes first; otherwise it is the model's window. Use counts the tool results of the current batch, so a run of large file reads still triggers them. get_context_remaining measures against the same limit.
  • Ask the agent for a new window in plain words; it saves a checkpoint and calls 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.
  • Automatic compaction stays Claude Code's own summary.
  • Interrupting a turn that called new_context, or the checkpoint turn, cancels the rollover and keeps the old context.

Management

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

  • Normal compaction before new windows (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.
  • Prune missing sessions (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.

Sessions and storage

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.

Source 4 files
hooks/register.ts 764 lines
1import 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}
764
hooks/history.ts 146 lines
1import 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}
146
hooks/notes.ts 174 lines
1export 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}
174
hooks/windows.ts 82 lines
1export 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