SLOPSHOPPER

memory-save

Loads MEMORY.md into the session and saves project learnings to it after every turn through a tool-less fork in the background, without blocking the stop.

newguardstatuspromptmodelprocess
A shopper browsing a rack in a slop shop
README

memory-save

A memory kept by a Stop hook blocks the turn's end, makes the model Read and Edit MEMORY.md in front of you, and costs an extra turn every time. This mod keeps a per-project MEMORY.md up to date without blocking the stop, and loads it into the session. After every main-loop turn it asks a tool-less fork of the session what the project must remember, and writes the answer itself. The main conversation never sees a memory edit: no blocked stop, no Read or Edit of MEMORY.md, no extra turn.

What it does

Loads the memory

At startup, resume, /clear and compaction, the classic.SessionStart hook adds one context block:

[PROJECT MEMORY: <project>] Answer in the language of the user's own messages, in every reply and every progress line, also at the end of a long turn whose context (tool output, docs, this memory) is in another language. Keep technical terms and identifiers as they are.

<the whole MEMORY.md>

Topic files in ~/.cli-tweaks/memory/<project>: history.md

The language line is there because long turns whose context was mostly English ended with English replies to prompts in another language (measured). The topic line is present only when topic files exist. A project without MEMORY.md gets no block. The block holds no instruction to write the file, because the mod writes it. The memory is not repeated between these events, so it does not grow the context turn by turn.

Saves the memory

After every main-loop turn that ended with an answer or an interruption, and that gave the fork something to read (see the next section):

  1. It reads ~/.cli-tweaks/memory/<project>/MEMORY.md, when the file exists.
  2. It sends one message to $.model.fork. The fork sees the whole session transcript and shares its prompt cache, but it has no tools. The message carries the current file, the writing rules and the reply format. The writing rules, the template and the MIGRATION, OFFLOAD and BULLET SPLIT notes are the texts of the classic memory-save Stop hook, word for word; only the parts about stopping are left out, because the fork does not stop. The OFFLOAD note has one sentence more: it tells the fork not to move a ## CRITICAL RULES bullet out.
  3. The fork answers with JSON: bullets to add, remove or replace, and text to append to topic files such as history.md.
  4. The mod applies the answer, checks the result and writes the files.

The save runs in the background. The next prompt is not held while the fork runs. One save runs at a time; a turn that ends during a save asks for one more save after it.

Skips a turn with nothing new

A turn is saved when you sent a prompt since the last save, or when a main-loop tool ran in it. A turn that a plugin prompt (a task-poke continue prompt, for example) or a background task's notification started, and in which the model only answered in words, is not saved and shows nothing. A subagent's tools do not count, because a background agent keeps running while the main loop only waits. Nothing is lost: the fork reads the whole conversation, so the next save sees the skipped turn too. The one case it does not cover is a session that closes right after such a turn.

Measured before this check: a session that waited for two background agents got three continue prompts in a row, answered each with one sentence, and ran a save for each; one of those saves moved five bullets out of MEMORY.md.

The project name is the primary repository name, also inside a git worktree, else the git top level, else the working directory.

What it shows

A status line under the prompt while a save runs, and after it:

memory-save: saving… · 14:31 memory-save: +2 -1 · 14:32 memory-save: +3 -1 ~3 topic: history · 14:32 memory-save: no change · 14:32 memory-save: +2 1 refused · 14:32 memory-save: error: reply has no JSON object · 14:32

While the sidebar is open, that state goes there instead, as a MEMORY.md section that stays for the session and is rewritten at each save, and the status line stays clear. There the line is coloured part by part: what a save wrote (+2 -1 ~3 topic: …) green, each N skipped, N refused and N rule(s) retired yellow, the error: front red with the message in the default colour, saving… and no change faint, and the clock faint. With the sidebar closed, or without that mod installed, the status line is drawn as above, in the engine's own colour.

The section holds a second, faint line under the state: the last transcript line, without the MEMORY.md: front. The state says where the save stands, the second line says what it did:

MEMORY.md topic: history · 14:32 topic files only; appended to history.md

One line in the transcript when a save changed a file. The line is not sent to the model:

memory-save: MEMORY.md: 2 added, 1 removed; appended to history.md

The file format

MEMORY.md has exactly these sections, in this order: ## CRITICAL RULES, ## Architecture & Config Facts, ## Active Warnings, ## Topic Files. A new file starts from this skeleton.

The template is never broken. At every load (startup, resume, /clear, compaction) and before every save, a file without exactly these four sections in this order is put into the template by the mod itself, without the fork: the sections go in order, a repeated section is merged into one, a missing section is added as - None yet., and every other ## section moves to the end of ## Architecture & Config Facts under a ### Unsorted: <heading> line. No line is dropped. The next save tells the fork to move each unsorted bullet to the section it belongs in, or history to a topic file, and to remove the ### Unsorted: line once it is empty. The old file is kept as MEMORY.pre-migration.md next to it (a later repair replaces that copy), and one line in the transcript says so:

memory-save: MEMORY.md: put into the four sections (old copy: MEMORY.pre-migration.md)

A save whose result is not in the template is never written.

What is checked before a write

  • The reply is one JSON object. A reply the parser cannot read is left to the next turn: nothing is written, the reply is kept as evidence, and one transcript line says so without a status line. An op or a topic of another shape is refused alone, the rest of the reply is written, the status line counts it (+2 1 refused), the transcript line names it, and the next save tells the fork what it refused. One bad op no longer loses the whole save.
  • An add names a heading the file already has: one of the four sections, or a ### subheading of it, whatever its case. A bullet goes at the end of that heading's own block, so an add to a section lands before its first subheading. An add whose heading the file lacks is refused alone.
  • A removed or replaced line exists in the file exactly, or it differs only by a leading list marker from exactly one line. The fork writes every entry as a bullet, also one that stands in the file as a plain paragraph line. A remove or replace whose line the file does not have is skipped, and the other ops are written: the status line counts it (+1 1 skipped), the transcript line names it, and the next save tells the fork to copy such a line exactly, with its markup. A misquoted line (the fork added ** around one, for example) used to stop the whole save.
  • A topic file name is lowercase, ends in .md, has no directory part and is not memory.md.
  • The result has the four sections in order, fewer than 200 lines and fewer than 50000 characters. A file that is already at or over a cap (one written before these checks, for example) is the exception: a save that makes it smaller in both measures is written, so the file comes back under the caps in steps instead of every save failing. A result over a cap is not lost either: the adds and the topic appends are dropped, the removes alone are written, and the dropped part is counted as refused. A remove or replace that would take the title or one of the four ## section lines is refused alone, so the template cannot break; a ### subheading may still go.
  • From 160 lines or 42000 characters the fork is told how many lines and characters this save must remove. Over a cap the note becomes a shrink-only save: add no new bullet, only move entries to a topic file.
  • A save that runs under that note does not remove a ## CRITICAL RULES bullet. Such a remove is refused alone, and the fork is told to shorten the bullet with a replace op instead. A save below 160 lines and 42000 characters may remove a CRITICAL RULES bullet, because the fork removes it there only when the conversation shows the rule no longer holds. The removed bullet goes to history.md under ## Retired CRITICAL RULES, the transcript line names it (retired from CRITICAL RULES, kept in history.md: ...), and the status line turns yellow (1 rule(s) retired), so you can put back a rule the fork removed by mistake. Measured before this check: a project at 160 lines lost a rule the user had chosen, because the offload note made the fork pick entries to move out.
  • No new bullet is longer than 600 characters. An add or replace whose bullet is longer is refused alone: the other ops are written, the status line counts it (+1 1 refused), the transcript line names it, and the next save tells the fork to split such a bullet or move its detail to a topic file.
  • MEMORY.md did not change while the fork ran.

Two cases are left that write nothing and show an error on the status line: the fork gave no text to read (nothing to fork yet, an API error with its status and kind, a reply with no text, or a cut call), and MEMORY.md changed while the fork ran.

The reply's JSON object is read from the last } back to the first { that opens an object of named fields and parses. A reply that writes a sentence before the JSON is still read, also one whose sentence holds braces of its own, for example a { tool: 'Edit' } matcher.

A reply that is not a JSON object of that shape is written to memory-save.failed-reply.txt in the memory directory, and one transcript line names its cause and its output tokens:

memory-save: MEMORY.md: this turn's reply was not read (reply is not valid JSON (JSON Parse error: Expected '}')); 1840 output tokens, kept in memory-save.failed-reply.txt

