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.

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.
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.
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):
~/.cli-tweaks/memory/<project>/MEMORY.md, when the file exists.$.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.history.md.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.
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.
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
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.
+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.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.+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..md, has no directory part and is not memory.md.## section lines is refused alone, so the template cannot break; a ### subheading may still go.## 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.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.
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
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.~/.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./clear and compaction.The mod has no command. To stop the saves, disable it: claude plugin disable memory-save@kilimcininkoroglu-mods.
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.
/reload-plugins, an enable) loads the memory at the next /clear, compaction or session.claude plugin test cannot raise classic.SessionStart. The load is covered by unit tests of its text and by a live session check.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
hooks/register.ts 319 lines1import 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}
319hooks/memory.ts 764 lines1/**
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