SLOPSHOPPER

context-restore

Hands a skill or command call the current text of its file when the file changed on disk after the session loaded it, and hands the model a rules file or the…

newguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-restore
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-restore ⎿ context-restore: on · every skill and command call is checked against its file, 0 rules file(s) watched ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-restore

You fix a skill, a command or a rules file in the middle of a session, call it again, and the model keeps following the old text. Claude Code loads these files once and hands out that first copy for the rest of the session. This mod notices when such a file changed on disk and makes sure the model gets the current text.

What it does

Measured on Claude Code 2.1.280:

  • The engine loads each skill and command once and hands that copy out at every call, even after the file changed on disk. A typed /name sends the old text, and a Skill tool call answers Skill /<name> is already loaded above; instructions unchanged.
  • A skill that compaction cut is sent whole again by the engine itself when it is called again, typed or through the Skill tool. The mod does nothing in that case.
  • A rules file, or the global ~/.claude/CLAUDE.md, that changes between two prompts does not reach the model until a compaction.
  • A skill's other files (subcommands/*.md, references/*.md) never enter the engine's copy; the model reads them with the Read tool. A copy it read earlier in the session stays in the context after the file changed.

So the mod does three things:

  1. At each call of a skill or command (typed as /name, called through the Skill tool, or preloaded into a subagent), it reads the file the text came from. A skill names its directory in its first line (Base directory for this skill: <dir>), so its file is <dir>/SKILL.md. A command's file is looked up: a plugin's commands/<name>.md for <plugin>:<name>, otherwise the project's or your own commands/<name>.md. A built-in command has no file.
  2. A file with no placeholder is compared with the engine's text. If they differ, the file's text replaces the engine's copy, and the arguments the engine added after it (ARGUMENTS: ...) stay. The model then gets the new text, also on a Skill tool call.
  3. A file whose only placeholder is $ARGUMENTS is compared as a template: every other part word for word, each $ARGUMENTS matching any text. If the engine's text does not fit, the engine's filled-in text stays and the file's current text follows it, with a note saying that it replaces the instructions above and that the arguments above still apply.
  4. A file with another placeholder ($1, ${...}, ` !... `) cannot be compared, because the engine filled it in. If the file was written after the session started, the same note follows.
  5. A skill or command that is not called again is not sent again.
  6. It records every rules file the instructions attachment carries (each starts with Contents of <path> (, and only a path with /rules/ in it counts), plus the global CLAUDE.md (~/.claude/CLAUDE.md, under CLAUDE_CONFIG_DIR when it is set). A project's CLAUDE.md does not count. With each prompt you send, a rules file whose text changed since the session read it reaches the model as a note only the model reads: the file, and its current text, which replaces the earlier one. A file written again with the same text sends nothing, and each change is sent once.
  7. It records every file the main loop's Read tool read, with its last write time. A subagent's reads live in its own context, so they are not recorded. At each call of a skill, the files of that skill's directory that the model read and that were written since reach the model as one line at the end of the call's text, naming them and asking the model to read them again; their text is not sent. The line comes at every call until the model reads the file again. Measured on Claude Code 2.1.282 with a skill whose text asks to read its sub/a.md at every call: the model read the changed file again both with the line and without it, so the line matters for a skill that does not ask for a fresh read at every call.

You see one line per event in the sidebar stream, or in the transcript while the sidebar is closed. The first part is faint and the file names are in the default colour; for a file the model has to read again the names are yellow:

context-restore: changed on disk, the call got the current text: commit context-restore: changed on disk, the new text went to the model: context7.md context-restore: changed on disk since the model read it, the call asks to read again: subcommands/ssrf.md (bug-report)

/context-restore prints the setting, how many rules files are watched, and the last event.

Command

/context-restore the setting, what is watched, and the last event /context-restore on | off on by default

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install context-restore@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.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod if you want the event lines there. Without it they go to the transcript.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=context-restore}, skill.prompt, tool.call{tool=Read}, prompt.attachment{type=instructions}, prompt.submit ❯ ./register.ts calls: $.clock.now, $.command.register, $.env.get, $.fs.exists (via commandFileOf, mtimeOf, pluginDirs), $.fs.read (via changedRules, pluginDirs, readBody, recordRules), $.fs.stat (via mtimeOf), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via setEnabled), $.ui.log ❯ ./register.ts env reads: CLAUDE_CONFIG_DIR, HOME