Each such reply replaces the file, so it holds the last one. Read it to see whether the reply was cut short or held broken JSON. The fork reply's stop reason is not available to a mod, so the mod cannot tell the two apart itself.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install memory-save@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

Load it from a local checkout for one session:

claude --plugin-dir plugins/memory-save

After installing

  1. Remove every other hook, CLAUDE.md line or skill that tells the model to edit MEMORY.md. The mod writes the file, and a model edit while the fork runs makes that save stop with an error.
  2. To keep a memory file you already have, copy it to ~/.cli-tweaks/memory/<project>/MEMORY.md. At the next load the mod puts it into the four sections and keeps the old copy as MEMORY.pre-migration.md. A project without the file gets one after its first save; the mod creates the directory.
  3. Restart Claude Code. The memory loads at startup, resume, /clear and compaction.

The mod has no command. To stop the saves, disable it: claude plugin disable memory-save@kilimcininkoroglu-mods.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.288:

❯ ./register.ts hooks: session.start, classic.SessionStart, prompt.submit, tool.call, turn.complete ❯ ./register.ts calls: $.clock.after, $.clock.now (via report), $.env.get (via locate), $.fs.exists (via readFile), $.fs.list (via memoryContext), $.fs.read (via readFile), $.fs.write (via ask, save, templated, writeTopics), $.model.fork (via ask), $.process.run (via git), $.sidebar.clear (via clearReport), $.sidebar.set (via report), $.ui.log (via ask, git, logEvent), $.ui.status (via clearReport, report) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: HOME

Reach L2, writes files, runs git and drives Claude.

  1. Reads: HOME; MEMORY.md, its topic files and the directory listing under ~/.cli-tweaks/memory/<project>/; the session transcript, through the fork; the origin of each prompt and whether a main-loop tool ran, never the prompt text or a tool input
  2. Runs: git rev-parse, twice per session, to name the project; one tool-less $.model.fork per main-loop turn
  3. Sends: MEMORY.md as session context at startup, resume, /clear and compaction; the fork message (the writing rules and the current MEMORY.md) to the session's own API client, on top of the session's transcript
  4. Persists: MEMORY.md, MEMORY.pre-migration.md, topic files and the last unreadable fork reply (memory-save.failed-reply.txt) under ~/.cli-tweaks/memory/<project>/
  5. Hostile input: the fork's reply is untrusted text; only the documented JSON shape is applied, topic file names are checked, and the result must pass every check before a write

Limits

  • The fork has no tools. It knows only the transcript and the current file.
  • A mod loaded in the middle of a session (/reload-plugins, an enable) loads the memory at the next /clear, compaction or session.
  • The test engine of claude plugin test cannot raise classic.SessionStart. The load is covered by unit tests of its text and by a live session check.
  • Every main-loop turn costs one fork: the transcript as cache reads, the current file and the rules as input, and the answer as output.
  • A save that fails is not retried. The next turn saves again.
  • A fork can come back without text: before the conversation's first reply, on an API error, or cut by an abort. The status line then names the reason.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 319 lines
1import type { EngineInterface, Register } from 'claude-code'
2import {
3  BACKUP,
4  appendTopic,
5  buildPrompt,
6  changeParts,
7  changeText,
8  clockText,
9  contextText,
10  errorParts,
11  eventShort,
12  fit,
13  inspect,
14  isProjectName,
15  noChangeParts,
16  parseReply,
17  projectNameFrom,
18  repairSections,
19  skippedText,
20  topicFiles,
21  unansweredText,
22  type Part,
23  type Reply,
24  type TopicAppend,
25} from './memory.ts'
26
27/**
28 * What the hooks share: the project and its memory directory, the promise that
29 * resolves them, why they could not be resolved, and the save queue: one save
30 * runs at a time, and a turn that ends during it asks for one more.
31 */
32type State = {
33  project?: string
34  dir?: string
35  error?: string
36  located?: Promise<void>
37  running: boolean
38  pending: boolean
39  /** The lines the last save skipped, named to the next fork so it copies them exactly. */
40  skipped: string[]
41  /** The ops the last save refused, named to the next fork so it writes them in the documented shape. */
42  refused: string[]
43  /** The short form of the last transcript line, drawn faint under the state in the sidebar. */
44  event?: string
45  /** Whether the person sent a prompt since the last save was asked for. */
46  personSpoke: boolean
47  /** Whether a main-loop tool ran since the last save was asked for; a subagent's tools do not count. */
48  toolRan: boolean
49}
50
51/** The person's own input: Enter at the prompt (typed or queued) or the bridge. */
52const PERSON: ReadonlySet<string> = new Set(['composer', 'bridge'])
53
54/**
55 * Whether the turn that just ended gave the fork anything to read: a prompt the person sent, or a
56 * main-loop tool. A turn a plugin or a background task's notification started, where the model only
57 * answered in words, is skipped: the next save reads the whole conversation, that turn included.
58 */
59function worthSaving(state: State): boolean {
60  const worth = state.personSpoke || state.toolRan
61  state.personSpoke = false
62  state.toolRan = false
63  return worth
64}
65
66function message(err: unknown): string {
67  return err instanceof Error ? err.message : String(err)
68}
69
70/** Returns git's stdout, or "" when git fails, because a directory outside a repository is a normal case. */
71async function git($: EngineInterface, args: string[]): Promise<string> {
72  try {
73    const r = await $.process.run(['git', ...args], { timeoutMs: 3000 })
74    return r.exitCode === 0 ? r.stdout : ''
75  } catch (err) {
76    $.ui.log(`git ${args.join(' ')} did not run: ${message(err)}`, { to: 'debug' })
77    return ''
78  }
79}
80
81/** Resolves the project name and its memory directory once per session. */
82async function locate($: EngineInterface, state: State, cwd: string): Promise<void> {
83  const home = await $.env.get('HOME')
84  if (home === undefined || home === '') {
85    state.error = 'HOME is not set'
86    return
87  }
88  const commonDir = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
89  const topLevel = commonDir === '' ? await git($, ['rev-parse', '--show-toplevel']) : ''
90  const project = projectNameFrom(commonDir, topLevel, cwd)
91  if (!isProjectName(project)) {
92    state.error = `project name ${JSON.stringify(project)} is not a safe directory name`
93    return
94  }
95  state.project = project
96  state.dir = `${home}/.cli-tweaks/memory/${project}`
97}
98
99/**
100 * Starts the project lookup once. `classic.SessionStart` runs inside the
101 * `next(e)` of `session.start`, so both hooks share the one lookup.
102 */
103function ensureLocated($: EngineInterface, state: State, cwd: string): Promise<void> {
104  state.located ??= locate($, state, cwd)
105  return state.located
106}
107
108/** Returns the file's text, undefined when it does not exist; a read error rejects. */
109async function readFile($: EngineInterface, path: string): Promise<string | undefined> {
110  if (!(await $.fs.exists(path))) return undefined
111  try {
112    return await $.fs.read(path)
113  } catch (err) {
114    throw new Error(`cannot read ${path}: ${message(err)}`)
115  }
116}
117
118/**
119 * Returns MEMORY.md, first put into the template when it is not: the old file
120 * is kept as the backup and one line in the transcript says so. Undefined when
121 * the file does not exist.
122 */
123async function templated($: EngineInterface, state: State, project: string, dir: string): Promise<string | undefined> {
124  const file = `${dir}/MEMORY.md`
125  const current = await readFile($, file)
126  if (current === undefined || inspect(current).inFormat) return current
127  const repaired = repairSections(project, current)
128  await $.fs.write(`${dir}/${BACKUP}`, current)
129  await $.fs.write(file, repaired)
130  logEvent($, state, `MEMORY.md: put into the four sections (old copy: ${BACKUP})`)
131  return repaired
132}
133
134async function writeTopics($: EngineInterface, project: string, dir: string, topics: TopicAppend[]): Promise<void> {
135  for (const t of topics) {
136    const path = `${dir}/${t.file}`
137    await $.fs.write(path, appendTopic(project, t.file, await readFile($, path), t.append))
138  }
139}
140
141/** The section this mod owns in the shared sidebar. */
142const SECTION = { consumer: 'memory-save', key: 'save' }
143
144/** Writes one transcript line and keeps its short form for the sidebar's second line. */
145function logEvent($: EngineInterface, state: State, text: string): void {
146  $.ui.log(text)
147  state.event = eventShort(text)
148}
149
150/**
151 * The save's state, on the shared sidebar while it is open, else on the status line, as before.
152 * The sidebar also gets the last transcript line under the state, faint, because the status line
153 * says where the save stands and the event says what it did. The state comes in parts, each in its
154 * own colour, and the clock after them is faint. A sidebar mod that is not installed answers the same
155 * as a closed one.
156 */
157async function report($: EngineInterface, state: State, head: Part[]): Promise<void> {
158  const clock: Part = { text: ` · ${clockText(await $.clock.now())}`, kind: 'dim' }
159  const parts = [...head, clock]
160  const line = parts.map(p => p.text).join('')
161  const lines = [{ text: line, parts }, ...(state.event === undefined ? [] : [{ text: state.event, kind: 'dim' as const }])]
162  try {
163    if (await $.sidebar.set({ ...SECTION, title: 'MEMORY.md', lines, until: 'session', order: 20 })) {
164      $.ui.status(undefined)
165      return
166    }
167  } catch {
168    // The sidebar mod is not installed.
169  }
170  $.ui.status(line)
171}
172
173/** Takes the state down from both channels, because a save that is left to the next turn shows nothing. */
174async function clearReport($: EngineInterface): Promise<void> {
175  try {
176    await $.sidebar.clear(SECTION)
177  } catch {
178    // The sidebar mod is not installed.
179  }
180  $.ui.status(undefined)
181}
182
183/** Where a reply that could not be read is kept, the last one only, so its cause can be seen. */
184const FAILED_REPLY = 'memory-save.failed-reply.txt'
185
186/**
187 * Asks the fork what to remember and reads its answer. A reply that cannot be
188 * read is written to FAILED_REPLY, and the error names its output tokens.
189 */
190async function ask($: EngineInterface, state: State, project: string, dir: string, current: string | undefined): Promise<Reply | undefined> {
191  const reply = await $.model.fork({ prompt: buildPrompt(project, dir, current, state.skipped, state.refused) })
192  if (!reply.isAnswered) throw new Error(unansweredText(reply))
193  const u = reply.usage
194  $.ui.log(`fork usage: in ${u.input_tokens}, cache read ${u.cache_read_input_tokens}, out ${u.output_tokens}`, { to: 'debug' })
195  const parsed = parseReply(reply.text)
196  if (parsed.ok) return parsed.reply
197  // A reply the parser cannot read is left to the next turn: the evidence is kept and the person reads
198  // one transcript line, because a save the fork repeats every turn is no reason for a status line error.
199  await $.fs.write(`${dir}/${FAILED_REPLY}`, reply.text)
200  logEvent($, state, `MEMORY.md: this turn's reply was not read (${parsed.error}); ${u.output_tokens} output tokens, kept in ${FAILED_REPLY}`)
201  return undefined
202}
203
204/** Reports a save that changed no file, naming the lines it skipped and the ops it refused. */
205async function reportNoChange($: EngineInterface, state: State, skipped: string[], refused: string[]): Promise<void> {
206  const parts = [skipped.length > 0 ? skippedText(skipped) : '', refused.length > 0 ? `refused: ${refused.join('; ')}` : ''].filter(p => p !== '')
207  if (parts.length > 0) logEvent($, state, `MEMORY.md: no change; ${parts.join('; ')}`)
208  return report($, state, noChangeParts(skipped.length, refused.length))
209}
210
211/** Asks the fork what to remember, then writes MEMORY.md and its topic files. */
212async function save($: EngineInterface, state: State): Promise<void> {
213  await state.located
214  const { project, dir } = state
215  if (project === undefined || dir === undefined) throw new Error(state.error ?? 'the project is not resolved yet')
216  const file = `${dir}/MEMORY.md`
217  const current = await templated($, state, project, dir)
218  const reply = await ask($, state, project, dir, current)
219  if (reply === undefined) return clearReport($)
220  const result = fit(project, current, reply)
221  if (!result.ok) throw new Error(result.error)
222  if (!result.changed) {
223    state.skipped = result.skipped
224    state.refused = result.refused
225    return reportNoChange($, state, result.skipped, result.refused)
226  }
227  state.skipped = result.changes.skipped
228  state.refused = result.changes.refused
229  if ((await readFile($, file)) !== current) throw new Error('not written: MEMORY.md changed during the save')
230  await writeTopics($, project, dir, result.topics)
231  await $.fs.write(file, result.text)
232  logEvent($, state, changeText(result.changes, result.topics))
233  await report($, state, changeParts(result.changes, result.topics))
234}
235
236/** Returns the session's memory context, or undefined when the project has no MEMORY.md. */
237async function memoryContext($: EngineInterface, state: State): Promise<string | undefined> {
238  const { project, dir } = state
239  if (project === undefined || dir === undefined) throw new Error(state.error ?? 'the project is not resolved')
240  const memory = await templated($, state, project, dir)
241  if (memory === undefined) return undefined
242  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file').map(f => f.name)
243  return contextText(project, dir, memory, topicFiles(files))
244}
245
246/** Adds the memory context to the classic SessionStart result; a failure shows on the status line and adds nothing. */
247async function withMemory<R extends { additionalContext?: string[] }>($: EngineInterface, state: State, r: R): Promise<R> {
248  try {
249    const text = await memoryContext($, state)
250    return text === undefined ? r : { ...r, additionalContext: [...(r.additionalContext ?? []), text] }
251  } catch (err) {
252    await report($, state, errorParts(`memory not loaded: ${message(err)}`))
253    return r
254  }
255}
256
257/** Runs saves one at a time; a failed save shows its error on the status line and the queue goes on. */
258async function drain($: EngineInterface, state: State): Promise<void> {
259  if (state.running) {
260    state.pending = true
261    return
262  }
263  state.running = true
264  try {
265    do {
266      state.pending = false
267      // The fork runs in the background; the line says so until the result replaces it.
268      await report($, state, [{ text: 'saving…', kind: 'dim' }])
269      await save($, state).catch((err: unknown) => report($, state, errorParts(message(err))))
270    } while (state.pending)
271  } finally {
272    state.running = false
273  }
274}
275
276export const register: Register = on => {
277  const state: State = { running: false, pending: false, skipped: [], refused: [], personSpoke: false, toolRan: false }
278
279  on('session.start', async ($, e, next) => {
280    // A new start (a reload, an enable) looks the project up again.
281    state.located = locate($, state, e.cwd)
282    const r = await next(e)
283    await state.located
284    return r
285  })
286
287  // The memory enters the context at startup, resume, /clear and compaction.
288  on('classic.SessionStart', async ($, e, next) => {
289    const r = await next(e)
290    if (e.agent_id !== undefined) return r
291    await ensureLocated($, state, e.cwd)
292    return withMemory($, state, r)
293  })
294
295  // Only the origin is read, never the prompt's text.
296  on('prompt.submit', async (_, e, next) => {
297    const kind = (e.origin as { kind?: string } | undefined)?.kind
298    if (kind !== undefined && PERSON.has(kind)) state.personSpoke = true
299    return next(e)
300  })
301
302  // Only whether a main-loop tool ran is read, never its input or result.
303  on('tool.call', async (_, e, next) => {
304    if (e.agentId === undefined) state.toolRan = true
305    return next(e)
306  })
307
308  on('turn.complete', async ($, e, next) => {
309    const r = await next(e)
310    if (e.agentId !== undefined || e.reason === 'error' || e.reason === 'refusal') return r
311    if (!worthSaving(state)) return r
312    // The save runs on a timer, not in the turn's dispatch: since 2.1.288 an engine call still in
313    // flight when the dispatch closes is aborted. A timer outlives the dispatch and still holds the
314    // next prompt for nothing.
315    $.clock.after(0, () => void drain($, state))
316    return r
317  })
318}
319
hooks/memory.ts 764 lines
1/**
2 * The pure part of memory-save: the project name, the save prompt, the reply
3 * format and the checks that decide whether a new MEMORY.md may be written.
4 */
5
6export const SECTIONS = ['CRITICAL RULES', 'Architecture & Config Facts', 'Active Warnings', 'Topic Files'] as const
7export type Section = (typeof SECTIONS)[number]
8
9export const MAX_LINES = 200
10export const MAX_CHARS = 50_000
11export const SOFT_LINES = 160
12export const SOFT_CHARS = 42_000
13export const MAX_BULLET = 600
14
15const PROJECT_NAME = /^[A-Za-z0-9._-]+$/
16const TOPIC_FILE = /^[a-z0-9][a-z0-9._-]*\.md$/
17
18export type Op =
19  /** `section` is a heading of the file: one of the four sections, or a '### ' subheading. */
20  | { op: 'add'; section: string; text: string }
21  | { op: 'remove'; line: string }
22  | { op: 'replace'; line: string; text: string }
23
24export type TopicAppend = { file: string; append: string }
25
26/** `refused` names each op or topic the mod could not read or place; the rest is still applied. */
27export type Reply = { ops: Op[]; topics: TopicAppend[]; refused: string[] }
28
29export type Parsed = { ok: true; reply: Reply } | { ok: false; error: string }
30
31/**
32 * `skipped` holds the lines of remove and replace ops the file has no line for. `retired` holds the
33 * CRITICAL RULES bullets this save removed; they are counted in `removed` too.
34 */
35export type Changes = { added: number; removed: number; replaced: number; created: boolean; skipped: string[]; refused: string[]; retired: string[] }
36
37export type Applied =
38  | { ok: true; changed: false; skipped: string[]; refused: string[] }
39  | { ok: true; changed: true; text: string; changes: Changes; newBullets: string[]; topics: TopicAppend[] }
40  | { ok: false; error: string }
41
42export type Inspection = {
43  lines: number
44  chars: number
45  /** Whether the file has exactly the four sections, in order. */
46  inFormat: boolean
47  longBullets: { line: number; chars: number; head: string }[]
48}
49
50function trimSlashes(path: string): string {
51  return path.replace(/\/+$/, '')
52}
53
54function baseName(path: string): string {
55  return trimSlashes(path).split('/').at(-1) ?? ''
56}
57
58function parentOf(path: string): string {
59  return trimSlashes(path).split('/').slice(0, -1).join('/')
60}
61
62/**
63 * Returns the primary repository name from the git common dir, which names the
64 * main repository also inside a worktree, or "" when the dir has neither shape.
65 */
66function nameFromCommonDir(commonDir: string): string {
67  if (commonDir === '') return ''
68  if (baseName(commonDir) === '.git') return baseName(parentOf(commonDir))
69  const worktrees = parentOf(commonDir)
70  if (baseName(worktrees) === 'worktrees' && baseName(parentOf(worktrees)) === '.git') {
71    return baseName(parentOf(parentOf(worktrees)))
72  }
73  return ''
74}
75
76/**
77 * Names the project the way `~/.claude/hooks/project.py` does, so the mod and
78 * the classic hooks use the same memory directory: the primary repository,
79 * else the git top level, else the working directory.
80 */
81export function projectNameFrom(commonDir: string, topLevel: string, cwd: string): string {
82  const fromCommon = nameFromCommonDir(commonDir.trim())
83  if (fromCommon !== '') return fromCommon
84  const top = topLevel.trim()
85  return top !== '' ? baseName(top) : baseName(cwd)
86}
87
88export function isProjectName(name: string): boolean {
89  return PROJECT_NAME.test(name) && name !== '.' && name !== '..'
90}
91
92function linesOf(text: string): string[] {
93  return text === '' ? [] : text.replace(/\n$/, '').split('\n')
94}
95
96function headingsOf(text: string): string[] {
97  return linesOf(text)
98    .filter(l => l.startsWith('## '))
99    .map(l => l.slice(3).trim())
100}
101
102/** Whether the text has exactly the four sections, in order, and no other '## ' heading. */
103function hasSections(headings: string[]): boolean {
104  return headings.length === SECTIONS.length && SECTIONS.every((s, i) => s.toLowerCase() === headings[i]?.toLowerCase())
105}
106
107function foundText(headings: string[]): string {
108  return headings.join(', ') || 'none'
109}
110
111function isHeading(line: string, section: string): boolean {
112  return line.trim().toLowerCase() === `## ${section}`.toLowerCase()
113}
114
115export function inspect(text: string): Inspection {
116  const lines = linesOf(text)
117  const longBullets = lines.flatMap((line, i) =>
118    line.startsWith('- ') && line.length > MAX_BULLET ? [{ line: i + 1, chars: line.length, head: line.slice(0, 55) }] : [],
119  )
120  return {
121    lines: lines.length,
122    chars: text.length,
123    inFormat: hasSections(headingsOf(text)),
124    longBullets,
125  }
126}
127
128const EMPTY_SECTION = '- None yet.'
129
130function trimBlank(lines: string[]): string[] {
131  const start = lines.findIndex(l => l.trim() !== '')
132  if (start === -1) return []
133  const end = lines.length - [...lines].reverse().findIndex(l => l.trim() !== '')
134  return lines.slice(start, end)
135}
136
137/** Splits a file into the lines before its first '## ' heading and the body of each section, by heading. */
138function splitSections(text: string): { head: string[]; sections: [string, string[]][] } {
139  const head: string[] = []
140  const sections: [string, string[]][] = []
141  for (const line of linesOf(text)) {
142    if (line.startsWith('## ')) sections.push([line.slice(3).trim(), []])
143    else (sections.at(-1)?.[1] ?? head).push(line)
144  }
145  return { head, sections }
146}
147
148/**
149 * Where the content of a '## ' section that is not in the template waits,
150 * under a '### Unsorted: <heading>' heading, until the fork moves each of its
151 * bullets to the section it belongs in.
152 */
153const UNSORTED_HOME: Section = 'Architecture & Config Facts'
154const UNSORTED = '### Unsorted: '
155
156/**
157 * Puts any file into the template: the four sections in order, a same-named
158 * section merged into one, a missing one added as `- None yet.`, and every
159 * other '## ' section kept as an unsorted part of Architecture & Config Facts
160 * for the fork to sort. No content is dropped.
161 */
162export function repairSections(project: string, text: string): string {
163  const { head, sections } = splitSections(text)
164  const bodies = new Map<Section, string[]>()
165  for (const [heading, body] of sections) {
166    const section = sectionOf(heading)
167    const lines = section === undefined ? [`${UNSORTED}${heading}`, '', ...trimBlank(body), ''] : trimBlank(body)
168    const home = section ?? UNSORTED_HOME
169    const before = bodies.get(home) ?? []
170    bodies.set(home, trimBlank([...before, ...(before.length > 0 ? [''] : []), ...lines]))
171  }
172  const title = trimBlank(head)
173  const parts = [title.length > 0 ? title.join('\n') : `# ${project}`]
174  for (const section of SECTIONS) {
175    const body = bodies.get(section) ?? []
176    parts.push(`## ${section}\n\n${body.length > 0 ? body.join('\n') : EMPTY_SECTION}`)
177  }
178  return `${parts.join('\n\n')}\n`
179}
180
181/** Returns the four-section file a project starts from. */
182export function skeleton(project: string): string {
183  return `# ${project}\n\n${SECTIONS.map(s => `## ${s}\n`).join('\n')}`
184}
185
186/*
187 * The rule texts below are the user's own, copied word for word from the classic memory-save Stop hook.
188 * Only the parts about stopping are left out ("before stopping", "just stop", "this session"), because
189 * the fork does not stop: it answers with JSON. Do not reword them.
190 */
191
192/** The writing rules for a project that has a MEMORY.md. */
193const existingRules = (dir: string): string => `MANDATORY — these are HARD rules, not suggestions; apply each:
1941. If you learned an ACTIVE PROJECT-SCOPED RULE that changes future behavior for this repository's code, commands, architecture, configuration, deployment, tests, or product preferences, you MUST record it in ${dir}/MEMORY.md in imperative mood — under '## CRITICAL RULES' when it is a behavior rule. Do NOT write global Claude Code behavior, shared skill workflow rules, general agent preferences, or cross-project policies to this project MEMORY.md; put those in the appropriate global instruction or shared skill file instead. If the scope is unclear, do not write it here.
1952. NEVER write commit hashes, dated fix histories, completed-slice/feature DONE-records, or any archival narrative to MEMORY.md — these are FORBIDDEN there. ALL historical detail goes to a topic file ONLY — history.md by default, or a dedicated subject file when a topic grows large enough to warrant its own (list any new topic file under '## Topic Files'). Enforce this at WRITE time, not merely as an afterthought.
1963. MEMORY.md holds ONLY durable rules, patterns, and stable facts, each as ONE focused bullet — strip narrative, examples, dated context, and completed-work detail to a topic file (history.md, or a dedicated subject file for a large topic). TWO caps BOTH apply: keep it under 200 lines AND under 50000 characters. Do NOT cram multiple ideas into one long line to dodge the line cap — the character cap catches that. If either is exceeded, move the oldest/least-critical entries to history.md.
1974. Write in English ONLY. Rules 4 to 8 apply to MEMORY.md and to every topic file alike.
1985. Write every entry to ASD-STE100 (Simplified Technical English): short sentences, active voice, simple tenses, ONE INSTRUCTION per sentence, and the SAME term for the same thing throughout. A reason clause is not a second instruction: keep the short 'because' or 'so that' clause whenever dropping it would let the rule be applied wrongly.
1996. Name the fact directly. NEVER invent a metaphor or a figurative phrase, and NEVER present an invented phrase as if it were an established term. An invented phrase is unrecoverable months later, because the reader cannot tell which fact it encodes.
2007. NEVER hedge a fact you measured. When you have NOT verified something, say exactly that instead of softening the claim, so a guess is never read back as a fact.
2018. Reproduce identifiers, file names, config keys and trigger phrases exactly as they appear in the codebase, including non-English ones. Never translate them.`
202
203const TEMPLATE = `MEMORY.md MUST use exactly these four sections, in this order:
204  ## CRITICAL RULES        - non-negotiable project-scoped active rules, imperative mood
205  ## Architecture & Config Facts - project-scoped stable technical context, not rules
206  ## Active Warnings       - project-scoped pitfalls and recurring mistakes
207  ## Topic Files           - pointers to detail files (e.g. history.md)
208Only record project-scoped learnings in this project MEMORY.md. A learning is project-scoped only when it changes future behavior for this repository's code, commands, architecture, configuration, deployment, tests, or product preferences. Do not write global Claude Code behavior, shared skill workflow rules, general agent preferences, or cross-project policies to this project MEMORY.md. Put global rules in the appropriate global instruction or shared skill file instead. If the scope is unclear, do not write it here. Keep each bullet to ONE focused project rule or fact; strip narrative, examples, and dated context to a topic file (history.md, or a dedicated subject file for a large topic, listed under '## Topic Files'). TWO caps BOTH apply (under 200 lines AND under 50000 characters), so keep bullets concise and do NOT pad them into long single lines.`
209
210/** The writing rules for a project without a MEMORY.md, followed by the template. */
211const newProjectRules = (dir: string): string => `MANDATORY — this is a new project with no memory yet. You MUST create ${dir}/MEMORY.md following the template below, with project-scoped rules in imperative mood. Record only learnings that change future behavior for this repository's code, commands, architecture, configuration, deployment, tests, or product preferences. Do NOT write global Claude Code behavior, shared skill workflow rules, general agent preferences, or cross-project policies to this project MEMORY.md; put those in the appropriate global instruction or shared skill file instead. If the scope is unclear, do not write it here. NEVER write commit hashes or dated history to MEMORY.md — historical detail goes to a topic file only (history.md by default, or a dedicated subject file for a large topic). Keep it lean — bounded by BOTH a line cap (under 200) and a character cap (under 50000) — and in English ONLY. Every writing rule here applies to MEMORY.md and to every topic file alike. Each bullet is ONE focused project rule or fact; no narrative or padding. Write every entry to ASD-STE100 (Simplified Technical English): short sentences, active voice, simple tenses, ONE INSTRUCTION per sentence, and the SAME term for the same thing throughout; a reason clause is not a second instruction, so keep the short 'because' or 'so that' clause whenever dropping it would let the rule be applied wrongly. Name the fact directly: NEVER invent a metaphor or a figurative phrase, and never present an invented phrase as if it were an established term, because an invented phrase is unrecoverable months later. NEVER hedge a fact you measured, and when you have NOT verified something say exactly that instead of softening the claim, so a guess is never read back as a fact. Reproduce identifiers, file names, config keys and trigger phrases exactly as they appear in the codebase, including non-English ones, and never translate them. Skip this ONLY if the session was genuinely trivial with nothing project-scoped worth remembering.
212${TEMPLATE}`
213
214const MIGRATION = `MANDATORY MIGRATION: MEMORY.md is MISSING the '## CRITICAL RULES' section, so it is NOT in the required format. You MUST restructure the whole file into the four-section template, in this exact order:
215  ## CRITICAL RULES        - non-negotiable project-scoped active rules, imperative mood
216  ## Architecture & Config Facts - project-scoped stable technical context, not rules
217  ## Active Warnings       - project-scoped pitfalls and recurring mistakes
218  ## Topic Files           - pointers to detail files (e.g. history.md)
219Preserve all real content, reorganize it under those sections, and convert rules to imperative mood.`
220
221const FORMAT = `Answer with ONE JSON object and nothing else, no prose, no code fence:
222{"ops": [...], "topics": [...]}
223- {"op":"add","section":"<one of: ${SECTIONS.join(' | ')}, or a '### ' subheading the file already has, copied exactly>","text":"- <one bullet on one line>"}
224- {"op":"remove","line":"<an existing line of MEMORY.md, copied exactly>"}
225- {"op":"replace","line":"<an existing line, copied exactly>","text":"- <the new bullet on one line>"}
226- topics: {"file":"history.md","append":"<markdown to append to that topic file>"}; a file name is lowercase, ends in .md, and is not MEMORY.md. The mod lists a new topic file under '## Topic Files'.
227When nothing project-scoped was learned since the file was last written, answer {"ops":[],"topics":[]}.`
228
229function sortNote(current: string): string {
230  const parts = linesOf(current).filter(l => l.startsWith(UNSORTED))
231  if (parts.length === 0) return ''
232  return `MANDATORY SORT: the mod moved sections outside the template under these headings: ${parts.join(', ')}. In this answer, move every bullet under them to the section it belongs in (a remove op and an add op), move history to a topic file, and remove each '${UNSORTED}' heading line once its bullets are gone.`
233}
234
235/** How many lines and characters this save must remove, so the result is under the caps with room to spare. */
236function mustRemove(state: Inspection): string {
237  const lines = state.lines - (SOFT_LINES - 1)
238  const chars = state.chars - (SOFT_CHARS - 1)
239  const parts = [lines > 0 ? `${lines} line(s)` : '', chars > 0 ? `${chars} character(s)` : ''].filter(p => p !== '')
240  return parts.join(' and ')
241}
242
243function sizeNotes(state: Inspection): string {
244  const notes: string[] = []
245  if (state.lines >= SOFT_LINES || state.chars >= SOFT_CHARS) {
246    const overCap = state.lines >= MAX_LINES || state.chars >= MAX_CHARS
247    const shrinkOnly = overCap
248      ? ` MEMORY.md is ALREADY over a hard cap, so this save is a SHRINK-ONLY save: add NO new bullet, and remove at least ${mustRemove(state)}. A save that does not make the file smaller is refused, and nothing is written.`
249      : ''
250    notes.push(
251      `MANDATORY OFFLOAD: MEMORY.md is now ${state.lines} lines / ${state.chars} characters — at or near a cap (BOTH limits apply: keep under 200 lines AND under 50000 characters). You MUST move the oldest/least-critical entries (resolved warnings, superseded facts, dated notes, completed-work records) to a topic file (history.md, or a dedicated subject file when a topic is large), leaving MEMORY.md a lean index of ACTIVE rules and current architecture facts. NEVER move or remove a bullet under '## CRITICAL RULES' in this save: the mod refuses that remove; shorten such a bullet with a replace op instead. Remove at least ${mustRemove(state)} in THIS save.${shrinkOnly}`,
252    )
253  }
254  if (state.longBullets.length > 0) {
255    const list = state.longBullets.map(b => `  - line ${b.line} (${b.chars} chars): ${b.head}...`).join('\n')
256    notes.push(
257      `MANDATORY BULLET SPLIT: ${state.longBullets.length} bullet(s) exceed ${MAX_BULLET} characters — a single bullet this long soft-wraps in an editor and is unscannable. Each bullet MUST be ONE focused rule or fact. Fix each by EITHER splitting it into multiple focused bullets, OR moving its detail (recipe steps, examples, multi-aspect notes) to a topic file and leaving the load-bearing rule as a lean line with a '(detail in <topic>.md)' pointer. Prefer SPLITTING for '## CRITICAL RULES' (only that section is re-injected periodically, so its detail must stay in MEMORY.md) and OFFLOADING for architecture facts. The exception is an irreducible safety rule whose detail is the rule itself (e.g. an exact regex/command) — keep it whole. Over-cap bullets:\n${list}`,
258    )
259  }
260  return notes.join('\n')
261}
262
263function skippedNote(skipped: readonly string[]): string {
264  if (skipped.length === 0) return ''
265  const list = skipped.map(l => `  - ${l}`).join('\n')
266  return `MANDATORY EXACT COPY: the last save skipped these remove or replace lines, because MEMORY.md has no line equal to them. Copy the "line" of a remove or replace op character for character from the file above, with its markup: do not add or drop a list marker, bold, italics or backticks. Skipped:\n${list}`
267}
268
269function refusedNote(refused: readonly string[]): string {
270  if (refused.length === 0) return ''
271  const list = refused.map(r => `  - ${r}`).join('\n')
272  return `MANDATORY OP SHAPE: the last save refused these ops and wrote the rest. An "add" names a heading MEMORY.md already has: one of the four '## ' sections, or a '### ' subheading of the file, copied exactly. A new bullet is at most ${MAX_BULLET} characters: split a longer one into focused bullets, or move its detail to a topic file. A save under the MANDATORY OFFLOAD note never removes a '## CRITICAL RULES' bullet: shorten it with a replace op, and remove it only in a later save when the conversation shows the rule no longer holds. Refused:\n${list}`
273}
274
275/**
276 * Builds the one message the fork answers. The current file rides along as
277 * data, because the fork has no tools and cannot read it. `skipped` names the
278 * lines the last save could not find, and `refused` the ops it could not place.
279 */
280export function buildPrompt(project: string, dir: string, current: string | undefined, skipped: readonly string[] = [], refused: readonly string[] = []): string {
281  const head = `You are the memory-save step of this session, not the assistant. Do not answer the user and do not use tools. Review the conversation above and decide what project "${project}" must remember in its MEMORY.md.`
282  if (current === undefined) {
283    return [head, 'MEMORY.md does not exist yet. The mod creates it with the four sections when your answer has at least one op.', newProjectRules(dir), FORMAT].join('\n\n')
284  }
285  const state = inspect(current)
286  const file = `The current MEMORY.md, as data between the markers:\n<memory_file>\n${current}\n</memory_file>`
287  const migration = linesOf(current).some(l => l.trim().toLowerCase() === '## critical rules') ? '' : MIGRATION
288  const notes = [sortNote(current), sizeNotes(state), skippedNote(skipped), refusedNote(refused)]
289  return [head, file, existingRules(dir), migration, ...notes, FORMAT].filter(p => p !== '').join('\n\n')
290}
291
292/** At most this many spans are tried, so a long reply full of braces still parses in one pass. */
293const MAX_SPANS = 50
294
295/**
296 * Each `{` that opens an object of named fields. A brace inside prose (`{ tool: 'Edit' }`) has no quote
297 * after it and is left out, so text before the JSON does not become the start of the span.
298 */
299function objectStarts(text: string): number[] {
300  const out: number[] = []
301  for (const m of text.matchAll(/\{\s*"/g)) if (m.index !== undefined) out.push(m.index)
302  return out
303}
304
305function isRecord(value: unknown): value is Record<string, unknown> {
306  return typeof value === 'object' && value !== null && !Array.isArray(value)
307}
308
309function oneLine(value: unknown): string | undefined {
310  return typeof value === 'string' && value.trim() !== '' && !value.includes('\n') ? value : undefined
311}
312
313function sectionOf(value: unknown): Section | undefined {
314  return SECTIONS.find(s => typeof value === 'string' && s.toLowerCase() === value.trim().toLowerCase())
315}
316
317function parseOp(value: unknown): Op | string {
318  if (!isRecord(value)) return 'an op is not an object'
319  const text = oneLine(value.text)
320  const line = oneLine(value.line)
321  if (value.op === 'add') {
322    const section = oneLine(value.section)
323    if (section === undefined) return `add: section is not one line: ${JSON.stringify(value.section)}`
324    return text === undefined ? 'add: text is not one line' : { op: 'add', section, text }
325  }
326  if (value.op === 'remove') return line === undefined ? 'remove: line is not one line' : { op: 'remove', line }
327  if (value.op === 'replace') {
328    return line === undefined || text === undefined ? 'replace: line or text is not one line' : { op: 'replace', line, text }
329  }
330  return `unknown op ${JSON.stringify(value.op)}`
331}
332
333function parseTopic(value: unknown): TopicAppend | string {
334  if (!isRecord(value) || typeof value.file !== 'string' || typeof value.append !== 'string') {
335    return 'a topic needs a file and an append text'
336  }
337  const file = value.file.trim()
338  if (!TOPIC_FILE.test(file) || file === 'memory.md') return `topic file name refused: ${file}`
339  return value.append.trim() === '' ? `topic ${file}: empty append` : { file, append: value.append }
340}
341
342/** Reads every entry it can; an entry it cannot read is named in `refused` and the rest is kept. */
343function listOf<T>(value: unknown, parse: (v: unknown) => T | string, refused: string[]): T[] | string {
344  if (value === undefined) return []
345  if (!Array.isArray(value)) return 'ops and topics must be arrays'
346  const items: T[] = []
347  for (const raw of value) {
348    const item = parse(raw)
349    if (typeof item === 'string') refused.push(item)
350    else items.push(item)
351  }
352  return items
353}
354
355function parseSpan(span: string): Record<string, unknown> | string {
356  let value: unknown
357  try {
358    value = JSON.parse(span)
359  } catch (err) {
360    return `reply is not valid JSON (${err instanceof Error ? err.message : String(err)})`
361  }
362  return isRecord(value) ? value : 'reply is not a JSON object'
363}
364
365/**
366 * The reply's own JSON object. The span always ends at the last `}`, and the first start that parses is
367 * the outermost object, so a reply that writes a sentence before the JSON is still read.
368 */
369function decode(text: string): Record<string, unknown> | string {
370  const end = text.lastIndexOf('}')
371  const starts = objectStarts(text).filter(i => i < end).slice(0, MAX_SPANS)
372  if (starts.length === 0) return 'reply has no JSON object'
373  let last = 'reply has no JSON object'
374  for (const start of starts) {
375    const value = parseSpan(text.slice(start, end + 1))
376    if (typeof value !== 'string') return value
377    last = value
378  }
379  return last
380}
381
382/** A fork result with no text to read, by the reason the engine gives. */
383export type Unanswered =
384  | { reason: 'nothing-to-fork' }
385  | { reason: 'api-error'; status: number | null; error: string }
386  | { reason: 'empty-reply' }
387  | { reason: 'aborted' }
388
389export function unansweredText(r: Unanswered): string {
390  if (r.reason === 'nothing-to-fork') return 'the fork had nothing to fork yet'
391  if (r.reason === 'empty-reply') return 'the fork replied with no text'
392  if (r.reason === 'aborted') return 'the fork was cut before its reply'
393  return `the fork got an API error, ${r.status ?? 'no response'} (${r.error})`
394}
395
396/**
397 * Reads the fork's answer. A reply that is not one JSON object is an error, never a guess. An op or a
398 * topic of another shape is named in `refused` and the rest of the reply is kept, because one bad entry
399 * must not lose the whole save.
400 */
401export function parseReply(text: string): Parsed {
402  const value = decode(text)
403  if (typeof value === 'string') return { ok: false, error: value }
404  const refused: string[] = []
405  const ops = listOf(value.ops, parseOp, refused)
406  if (typeof ops === 'string') return { ok: false, error: ops }
407  const topics = listOf(value.topics, parseTopic, refused)
408  if (typeof topics === 'string') return { ok: false, error: topics }
409  return { ok: true, reply: { ops, topics, refused } }
410}
411
412function bullet(text: string): string {
413  const t = text.trim()
414  return t.startsWith('- ') ? t : `- ${t.replace(/^[-*]\s*/, '')}`
415}
416
417function sectionEnd(lines: string[], start: number): number {
418  const next = lines.findIndex((l, i) => i > start && l.startsWith('## '))
419  return next === -1 ? lines.length : next
420}
421
422const HEADING = /^#{2,3} /
423
424function headingName(line: string): string {
425  return line.replace(/^#+\s*/, '').trim().toLowerCase()
426}
427
428/** The line of the heading an add op names: one of the four sections, or a '### ' subheading of the file. */
429function headingIndex(lines: string[], name: string): number {
430  const want = headingName(name)
431  return lines.findIndex(l => HEADING.test(l) && headingName(l) === want)
432}
433
434/** The end of a heading's own block: the next heading of any level, so a bullet stays under its own heading. */
435function blockEnd(lines: string[], start: number): number {
436  const next = lines.findIndex((l, i) => i > start && HEADING.test(l))
437  return next === -1 ? lines.length : next
438}
439
440function addTo(lines: string[], section: string, text: string): string | undefined {
441  const start = headingIndex(lines, section)
442  if (start === -1) return `add: unknown section ${JSON.stringify(section)}`
443  let at = blockEnd(lines, start)
444  while (at > start + 1 && (lines[at - 1] ?? '').trim() === '') at--
445  const body = lines.slice(start + 1, at).filter(l => l.trim() !== '')
446  if (body.length === 1 && /^- none yet\.?$/i.test(body[0]?.trim() ?? '')) lines.splice(at - 1, 1, text)
447  else if (at === start + 1) lines.splice(at, 0, '', text)
448  else lines.splice(at, 0, text)
449  return undefined
450}
451
452function withoutMarker(line: string): string {
453  return line.trim().replace(/^[-*]\s+/, '')
454}
455
456/**
457 * The index of the line an op names: the exact line, else the one line that
458 * differs from it only by a leading list marker. The fork writes every entry
459 * as a bullet, also one that stands in the file as a plain paragraph line.
460 */
461function indexOfLine(lines: string[], line: string): number {
462  const exact = lines.findIndex(l => l.trimEnd() === line.trimEnd())
463  if (exact !== -1) return exact
464  const bare = withoutMarker(line)
465  const loose = bare === '' ? [] : lines.flatMap((l, i) => (withoutMarker(l) === bare ? [i] : []))
466  return loose.length === 1 ? (loose[0] ?? -1) : -1
467}
468
469/** A bullet over the cap is refused as its own op, so the other ops of the same reply are still written. */
470function tooLong(kind: Op['op'], text: string): string | undefined {
471  return text.length > MAX_BULLET ? `${kind}: bullet of ${text.length} characters, the limit is ${MAX_BULLET}: ${text.slice(0, 60)}…` : undefined
472}
473
474/** Whether line `at` sits under '## CRITICAL RULES': the last '## ' heading above it is that section. */
475function inCriticalRules(lines: string[], at: number): boolean {
476  for (let i = at; i >= 0; i--) {
477    const line = lines[i] ?? ''
478    if (line.startsWith('## ')) return isHeading(line, 'CRITICAL RULES')
479  }
480  return false
481}
482
483/**
484 * What one save's ops share: the bullets they wrote, whether the save runs under the size offload note,
485 * and the CRITICAL RULES bullets it removed.
486 */
487type Work = { newBullets: string[]; offload: boolean; retired: string[] }
488
489/** Whether the file is at or over the soft caps, where the prompt tells the fork to move entries out. */
490function atSoftCap(current: string | undefined): boolean {
491  if (current === undefined) return false
492  const state = inspect(current)
493  return state.lines >= SOFT_LINES || state.chars >= SOFT_CHARS
494}
495
496/** Why the line a remove or replace names must stay, or undefined when the op may change it. */
497function keptLine(lines: string[], at: number, op: Exclude<Op, { op: 'add' }>, offload: boolean): string | undefined {
498  // A '### ' subheading may go (the fork removes an 'Unsorted' line once its bullets moved); the title and
499  // the four '## ' sections may not, because the file must stay in the template.
500  if (/^#{1,2} /.test(lines[at] ?? '')) return `${op.op}: the line is a section heading: ${op.line.slice(0, 60)}`
501  // A size offload chose user rules to move out (measured on a project at the soft line cap), so a
502  // CRITICAL RULES bullet goes only in a save without that note, where the fork found it no longer holds.
503  if (op.op === 'remove' && offload && inCriticalRules(lines, at)) return `remove: the line is a CRITICAL RULES bullet, and this save only makes room: ${op.line.slice(0, 60)}`
504  return undefined
505}
506
507function applyOp(lines: string[], op: Op, work: Work): string | undefined {
508  if (op.op === 'add') {
509    const text = bullet(op.text)
510    const error = tooLong('add', text) ?? addTo(lines, op.section, text)
511    if (error === undefined) work.newBullets.push(text)
512    return error
513  }
514  const at = indexOfLine(lines, op.line)
515  if (at === -1) return `${op.op}: line not found: ${op.line.slice(0, 60)}`
516  const kept = keptLine(lines, at, op, work.offload)
517  if (kept !== undefined) return kept
518  if (op.op === 'remove') {
519    if (inCriticalRules(lines, at)) work.retired.push(lines[at] ?? op.line)
520    lines.splice(at, 1)
521    return undefined
522  }
523  const text = bullet(op.text)
524  const long = tooLong('replace', text)
525  if (long !== undefined) return long
526  work.newBullets.push(text)
527  lines[at] = text
528  return undefined
529}
530
531/** A retired CRITICAL RULES bullet is kept in history.md, so a wrong removal can be put back. */
532function retiredTopic(retired: readonly string[]): TopicAppend[] {
533  return retired.length === 0 ? [] : [{ file: 'history.md', append: `## Retired CRITICAL RULES\n\n${retired.join('\n')}` }]
534}
535
536function tally(ops: Op[], created: boolean, skipped: string[], refused: string[], retired: string[]): Changes {
537  const count = (kind: Op['op']): number => ops.filter(o => o.op === kind).length
538  return { added: count('add'), removed: count('remove'), replaced: count('replace'), created, skipped, refused, retired }
539}
540
541/** Lists every topic file the reply writes under '## Topic Files' when the file does not name it yet. */
542function pointTopics(lines: string[], topics: TopicAppend[], newBullets: string[], refused: string[]): void {
543  for (const { file } of topics) {
544    const start = lines.findIndex(l => isHeading(l, 'Topic Files'))
545    const listed = start !== -1 && lines.slice(start, sectionEnd(lines, start)).some(l => l.includes(file))
546    if (listed) continue
547    const error = addTo(lines, 'Topic Files', `- \`${file}\`.`)
548    if (error === undefined) newBullets.push(`- \`${file}\`.`)
549    else refused.push(`topic ${file}: ${error}`)
550  }
551}
552
553/**
554 * Applies the reply to the current file. A missing file starts from the
555 * skeleton. A remove or replace whose line the file lacks is skipped and
556 * named, and an add whose heading the file lacks is refused and named: the rest
557 * is applied, because one bad op is no reason to lose the others. The result is
558 * not checked here; `validate` checks it.
559 */
560export function apply(project: string, current: string | undefined, reply: Reply): Applied {
561  const lines = linesOf(current ?? skeleton(project))
562  const newBullets: string[] = []
563  const done: Op[] = []
564  const skipped: string[] = []
565  const refused = [...reply.refused]
566  const work: Work = { newBullets, offload: atSoftCap(current), retired: [] }
567  for (const op of reply.ops) {
568    if (op.op !== 'add' && indexOfLine(lines, op.line) === -1) {
569      skipped.push(op.line)
570      continue
571    }
572    const error = applyOp(lines, op, work)
573    if (error !== undefined) refused.push(error)
574    else done.push(op)
575  }
576  if (done.length === 0 && reply.topics.length === 0) return { ok: true, changed: false, skipped, refused }
577  const topics = [...reply.topics, ...retiredTopic(work.retired)]
578  pointTopics(lines, topics, newBullets, refused)
579  const changes = tally(done, current === undefined, skipped, refused, work.retired)
580  return { ok: true, changed: true, text: `${lines.join('\n')}\n`, changes, newBullets, topics }
581}
582
583/** `2 skipped, not in the file: - Walk a backfill…; - Old note…` */
584export function skippedText(skipped: readonly string[]): string {
585  return `${skipped.length} skipped, not in the file: ${skipped.map(l => (l.length > 60 ? `${l.slice(0, 60)}…` : l)).join('; ')}`
586}
587
588/**
589 * Whether this save is a step back from a file that is already over a cap: smaller in both measures and
590 * smaller in at least one. Such a step is written, because a file over a cap can only come back in steps,
591 * and refusing it leaves the project with no save at all.
592 */
593function isShrinkStep(state: Inspection, current: string | undefined): boolean {
594  if (current === undefined) return false
595  const before = inspect(current)
596  if (before.lines < MAX_LINES && before.chars < MAX_CHARS) return false
597  return state.lines <= before.lines && state.chars <= before.chars && (state.lines < before.lines || state.chars < before.chars)
598}
599
600/** Returns why the new file must not be written, or an empty list. `current` is the file before this save. */
601export function validate(text: string, newBullets: string[], current?: string): string[] {
602  const errors: string[] = []
603  const headings = headingsOf(text)
604  if (!hasSections(headings)) errors.push(`sections are not exactly ${SECTIONS.join(', ')} (found: ${foundText(headings)})`)
605  const state = inspect(text)
606  const step = isShrinkStep(state, current)
607  if (state.lines >= MAX_LINES && !step) errors.push(`${state.lines} lines, the limit is under ${MAX_LINES}`)
608  if (state.chars >= MAX_CHARS && !step) errors.push(`${state.chars} characters, the limit is under ${MAX_CHARS}`)
609  const long = newBullets.filter(b => b.length > MAX_BULLET)
610  if (long.length > 0) errors.push(`${long.length} new bullet(s) over ${MAX_BULLET} characters`)
611  return errors
612}
613
614/** The same reply without its adds and topic appends, so a save over a cap still writes its removes. */
615function withoutAdds(reply: Reply): Reply {
616  return { ops: reply.ops.filter(op => op.op !== 'add'), topics: [], refused: reply.refused }
617}
618
619/** The result of the shrinking second pass, or the first pass's errors when that pass writes nothing either. */
620function shrunk(project: string, current: string | undefined, reply: Reply, errors: string[]): Applied {
621  const second = apply(project, current, withoutAdds(reply))
622  if (!second.ok || !second.changed || validate(second.text, second.newBullets, current).length > 0) {
623    return { ok: false, error: `not written: ${errors.join('; ')}` }
624  }
625  const dropped = reply.ops.filter(op => op.op === 'add').length + reply.topics.length
626  const note = `over a cap (${errors.join('; ')}): ${dropped} add(s) and topic append(s) dropped, the removes were written`
627  return { ...second, changes: { ...second.changes, refused: [...second.changes.refused, note] } }
628}
629
630/**
631 * The save to write. A result over a line or character cap is not lost: the adds and topic appends are
632 * dropped and the removes alone are written, so the file comes down instead of the whole save failing.
633 */
634export function fit(project: string, current: string | undefined, reply: Reply): Applied {
635  const first = apply(project, current, reply)
636  if (!first.ok || !first.changed) return first
637  const errors = validate(first.text, first.newBullets, current)
638  return errors.length === 0 ? first : shrunk(project, current, reply, errors)
639}
640
641/** Returns the topic file after the append; a new file gets a title. */
642export function appendTopic(project: string, file: string, existing: string | undefined, append: string): string {
643  const base = existing ?? `# ${project}: ${file.replace(/\.md$/, '')}\n`
644  const sep = base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n'
645  return `${base}${sep}${append.trim()}\n`
646}
647
648export const BACKUP = 'MEMORY.pre-migration.md'
649
650function count(n: number, word: string): string {
651  return `${n} ${word}`
652}
653
654/** One line for the log, naming what the save changed. */
655export function changeText(changes: Changes, topics: TopicAppend[]): string {
656  const parts: string[] = []
657  if (changes.created) parts.push('created')
658  if (changes.added > 0) parts.push(count(changes.added, 'added'))
659  if (changes.removed > 0) parts.push(count(changes.removed, 'removed'))
660  if (changes.replaced > 0) parts.push(count(changes.replaced, 'replaced'))
661  const files = [...new Set(topics.map(t => t.file))]
662  const topicPart = files.length > 0 ? `; appended to ${files.join(', ')}` : ''
663  const skippedPart = changes.skipped.length > 0 ? `; ${skippedText(changes.skipped)}` : ''
664  const refusedPart = changes.refused.length > 0 ? `; refused: ${changes.refused.join('; ')}` : ''
665  const retiredPart = changes.retired.length > 0 ? `; retired from CRITICAL RULES, kept in history.md: ${changes.retired.join('; ')}` : ''
666  return `MEMORY.md: ${parts.join(', ') || 'topic files only'}${topicPart}${skippedPart}${refusedPart}${retiredPart}`
667}
668
669/** How many characters of the last event the sidebar's second line holds. */
670const MAX_EVENT = 120
671
672/**
673 * The last transcript line as the sidebar's second line: the section's own title is left off the
674 * front, and a long line is cut, because the pane holds one row for it.
675 */
676export function eventShort(text: string): string {
677  const body = text.replace(/^MEMORY\.md: /, '')
678  return body.length > MAX_EVENT ? `${body.slice(0, MAX_EVENT - 1)}…` : body
679}
680
681const SHORT_TOPICS = 3
682
683/** The topic files a save appended to, without `.md`: the first three and the count of the rest. */
684function topicNames(topics: TopicAppend[]): string {
685  const names = [...new Set(topics.map(t => t.file.replace(/\.md$/, '')))]
686  const rest = names.length - SHORT_TOPICS
687  return `topic: ${names.slice(0, SHORT_TOPICS).join(', ')}${rest > 0 ? ` +${rest}` : ''}`
688}
689
690/** How the sidebar colours a line or a part of one. */
691export type Tone = 'ok' | 'warn' | 'error' | 'dim'
692export type Part = { text: string; kind?: Tone }
693
694const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
695
696/** Parts with a separator between each two, the separator in the line's own colour. */
697function joinParts(parts: Part[], sep: string): Part[] {
698  return parts.flatMap((p, i) => (i === 0 ? [p] : [part(sep, undefined), p]))
699}
700
701/**
702 * The short form for the status line, in parts: what was written green, and each part the save left
703 * out or a rule it retired yellow, so the person sees a rule leave and can put it back.
704 */
705export function changeParts(changes: Changes, topics: TopicAppend[]): Part[] {
706  const counts: [string, number][] = [
707    ['+', changes.added],
708    ['-', changes.removed],
709    ['~', changes.replaced],
710  ]
711  const written = counts.filter(([, n]) => n > 0).map(([sign, n]) => `${sign}${n}`)
712  if (topics.length > 0) written.push(topicNames(topics))
713  const left: [number, string][] = [
714    [changes.skipped.length, 'skipped'],
715    [changes.refused.length, 'refused'],
716    [changes.retired.length, 'rule(s) retired'],
717  ]
718  const warned = left.filter(([n]) => n > 0).map(([n, what]) => part(`${n} ${what}`, 'warn'))
719  const all = [...(written.length > 0 ? [part(written.join(' '), 'ok')] : []), ...warned]
720  return all.length === 0 ? [part('saved', 'ok')] : joinParts(all, ' ')
721}
722
723/** The short form for the status line. */
724export function changeShort(changes: Changes, topics: TopicAppend[]): string {
725  return changeParts(changes, topics).map(p => p.text).join('')
726}
727
728/** A save that changed no file, faint, with the lines it skipped and the ops it refused yellow. */
729export function noChangeParts(skipped: number, refused: number): Part[] {
730  const counts = [skipped > 0 ? `${skipped} skipped` : '', refused > 0 ? `${refused} refused` : ''].filter(p => p !== '')
731  if (counts.length === 0) return [part('no change', 'dim')]
732  return [part('no change', 'dim'), part(', ', undefined), part(counts.join(', '), 'warn')]
733}
734
735/** An error as `error: <message>`, only the front red. */
736export function errorParts(text: string): Part[] {
737  return [part('error:', 'error'), part(` ${text}`, undefined)]
738}
739
740/** Returns the topic files among a directory's entries: every other `.md` file but the migration backup. */
741export function topicFiles(names: readonly string[]): string[] {
742  return names.filter(n => n.endsWith('.md') && n !== 'MEMORY.md' && n !== BACKUP).sort()
743}
744
745/**
746 * The reply language rule. Long turns whose context is mostly English (tool output, docs, this file)
747 * ended with English replies to Turkish prompts (measured), so the rule names that case.
748 */
749export const LANGUAGE_NOTE = 'Answer in the language of the user\'s own messages, in every reply and every progress line, also at the end of a long turn whose context (tool output, docs, this memory) is in another language. Keep technical terms and identifiers as they are.'
750
751/**
752 * The text the session starts with: the reply language rule, the whole MEMORY.md and the topic files
753 * next to it. The file holds no instruction to write it, because the mod does.
754 */
755export function contextText(project: string, dir: string, memory: string, topics: readonly string[]): string {
756  const head = `[PROJECT MEMORY: ${project}]\n${LANGUAGE_NOTE}\n\n${memory.trim()}`
757  return topics.length === 0 ? head : `${head}\n\nTopic files in ${dir}: ${topics.join(', ')}`
758}
759
760export function clockText(ms: number): string {
761  const d = new Date(ms)
762  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
763}
764