Reach L1: it reads files.

  1. Reads: the file of each skill and command the session calls, and its last write time; the rules files and the global CLAUDE.md the session read, and their last write time; the last write time of each file the model reads with the Read tool; the host's installed_plugins.json, to find a plugin's command file
  2. Runs: nothing
  3. Sends: to the model, the current text of a called skill or command whose file changed, and the whole text of a read rules file or of the global CLAUDE.md that changed on disk; the names of a called skill's files that changed since the model read them
  4. Persists: in $.store, the on/off setting; the rules and read records live in memory and end with the session
  5. Hostile input: every text sent is a file the session itself uses; a skill or rules file that holds hostile text reaches the model through the engine as well

Limits

  • A file with a placeholder other than $ARGUMENTS counts as changed by its last write time against the session's start. /reload-plugins keeps an unchanged module and its start time, so a plugin updated and reloaded mid-session reads as changed at every call of such a file.
  • A $ARGUMENTS template fits loosely: a file that changed from Old $ARGUMENTS to $ARGUMENTS fits every engine text, so that change goes unnoticed.
  • A file with placeholders gets its current text after the engine's text, with the placeholders not filled in.
  • A command whose file is in neither place the mod looks (a --plugin-dir plugin, a command in a subdirectory of commands/) keeps the engine's text.
  • A changed rules file reaches the model with your next prompt, not at the moment it is written. When a compaction comes between the change and the next prompt, the engine sends the file too.
  • /reload-plugins makes the engine send the instruction files again with their new text, and the reloaded module takes that as its starting text. After an edit followed by a reload the mod therefore sends nothing, because the engine already did; it matters for an edit with no reload or restart in between.
  • A project's CLAUDE.md is not watched, only the global one.
  • The global CLAUDE.md goes to the model whole at every change, about 5k tokens for a 21 KB file.
  • A changed skill file the model read is named only at the skill's next call, not between calls. skill.prompt does not say which loop called the skill, so a skill preloaded into a subagent can get the line for a file only the main loop read.

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 237 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { baseName, bodyOfFile, changedLog, changedNote, currentLog, currentText, rereadLog, rereadNote, rulePathsOf, sectionKey, skillDirOf, skillFileOf, skillFilesIn, statusText, type Line } from './restore.ts'
3
4const ENABLED_KEY = 'enabled'
5const CONSUMER = 'context-restore'
6const USAGE = 'expects nothing (the status), on or off'
7
8/**
9 * The on/off setting, the directory the session started in, the host's config directory, when the session
10 * started, every rules file it read with the time it was last written and its text, every file the main
11 * loop's Read tool read with the time it was last written then, and the last thing the mod did.
12 */
13type State = { enabled: boolean; root: string; config: string; startedAt: number; rules: Map<string, { at: number; text: string }>; reads: Map<string, number>; last?: string }
14
15/**
16 * Reads the on/off setting from the store, which every window shares, so a change made in another
17 * window applies here at the next hook that acts on it.
18 */
19async function readSettings($: EngineInterface, state: State): Promise<void> {
20  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
21}
22
23function errorText(err: unknown): string {
24  return err instanceof Error ? err.message : String(err)
25}
26
27/** What the mod did, told to the person: the sidebar while it is open, else one transcript line. */
28async function toPerson($: EngineInterface, state: State, line: Line): Promise<void> {
29  state.last = line.text
30  try {
31    if (await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(line.text), title: 'context restored', lines: [line], until: 'stream' })) return
32  } catch {
33    // The sidebar mod is not installed.
34  }
35  $.ui.log(line.text)
36}
37
38/** The last write of a file, or undefined when it is not there. */
39async function mtimeOf($: EngineInterface, path: string): Promise<number | undefined> {
40  if (!(await $.fs.exists(path))) return undefined
41  return (await $.fs.stat(path)).mtimeMs
42}
43
44/** The directories a plugin is installed in, from the host's install record. */
45async function pluginDirs($: EngineInterface, state: State, plugin: string): Promise<string[]> {
46  const record = `${state.config}/plugins/installed_plugins.json`
47  if (!(await $.fs.exists(record))) return []
48  const all = (JSON.parse(String(await $.fs.read(record))) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
49  return Object.entries(all)
50    .filter(([id]) => id.startsWith(`${plugin}@`))
51    .flatMap(([, rows]) => rows.map(r => r.installPath).filter((p): p is string => typeof p === 'string'))
52}
53
54/**
55 * The file a command came from: a plugin's `commands/<name>.md` for `<plugin>:<name>`, else the project's
56 * or the person's `commands/<name>.md`. A built-in command has none.
57 */
58async function commandFileOf($: EngineInterface, state: State, name: string): Promise<string | undefined> {
59  const colon = name.indexOf(':')
60  const candidates = colon > 0
61    ? (await pluginDirs($, state, name.slice(0, colon))).map(dir => `${dir}/commands/${name.slice(colon + 1)}.md`)
62    : [`${state.root}/.claude/commands/${name}.md`, `${state.config}/commands/${name}.md`]
63  for (const path of candidates) if (await $.fs.exists(path)) return path
64  return undefined
65}
66
67/** A file's text as the engine hands it to the model. */
68async function readBody($: EngineInterface, path: string): Promise<string> {
69  const text = String(await $.fs.read(path))
70  return path.endsWith('/SKILL.md') ? bodyOfFile(text, path.slice(0, -'/SKILL.md'.length)) : bodyOfFile(text)
71}
72
73/**
74 * The text one call of a skill or command should carry: the file's current text when the engine handed an
75 * older copy it loaded at the start, else undefined. A skill names its directory, a command is looked up.
76 */
77async function fresherText($: EngineInterface, state: State, name: string, text: string): Promise<string | undefined> {
78  const file = skillFileOf(text) ?? (await commandFileOf($, state, name))
79  if (file === undefined) return undefined
80  const at = await mtimeOf($, file)
81  if (at === undefined) return undefined
82  return currentText(text, await readBody($, file), file, at > state.startedAt)
83}
84
85/**
86 * The files of a skill's directory the main loop read and that were written since, relative to the directory.
87 * A file read again takes a new time, so it is named until the model reads it again.
88 */
89async function changedSinceRead($: EngineInterface, state: State, dir: string): Promise<string[]> {
90  const out: string[] = []
91  for (const file of skillFilesIn(state.reads.keys(), dir)) {
92    const at = await mtimeOf($, `${dir}/${file}`)
93    if (at !== undefined && at !== state.reads.get(`${dir}/${file}`)) out.push(file)
94  }
95  return out
96}
97
98/** A skill's text with the note to read its changed files again, or the text unchanged when none changed. */
99async function withRereadNote($: EngineInterface, state: State, skill: string, text: string): Promise<string> {
100  const dir = skillDirOf(text)
101  const files = dir === undefined ? [] : await changedSinceRead($, state, dir)
102  if (files.length === 0) return text
103  await toPerson($, state, rereadLog(skill, files))
104  return `${text.trimEnd()}\n\n${rereadNote(files)}`
105}
106
107/** The text one skill or command call should carry: the file's current text, then the note on changed files the model read. */
108async function callText($: EngineInterface, state: State, skill: string, text: string): Promise<string> {
109  const fresher = await fresherText($, state, skill, text)
110  if (fresher !== undefined) await toPerson($, state, currentLog(skill))
111  return withRereadNote($, state, skill, fresher ?? text)
112}
113
114/** Records a file the main loop's Read tool read, with the time it was last written. */
115async function recordRead($: EngineInterface, state: State, path: string): Promise<void> {
116  const at = await mtimeOf($, path)
117  if (at === undefined) state.reads.delete(path)
118  else state.reads.set(path, at)
119}
120
121/** Records the rules files and the global CLAUDE.md the session read, with the time each was last written and its text. */
122async function recordRules($: EngineInterface, state: State, text: string): Promise<void> {
123  for (const path of rulePathsOf(text, `${state.config}/CLAUDE.md`)) {
124    const at = await mtimeOf($, path)
125    if (at !== undefined) state.rules.set(path, { at, text: String(await $.fs.read(path)) })
126  }
127}
128
129/** One rules file that changed on disk: its label, its path and its new text. */
130type Change = { label: string; path: string; text: string }
131
132/**
133 * The rules files whose text changed on disk since the session read them; each record takes the new time
134 * and text. A file written again with the same text (an editor's save, a checkout) sends nothing, because
135 * its whole text would reach the model for no change (measured: a touched CLAUDE.md sent 21 KB).
136 */
137async function changedRules($: EngineInterface, state: State): Promise<Change[]> {
138  const out: Change[] = []
139  for (const [path, seen] of state.rules) {
140    const at = await mtimeOf($, path)
141    if (at === undefined || at === seen.at) continue
142    const text = String(await $.fs.read(path))
143    state.rules.set(path, { at, text })
144    if (text !== seen.text) out.push({ label: baseName(path), path, text })
145  }
146  return out
147}
148
149/** The notes for every rules file that changed on disk, and one line to the person naming them. */
150async function changeNotes($: EngineInterface, state: State): Promise<string[]> {
151  let changes: Change[]
152  try {
153    changes = await changedRules($, state)
154  } catch (err) {
155    $.ui.log(`a changed rules file was not read, so the model keeps its earlier text: ${errorText(err)}`)
156    return []
157  }
158  if (changes.length === 0) return []
159  await toPerson($, state, changedLog(changes.map(c => c.label)))
160  return changes.map(c => changedNote(c.label, c.path, c.text))
161}
162
163async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
164  state.enabled = on
165  await $.store.set(ENABLED_KEY, on)
166  return on ? 'on: a call of a changed skill or command gets its current text, and a changed rules file reaches the model' : 'off: the engine\'s text stays as it is'
167}
168
169async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
170  const word = args.trim()
171  if (word === 'on' || word === 'off') return setEnabled($, state, word === 'on')
172  if (word !== '') return USAGE
173  await readSettings($, state)
174  return statusText(state.enabled, state.rules.size, state.last)
175}
176
177export const register: Register = on => {
178  const state: State = { enabled: true, root: '', config: '', startedAt: 0, rules: new Map(), reads: new Map() }
179
180  on('session.start', async ($, e, next) => {
181    const r = await next(e)
182    state.startedAt = await $.clock.now()
183    await readSettings($, state)
184    state.root = e.cwd
185    state.config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
186    await $.command.register({ name: 'context-restore', description: 'The current text of a changed skill, command or rules file: status, on, off (context-restore)', argumentHint: '[on | off]', immediate: true })
187    return r
188  })
189
190  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
191  on('command.run', { command: 'context-restore' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
192
193  // The engine loads each skill and command once and hands that copy at every call, also after its file changed.
194  on('skill.prompt', async ($, e, next) => {
195    const r = await next(e)
196    await readSettings($, state)
197    if (!state.enabled) return r
198    try {
199      const text = await callText($, state, e.skill, r.text)
200      return text === r.text ? r : { ...r, text }
201    } catch (err) {
202      $.ui.log(`the file of ${e.skill} was not read, so the call keeps the engine's text: ${errorText(err)}`)
203      return r
204    }
205  })
206
207  // A subagent's reads live in its own context, so only the main loop's count.
208  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
209    const r = await next(e)
210    if (e.agentId !== undefined || r.deny !== undefined || r.isError === true) return r
211    await readSettings($, state)
212    if (!state.enabled) return r
213    try {
214      await recordRead($, state, e.file_path)
215    } catch (err) {
216      $.ui.log(`the write time of ${e.file_path} was not read, so a change to it is not noticed: ${errorText(err)}`)
217    }
218    return r
219  })
220
221  on('prompt.attachment', { type: 'instructions' }, async ($, e, next) => {
222    await readSettings($, state)
223    if (state.enabled) await recordRules($, state, e.text)
224    return next(e)
225  })
226
227  // The notes go to the model alone; the person reads one line naming the files.
228  on('prompt.submit', async ($, e, next) => {
229    // No rules file was recorded, so there is nothing to compare.
230    if (state.rules.size === 0) return next(e)
231    await readSettings($, state)
232    if (!state.enabled) return next(e)
233    const notes = await changeNotes($, state)
234    return notes.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), ...notes] })
235  })
236}
237
hooks/restore.ts 152 lines
1/** How the engine expands a skill or command file for the model, and the texts this mod writes. */
2
3const BASE_DIR = /^Base directory for this skill: ([^\n]+)/
4
5/** The directory a skill's text names in its first line, or undefined for a text that names none. */
6export function skillDirOf(text: string): string | undefined {
7  return BASE_DIR.exec(text)?.[1]
8}
9const FRONTMATTER = /^---\n[\s\S]*?\n---\n+/
10const RULE_HEADER = /^Contents of (.+?) \(/gm
11/** What the engine fills in when it expands a file: its arguments, a `${...}` variable, or a shell command's output. */
12const PLACEHOLDER = /\$ARGUMENTS|\$\d|\$\{|!`/
13/** The placeholders a file cannot be read back from: a positional argument, a variable, a shell command's output. */
14const OPAQUE_PLACEHOLDER = /\$\d|\$\{|!`/
15const ARGUMENTS = '$ARGUMENTS'
16/** What the engine adds after a file with no placeholder when the call carries arguments (measured on 2.1.280). */
17const ARGUMENTS_TAIL = '\n\nARGUMENTS: '
18
19/** The SKILL.md a skill's text names in its first line, or undefined for a text that names none. */
20export function skillFileOf(text: string): string | undefined {
21  const dir = skillDirOf(text)
22  return dir === undefined ? undefined : `${dir}/SKILL.md`
23}
24
25/** A skill or command file as the engine hands it to the model: no frontmatter, a skill with its directory first. */
26export function bodyOfFile(fileText: string, dir?: string): string {
27  const body = fileText.replace(FRONTMATTER, '')
28  return dir === undefined ? body : `Base directory for this skill: ${dir}\n\n${body}`
29}
30
31/** The note that follows the engine's text when the file changed and holds text the engine fills in. */
32export function appendedNote(path: string, body: string): string {
33  return `context-restore: ${path} changed on disk after this session loaded it. Its current text follows, with its placeholders not filled in. It replaces the instructions above; the arguments above still apply.\n\n${body}`
34}
35
36/** The engine's text of a file with no placeholder, split before the `ARGUMENTS:` part it adds for a call with arguments. */
37function splitArguments(engineText: string): { head: string; tail: string } {
38  const at = engineText.lastIndexOf(ARGUMENTS_TAIL)
39  return at < 0 ? { head: engineText, tail: '' } : { head: engineText.slice(0, at), tail: engineText.slice(at) }
40}
41
42/**
43 * Whether the engine's text is the file with each `$ARGUMENTS` filled in: every other part word for word,
44 * each `$ARGUMENTS` any text.
45 */
46export function fitsArguments(engineText: string, fileBody: string): boolean {
47  const parts = fileBody.trimEnd().split(ARGUMENTS).map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
48  return new RegExp(`^${parts.join('[\\s\\S]*')}$`).test(engineText.trimEnd())
49}
50
51/** The engine's filled text with the file's current text after it. */
52function withNote(engineText: string, fileBody: string, path: string): string {
53  return `${engineText.trimEnd()}\n\n${appendedNote(path, fileBody)}`
54}
55
56/**
57 * The text the model reads for one call of a skill or command whose file holds placeholders. A file whose only
58 * placeholder is `$ARGUMENTS` is compared as a template, so the note follows exactly when the engine's copy
59 * differs. Any other placeholder cannot be read back, so the note follows once the file was written after
60 * the session started.
61 */
62function placeholderText(engineText: string, fileBody: string, path: string, writtenSinceStart: boolean): string | undefined {
63  if (!OPAQUE_PLACEHOLDER.test(fileBody)) return fitsArguments(engineText, fileBody) ? undefined : withNote(engineText, fileBody, path)
64  return writtenSinceStart ? withNote(engineText, fileBody, path) : undefined
65}
66
67/**
68 * The text the model reads for one call of a skill or command, when the engine's copy is older than the file.
69 * A file with no placeholder is compared as it is, and its text takes the place of a different engine text,
70 * with the call's arguments kept. A file with placeholders keeps the engine's filled text, with the new file
71 * text after it (`placeholderText`). Undefined: the engine's text is current.
72 */
73export function currentText(engineText: string, fileBody: string, path: string, writtenSinceStart: boolean): string | undefined {
74  if (PLACEHOLDER.test(fileBody)) return placeholderText(engineText, fileBody, path, writtenSinceStart)
75  const { head, tail } = splitArguments(engineText)
76  if (fileBody.trimEnd() === head.trimEnd()) return undefined
77  return tail === '' ? fileBody : `${fileBody.trimEnd()}\n${tail}`
78}
79
80/**
81 * The watched files an `instructions` attachment carries, by the `Contents of <path> (` line each starts
82 * with: every rules file, and the user's global CLAUDE.md. A project's CLAUDE.md is not watched.
83 */
84export function rulePathsOf(text: string, globalFile: string): string[] {
85  return [...text.matchAll(RULE_HEADER)].map(m => m[1] ?? '').filter(p => p.includes('/rules/') || p === globalFile)
86}
87
88/** The files a skill's directory holds besides its SKILL.md, out of `paths`, relative to the directory. */
89export function skillFilesIn(paths: Iterable<string>, dir: string): string[] {
90  return [...paths].filter(p => p.startsWith(`${dir}/`) && p !== `${dir}/SKILL.md`).map(p => p.slice(dir.length + 1))
91}
92
93/** The line a skill call ends with when files of its directory the model read changed on disk since. */
94export function rereadNote(files: readonly string[]): string {
95  return `context-restore: these files of this skill's base directory changed on disk after this session read them, so the copies read earlier are out of date; read them again before you use them: ${files.join(', ')}`
96}
97
98/** How the sidebar colours a line or a part of one. */
99type Tone = 'ok' | 'warn' | 'error' | 'dim'
100export type Part = { text: string; kind?: Tone }
101/** A sidebar line, as the sidebar mod's contract names it; `text` holds the whole line for a sidebar that draws no parts. */
102export type Line = { text: string; kind?: Tone; parts?: Part[] }
103
104const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
105
106/** A line made of parts, its `text` their texts joined. */
107const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
108
109/**
110 * A line the person reads: what the mod did about files that changed on disk, faint, and which files, in
111 * `tone`. Its `text` is the transcript line.
112 */
113function onDisk(done: string, names: readonly string[], tone?: Tone, tail: Part[] = []): Line {
114  return partsLine([part(`changed on disk${done}: `, 'dim'), part(names.join(', '), tone), ...tail])
115}
116
117/** The line the person reads when a skill call asked the model to read changed files again; the files are yellow. */
118export function rereadLog(skill: string, files: readonly string[]): Line {
119  return onDisk(' since the model read it, the call asks to read again', files, 'warn', [part(` (${skill})`, 'dim')])
120}
121
122/** The last part of a path, as the texts name a file. */
123export function baseName(path: string): string {
124  return path.slice(path.lastIndexOf('/') + 1)
125}
126
127/** The line the person reads when a call got the file's current text in place of the engine's older copy. */
128export function currentLog(name: string): Line {
129  return onDisk(', the call got the current text', [name])
130}
131
132/** The line the person reads when rules files changed on disk: what the model was handed again. */
133export function changedLog(labels: readonly string[]): Line {
134  return onDisk(', the new text went to the model', labels)
135}
136
137/** The note the model reads for one rules file that changed on disk after the session read it. */
138export function changedNote(label: string, path: string, text: string): string {
139  return `context-restore: ${label} (${path}) changed on disk after this session read it. Its current text follows and replaces the earlier one; follow it from now on.\n\n${text}`
140}
141
142/** A sidebar section key: the subject cut to what the sidebar takes. */
143export function sectionKey(text: string): string {
144  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'restore'
145}
146
147/** The `/context-restore` answer: the setting, what is watched, and the last thing done. */
148export function statusText(enabled: boolean, rules: number, last: string | undefined): string {
149  const done = last === undefined ? '' : ` · last: ${last}`
150  return `${enabled ? 'on' : 'off'} · every skill and command call is checked against its file, ${rules} rules file(s) watched${done}`
151}
152