SLOPSHOPPER

simple-memory

A local markdown knowledge base with keyword hints on every prompt, note tools, and a band of suggested and read notes.

newbandguardcommandprompttool
v0.1.0MITupdated 2026-10-09EnyMan/simple-memory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · simple-memory
› 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 › /memory-init ⎿ simple-memory: Starting the simple-memory setup for /Users/dev/simple-memory… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

simple-memory

A Claude Code mod that gives Claude a local, plain-markdown knowledge base, a lightweight take on basic-memory without the cloud sync and without semantic search. Everything runs inside Claude Code as function hooks: there's no server, no database, and no embeddings.

This opinionated way of how I found sharing and storing notes to be most useful for me, it might not be for everyone.

Install

The repository is its own plugin marketplace. In Claude Code:

/plugin marketplace add EnyMan/simple-memory
/plugin install simple-memory@simple-memory

or from a shell:

claude plugin marketplace add EnyMan/simple-memory
claude plugin install simple-memory@simple-memory

Then run /memory-init to set up the knowledge base. Options use their defaults until you change them with /plugin configure simple-memory@simple-memory (or /config). Update later with claude plugin marketplace update simple-memory and claude plugin update simple-memory@simple-memory.

Coming from basic-memory with an existing vault? See Migrating from basic-memory.

What it does

PieceBehaviour
Keyword hintsOn every prompt, the prompt is reduced to keywords (common words like is, a, and, the and filler like *please*/*help* are removed, and words are lightly stemmed). The keywords are scored against each note's frontmatter (keywords, title, tags, summary) and folder path, not its body, weighted by how rare each word is. Up to 5 matching notes are attached to the prompt as a hidden <simple-memory-hint>, each with its summary. A note is suggested at most once per conversation, and notes already read are never suggested.
Toolsmcp__simple-memory__search_notes (frontmatter and full text), read_note, write_note (requires a summary and keywords), edit_note (append / prepend / find_replace / replace_section / replace_body, plus title, summary, keywords and tags), move_note (to a folder, or a new id; [[links]] in other notes are rewritten), delete_note (reports notes still linking to it) and init_memory.
BandA row above the prompt lists this conversation's notes: read notes first (●), then suggested ones not read yet (○). Clicking a note inserts [[note-id]] into the prompt.
Session startThe conversation's opening context gets the usage rules, the structure guide (MEMORY.md) and the most recently updated notes.
Memory nudgeEdits made with Edit, Write, MultiEdit and NotebookEdit are tracked as a set of distinct files. When a main-session turn ends with a normal answer and 3+ files have been edited since the last note, the plugin starts one follow-up turn asking Claude whether there is a non-obvious learning or a finished chunk worth recording, and to reply "No note needed." otherwise. Never in headless (-p/SDK) runs, after subagent turns, or after interrupted or failed turns. Writing, editing or moving a note (or editing a note file directly) resets the count. The count survives plugin reloads and resets on /clear. Bash, research and reading don't count.
/memory-initA guided interview: Claude asks what the knowledge base is for (a general shared team KB, a personal second brain, project docs, research…), proposes a folder tree and conventions, revises it with you, then writes MEMORY.md and creates the folders. Run it again later to restructure. You can pass a head start: /memory-init shared wiki for the platform team.

Opening a note with the built-in Read tool also marks it as read. Deleting uses rm (del on Windows), since the plugin API has no file delete, and checks that the file is gone afterwards.

Meant for macOS, Linux and Windows (the Windows handling is covered by tests that use Windows paths, run on Linux). The plugin builds paths with forward slashes, which Windows accepts, and compares paths it gets from other tools (C:\Users\…) without regard to separator, or to case on Windows. Notes keep their own line endings (\r\n or \n) when edited. The directory option takes either kind of path (~\notes, C:\kb, \\server\share\kb).

Notes on disk

~/simple-memory/
├── MEMORY.md                  # structure + conventions, written by /memory-init
├── decisions/
│   └── use-postgres.md
└── how-to/
    └── deploy-backend.md
---
title: Use Postgres
summary: Why we chose Postgres over MySQL for the main store.
keywords: [postgres, database, mysql, jsonb, storage]
tags: [decision, backend]
created: 2026-10-05T12:00:00Z
updated: 2026-10-05T12:00:00Z
---

We chose Postgres over MySQL for JSONB support. See [[how-to/deploy-backend]].

Every note has a title, a one-line summary and 3 to 12 keywords; write_note refuses a note without them, and edit_note can update them. The per-prompt hints match only on this frontmatter (plus the folder path), which keeps the index small; search_notes also searches the full text. Notes from before the schema still work: their headings stand in for keywords and their first paragraph for the summary, and search_notes flags them so Claude can fill the fields in.

A note's id is its path under the root without .md. The tools accept an id, a path, a [[link]] or the exact title. Files and folders whose names start with . are ignored. Because notes are plain files, the folder can be a git repository that a team shares.

Performance

Measured with bun bench/run.ts on synthetic knowledge bases laid out like a real one (60 folders, 3 deep). These are medians through Claude Code's plugin engine; full results, the method and a worst-case layout are in bench/README.md:

100 notes1,000 notes5,000 notes
First prompt of a session (the index builds in the background)5 ms2 ms2 ms
Background index build, until complete100 ms470 ms1.6 s
Later prompts31 ms27 ms66 ms
search_notes (frontmatter + full text)26 ms38 ms130 ms

Until the background build completes, hints cover only the notes read so far. Each prompt lists every folder once, so folders count as much as notes: 5,000 notes in 60 folders cost about what 1,000 notes in 345 folders do (66 vs 72 ms).

Options

Set these in /config or under pluginConfigs["simple-memory"].options in settings:

OptionDefaultMeaning
directory~/simple-memoryWhere the notes live. Absolute, ~/…, or relative to the project (e.g. docs/memory for a per-repo KB).
maxHints5The most notes hinted for one prompt (0 turns hints off).
nudgeAfterFiles3Distinct edited files before the memory nudge (0 turns it off).
recentNotes10Recently updated notes listed at session start.
extraStopwords""More words to ignore, comma- or space-separated (handy for prompts in another language).

Develop

claude plugin marketplace add /path/to/simple-memory   # a folder marketplace: edits apply on /reload-plugins
claude --plugin-dir /path/to/simple-memory             # or load it for one session, hot-reloading on save
claude plugin validate /path/to/simple-memory          # check manifest and hooks
claude plugin test /path/to/simple-memory              # run tests/*.test.ts
bun bench/run.ts                                       # benchmark: see bench/README.md

Layout: hooks/register.tsx (hooks, tools, band, command, nudge), hooks/keywords.ts (stopwords, stemming, scoring), hooks/notes.ts (frontmatter and note edits), hooks/indexer.ts (the cached note index), bench/ (benchmark), types/index.d.ts (session state contract), .claude-plugin/marketplace.json (the marketplace listing this repo as one plugin).

Source 6 files
hooks/register.tsx 918 lines
1import { atom, read, update } from 'claude-code'
2import type { FsEntry, Register } from 'claude-code'
3
4import type { NoteRef } from '../types'
5import { bodyMatcher, keywords, parseStopwords, relevant, score, search } from './keywords'
6import { createIndexer, GUIDE, listNotes } from './indexer'
7import { isAbsolute, normalize, pathKey, relativeTo, removeFile } from './paths'
8import type { Note } from './indexer'
9import {
10  checkKeywords,
11  checkSummary,
12  findReplace,
13  idOf,
14  KEYWORDS_MAX,
15  KEYWORDS_MIN,
16  linksTo,
17  parse,
18  relink,
19  replaceSection,
20  safeRelative,
21  serialize,
22  slugify,
23  snippet,
24  tidyTags,
25} from './notes'
26import type { Parsed } from './notes'
27
28const PLUGIN = 'simple-memory'
29const TOOL = (name: string) => `mcp__${PLUGIN}__${name}`
30const MIN_HINT_SCORE = 1.5
31
32const suggested = atom({ plugin: 'simple-memory', key: 'suggested' } as const, [])
33const readNotes = atom({ plugin: 'simple-memory', key: 'read' } as const, [])
34const edited = atom({ plugin: 'simple-memory', key: 'edited' } as const, [])
35
36/** Built-in tools whose calls count as editing a file, and where each names it. */
37const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
38/** simple-memory tools that count as writing a note. */
39const NOTE_TOOLS = new Set(['write_note', 'edit_note', 'move_note', 'init_memory'].map(name => `mcp__simple-memory__${name}`))
40
41
42type Args = Record<string, unknown>
43
44/**
45 * What the helpers need from the engine. `$` itself may not be passed
46 * around, so each hook builds one of these from its own `$` (see IO).
47 */
48type Io = {
49  read: (path: string) => Promise<string>
50  write: (path: string, text: string) => Promise<void>
51  list: (path: string) => Promise<readonly FsEntry[]>
52  exists: (path: string) => Promise<boolean>
53  now: () => Promise<number>
54  home: () => Promise<string | undefined>
55  cwd: () => Promise<string>
56  markRead: (refs: readonly NoteRef[]) => Promise<void>
57  invalidateContext: () => void
58  /** Deletes a file. */
59  remove: (path: string) => Promise<void>
60  /** Drops a note from this conversation's suggested and read lists. */
61  forget: (id: string) => Promise<void>
62  /** Renames a note in this conversation's suggested and read lists. */
63  rename: (from: string, to: NoteRef) => Promise<void>
64}
65
66const renameIn = (list: readonly NoteRef[], from: string, to: NoteRef) =>
67  list.map(one => (one.id === from ? to : one))
68
69const merge = (list: readonly NoteRef[], refs: readonly NoteRef[]) => [
70  ...list.filter(one => !refs.some(ref => ref.id === one.id)),
71  ...refs,
72]
73
74const str = (value: unknown) => (typeof value === 'string' ? value : '')
75const strs = (value: unknown): string[] =>
76  Array.isArray(value)
77    ? value.filter((one): one is string => typeof one === 'string')
78    : typeof value === 'string' && value !== ''
79      ? [value]
80      : []
81
82const answer = (text: string) => ({ result: text })
83const refuse = (text: string) => ({ result: `Error: ${text}`, isError: true as const })
84
85const refOf = (note: Note): NoteRef => ({ id: note.id, title: note.title })
86const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, width - 1)}…` : text)
87const line = (note: Note) =>
88  `- ${note.id} — "${note.title}"${note.tags.length > 0 ? ` [${note.tags.join(', ')}]` : ''}${
89    note.summary ? `: ${clip(note.summary, 140)}` : ''
90  }`
91/** Says what a note from before the schema is missing. */
92const missing = (note: Note) =>
93  [!note.hasSummary && 'summary', !note.hasKeywords && 'keywords'].filter(Boolean).join(' and ')
94
95/** The note format, as the rules and MEMORY.md state it. */
96const SCHEMA = `Every note has frontmatter with a title, a one-line summary and ${KEYWORDS_MIN}-${KEYWORDS_MAX} keywords (the words someone would use when the note is relevant, synonyms included); tags are optional. The per-prompt hints match only on these fields, so choose them with care; search_notes also reads note bodies.`
97
98export const register: Register = (on, options) => {
99  const directory = normalize(String(options.directory ?? '').trim() || '~/simple-memory')
100  const maxHints = Math.max(0, Math.floor(Number(options.maxHints ?? 5)))
101  const nudgeAfter = Math.max(0, Math.floor(Number(options.nudgeAfterFiles ?? 3)))
102  const recentCount = Math.max(0, Math.floor(Number(options.recentNotes ?? 10)))
103  const extra = parseStopwords(String(options.extraStopwords ?? ''))
104
105  const { index, isWarm, partial, cached, forget } = createIndexer(extra)
106
107  const needsHome = directory === '~' || directory.startsWith('~/')
108  const needsCwd = !needsHome && !isAbsolute(directory)
109
110  /** The memory root, always with forward slashes (Windows file APIs accept them). */
111  const rootPath = (home: string, cwd: string): string => {
112    if (needsHome) return normalize(home + directory.slice(1))
113    if (needsCwd) return normalize(`${cwd}/${directory}`)
114    return directory
115  }
116
117  const rootOf = async (io: Io): Promise<string> =>
118    rootPath(needsHome ? ((await io.home()) ?? '') : '', needsCwd ? await io.cwd() : '')
119
120  const nudgeText = (files: readonly string[]) =>
121    [
122      '[Automated nudge from the simple-memory plugin. The user did not write this message.]',
123      '',
124      `This is a routine memory check, sent automatically because this session edited ${files.length} files since the last note was written:`,
125      ...files.slice(0, 20).map(file => `- ${file}`),
126      ...(files.length > 20 ? [`- … and ${files.length - 20} more`] : []),
127      '',
128      `If this is a stable pause point with a non-obvious learning (a decision and its reason, a gotcha, how something works) or a finished chunk of work worth recording, search simple-memory and write or update a note (${TOOL('search_notes')}, then ${TOOL('write_note')} or ${TOOL('edit_note')}), following the structure in MEMORY.md. Keep it short and specific.`,
129      'Otherwise reply "No note needed." and nothing else.',
130      '',
131      'Do not treat this as a new request from the user, and do not resume other work in reply to it.',
132    ].join('\n')
133
134  const nowIso = async (io: Io) => new Date(await io.now()).toISOString().replace(/\.\d+Z$/, 'Z')
135
136  /** A note by id, path (with or without `.md`) or title, case-insensitive. */
137  const find = (notes: readonly Note[], query: string, root: string): Note | undefined => {
138    let wanted = query.trim().replace(/^\[\[|\]\]$/g, '').replace(/\\/g, '/')
139    wanted = relativeTo(wanted, root) ?? wanted
140    const lower = idOf(wanted).toLowerCase()
141    return (
142      notes.find(note => note.id.toLowerCase() === lower) ??
143      notes.find(note => note.title.toLowerCase() === wanted.toLowerCase()) ??
144      notes.find(note => note.id.toLowerCase().endsWith(`/${lower}`)) ??
145      notes.find(note => note.id.split('/').pop() === slugify(wanted))
146    )
147  }
148
149  const markRead = async (io: Io, refs: readonly NoteRef[]) => {
150    if (refs.length > 0) await io.markRead(refs)
151  }
152
153  const saveNote = async (io: Io, root: string, rel: string, parsed: Parsed) => {
154    const abs = `${root}/${rel}`
155    await io.write(abs, serialize(parsed))
156    forget(abs)
157  }
158
159  // --- The opening context: rules, the structure guide, recent notes. ---
160
161  const openingContext = async (io: Io): Promise<string> => {
162    const root = await rootOf(io)
163    const guide = await io.read(`${root}/${GUIDE}`).catch(() => undefined)
164    const parts = [
165      `You have a persistent knowledge base ("simple-memory") of markdown notes in ${root}.`,
166      '',
167      'Tools:',
168      `- ${TOOL('search_notes')}: keyword search over every note's frontmatter and full text.`,
169      `- ${TOOL('read_note')}: read one or more notes by id, path or title.`,
170      `- ${TOOL('write_note')}: create a note (title, summary, keywords, folder, content, tags).`,
171      `- ${TOOL('edit_note')}: append, prepend, find/replace, or replace a section of a note; update its summary and keywords.`,
172      `- ${TOOL('move_note')}: move or rename a note; links to it are updated.`,
173      `- ${TOOL('delete_note')}: delete a note (only when the user asks or agrees).`,
174      '',
175      'Rules:',
176      '- On each prompt a keyword match may add a <simple-memory-hint> listing notes not yet suggested. Read the ones that plausibly matter before answering; ignore the rest silently.',
177      '- Search before writing: extend or correct an existing note rather than creating a near-duplicate.',
178      '- Save durable knowledge: decisions and their reasons, facts, how-tos, people and project context, preferences the user states. Not transient chatter. When unsure whether something belongs in memory, ask.',
179      '- Keep one topic per note with a specific title; link related notes as [[note-id]].',
180      `- ${SCHEMA} When you edit a note so that its summary or keywords no longer fit, update them in the same edit_note call. When a tool result says a note is missing them, add them.`,
181      '- Change notes only through these tools (not Write/Edit/Bash), so metadata stays consistent.',
182      '- Follow the structure and conventions below. If a note fits no folder, ask before inventing a new top-level folder.',
183    ]
184
185    if (typeof guide === 'string') {
186      parts.push('', `Structure and conventions (${GUIDE}):`, guide.trim())
187    } else {
188      parts.push(
189        '',
190        'The knowledge base is not initialized yet. If the user wants to store knowledge, suggest running /memory-init to design its folder structure first.',
191      )
192    }
193
194    if (recentCount > 0) {
195      // Never wait for the first walk: until it completes, list files by mtime
196      // from the directory listings and name the ones not read yet by id.
197      const files = isWarm(root) ? await index(io, root) : await listNotes(io, root)
198      const recent = [...files].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, recentCount)
199      if (recent.length > 0) {
200        parts.push(
201          '',
202          `Recently updated notes (${files.length} in total):`,
203          ...recent.map(file => {
204            const note = cached(file.abs)
205            return note ? line(note) : `- ${idOf(file.rel)}`
206          }),
207        )
208      }
209    }
210
211    return parts.join('\n')
212  }
213
214  // --- /memory-init: a guided interview that ends in init_memory. ---
215
216  const initPrompt = (root: string, existing: string | undefined, notes: number, args: string) =>
217    [
218      `Let's set up my simple-memory knowledge base in ${root}.`,
219      args.trim() !== '' ? `\nWhat I have in mind: ${args.trim()}\n` : '',
220      existing
221        ? `It already has a structure (${notes} notes); treat this as a review and propose changes. Current ${GUIDE}:\n\n${existing.trim()}\n`
222        : notes > 0
223          ? `The folder already holds ${notes} notes but no ${GUIDE}; take their folders into account.\n`
224          : '',
225      'Guide me to the best structure, as a short interview:',
226      '1. Ask what the knowledge base is for (use AskUserQuestion with concrete options, e.g. a general shared team knowledge base, a personal second brain, one project\'s documentation and decisions, research notes, customer/support knowledge, or something else). One or two questions at a time.',
227      '2. Then ask what matters for that use case: who reads and writes it (just me, a team via git), the main kinds of knowledge (decisions, how-tos, people, projects, glossary, meetings, references...), and how granular notes should be.',
228      '3. Propose a folder tree, at most two levels deep and about 4-10 top-level folders, each with a one-line purpose; plus conventions: note title style, when to create vs. edit a note, tag vocabulary, keyword habits (every note has a one-line summary and ${KEYWORDS_MIN}-${KEYWORDS_MAX} keywords; the plugin enforces this), linking with [[note-id]], and what not to store (secrets, transient chatter). For a general shared knowledge base, a good starting point is something like: decisions/, how-to/, concepts/, projects/, people/, references/, and inbox/ for unsorted notes.',
229      '4. Revise until I approve, then call mcp__simple-memory__init_memory with the result. Offer to write one or two seed notes after that.',
230      'Keep each message short.',
231    ]
232      .filter(Boolean)
233      .join('\n')
234
235  // --- Tools ---
236
237  const tools = [
238    {
239      name: 'search_notes',
240      description:
241        'Keyword search over every note of the simple-memory knowledge base: frontmatter (keywords, title, tags, summary, folder path) and full text. No semantic search, so try synonyms if nothing matches. Frontmatter matches rank first. An empty query lists the most recently updated notes. Returns note ids with their summary and a matching line; read them with read_note.',
242      inputSchema: {
243        type: 'object',
244        properties: {
245          query: { type: 'string', description: 'Keywords to look for.' },
246          folder: { type: 'string', description: 'Only notes under this folder (relative to the memory root).' },
247          tags: { type: 'array', items: { type: 'string' }, description: 'Only notes carrying all of these tags.' },
248          limit: { type: 'number', description: 'Most results to return (default 10).' },
249        },
250      },
251    },
252    {
253      name: 'read_note',
254      description:
255        'Read one or more notes from the simple-memory knowledge base, by id (path under the memory root without .md, e.g. "decisions/use-postgres"), path, [[link]] or exact title.',
256      inputSchema: {
257        type: 'object',
258        properties: {
259          notes: { type: 'array', items: { type: 'string' }, description: 'The notes to read.' },
260        },
261        required: ['notes'],
262      },
263    },
264    {
265      name: 'write_note',
266      description:
267        `Create a note in the simple-memory knowledge base. The file is <folder>/<slug of title>.md with title, summary, keywords, tags and timestamps in its frontmatter. summary and keywords are required: the per-prompt hints match only on the frontmatter. Search first; to change an existing note use edit_note (or overwrite: true to replace it whole).`,
268      inputSchema: {
269        type: 'object',
270        properties: {
271          title: { type: 'string', description: 'A specific, descriptive title.' },
272          summary: { type: 'string', description: 'One line (at most 200 characters) saying what the note holds.' },
273          keywords: {
274            type: 'array',
275            items: { type: 'string' },
276            description: `${KEYWORDS_MIN}-${KEYWORDS_MAX} terms someone would use when this note is relevant, including synonyms and names not in the title.`,
277          },
278          folder: { type: 'string', description: 'Folder under the memory root, following its structure ("" for the root).' },
279          content: { type: 'string', description: 'The markdown body (no frontmatter).' },
280          tags: { type: 'array', items: { type: 'string' } },
281          overwrite: { type: 'boolean', description: 'Replace the note if it exists (default false).' },
282        },
283        required: ['title', 'summary', 'keywords', 'folder', 'content'],
284      },
285    },
286    {
287      name: 'edit_note',
288      description:
289        'Edit a note of the simple-memory knowledge base. operation: "append" or "prepend" content to the body; "find_replace" replaces the exact text `find` (must be unique unless replace_all); "replace_section" replaces the body under heading `section` (added if missing); "replace_body" replaces the whole body. title, summary, keywords and tags, when given, update the frontmatter (the file keeps its path); give only those to change the frontmatter alone. Keep summary and keywords true to the body.',
290      inputSchema: {
291        type: 'object',
292        properties: {
293          note: { type: 'string', description: 'The note: id, path or title.' },
294          operation: {
295            type: 'string',
296            enum: ['append', 'prepend', 'find_replace', 'replace_section', 'replace_body'],
297            description: 'Left out to change only the frontmatter.',
298          },
299          content: { type: 'string', description: 'The new text.' },
300          find: { type: 'string', description: 'For find_replace: the exact text to replace.' },
301          replace_all: { type: 'boolean', description: 'For find_replace: replace every occurrence.' },
302          section: { type: 'string', description: 'For replace_section: the heading text.' },
303          title: { type: 'string', description: 'A new title.' },
304          summary: { type: 'string', description: 'A new one-line summary.' },
305          keywords: { type: 'array', items: { type: 'string' }, description: `The new full keyword list (${KEYWORDS_MIN}-${KEYWORDS_MAX}).` },
306          tags: { type: 'array', items: { type: 'string' }, description: 'The new full tag list.' },
307        },
308        required: ['note'],
309      },
310    },
311    {
312      name: 'move_note',
313      description:
314        'Move or rename a note of the simple-memory knowledge base. destination is a folder ("archive/", keeps the file name) or a new note id ("projects/new-name"). [[links]] to the note in other notes are updated. title, when given, also retitles it.',
315      inputSchema: {
316        type: 'object',
317        properties: {
318          note: { type: 'string', description: 'The note: id, path or title.' },
319          destination: { type: 'string', description: 'A folder ending in "/", or the new id.' },
320          title: { type: 'string', description: 'A new title.' },
321        },
322        required: ['note', 'destination'],
323      },
324    },
325    {
326      name: 'delete_note',
327      description:
328        'Delete a note from the simple-memory knowledge base, permanently. Only when the user asked for it or confirmed it. Reports notes that still link to it.',
329      inputSchema: {
330        type: 'object',
331        properties: {
332          note: { type: 'string', description: 'The note: id, path or title.' },
333        },
334        required: ['note'],
335      },
336    },
337    {
338      name: 'init_memory',
339      description:
340        'Create or restructure the simple-memory knowledge base: writes MEMORY.md (overview, folders, conventions) at the memory root and creates the folders. Call it only at the end of /memory-init, or when the user asks to change the structure, after they approved it.',
341      inputSchema: {
342        type: 'object',
343        properties: {
344          overview: { type: 'string', description: 'What the knowledge base is for and who uses it.' },
345          folders: {
346            type: 'array',
347            items: {
348              type: 'object',
349              properties: { path: { type: 'string' }, purpose: { type: 'string' } },
350              required: ['path', 'purpose'],
351            },
352          },
353          conventions: { type: 'string', description: 'Markdown: titles, when to create vs. edit, linking, what not to store.' },
354          tags: { type: 'array', items: { type: 'string' }, description: 'The suggested tag vocabulary.' },
355        },
356        required: ['overview', 'folders', 'conventions'],
357      },
358    },
359  ]
360
361  const searchNotes = async (io: Io, args: Args) => {
362    const root = await rootOf(io)
363    let notes = await index(io, root)
364    const folder = safeRelative(str(args.folder))
365    if (str(args.folder) !== '' && folder === undefined) return refuse('folder must be relative to the memory root.')
366    if (folder) notes = notes.filter(note => note.id.startsWith(`${folder}/`))
367    const tags = tidyTags(strs(args.tags))
368    if (tags.length > 0) notes = notes.filter(note => tags.every(tag => note.tags.map(t => t.toLowerCase()).includes(tag)))
369    const limit = Math.max(1, Math.min(50, Math.floor(Number(args.limit ?? 10)) || 10))
370    const query = keywords(str(args.query), extra)
371
372    if (query.length === 0) {
373      const recent = [...notes].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit)
374      if (recent.length === 0) return answer(`No notes in ${root}${folder ? `/${folder}` : ''}.`)
375      return answer([`${notes.length} notes; most recently updated:`, ...recent.map(line)].join('\n'))
376    }
377
378    const hits = search(notes, query).slice(0, limit)
379    if (hits.length === 0) return answer(`No notes match ${query.join(', ')}. Try other words or synonyms.`)
380    const matchers = query.map(bodyMatcher)
381    const isHit = (text: string) => matchers.some(matcher => matcher.test(text))
382    return answer(
383      hits
384        .map(({ item, inBody }) => {
385          const quote = inBody > 0 ? snippet(item.body, isHit) : ''
386          const gap = missing(item)
387          return `${line(item)}${gap ? ` (no ${gap})` : ''}${quote ? `\n    ${quote}` : ''}`
388        })
389        .join('\n'),
390    )
391  }
392
393  const readNote = async (io: Io, args: Args) => {
394    const root = await rootOf(io)
395    const wanted = strs(args.notes ?? args.note)
396    if (wanted.length === 0) return refuse('name at least one note.')
397    const notes = await index(io, root)
398    const found: Note[] = []
399    const out: string[] = []
400    for (const one of wanted.slice(0, 20)) {
401      const note = find(notes, one, root)
402      if (!note) {
403        const close = score(notes, keywords(one, extra)).slice(0, 3).map(hit => hit.item.id)
404        out.push(`# ${one}\n(not found${close.length > 0 ? `; did you mean ${close.join(', ')}?` : ''})`)
405        continue
406      }
407      found.push(note)
408      const text = await io.read(note.abs).catch(() => '')
409      out.push(`# ${note.id}  (${note.rel})\n${typeof text === 'string' ? text.trim() : ''}`)
410    }
411    await markRead(io, found.map(refOf))
412    return found.length === 0 ? refuse(out.join('\n\n')) : answer(out.join('\n\n---\n\n'))
413  }
414
415  const writeNote = async (io: Io, args: Args) => {
416    const title = str(args.title).trim()
417    if (title === '') return refuse('title is required.')
418    const summary = checkSummary(str(args.summary))
419    if (typeof summary !== 'string') return refuse(summary.error)
420    const keywordList = checkKeywords(strs(args.keywords))
421    if (!Array.isArray(keywordList)) return refuse(keywordList.error)
422    const folder = safeRelative(str(args.folder))
423    if (folder === undefined) return refuse('folder must be a relative path inside the memory root.')
424    const root = await rootOf(io)
425    const rel = `${folder ? `${folder}/` : ''}${slugify(title)}.md`
426    const abs = `${root}/${rel}`
427    const exists = await io.exists(abs)
428    if (exists && args.overwrite !== true) {
429      return refuse(`${idOf(rel)} already exists. Read it and use edit_note, or pass overwrite: true.`)
430    }
431    const now = await nowIso(io)
432    const before = exists ? parse(String(await io.read(abs).catch(() => '')), title) : undefined
433    const previous = before?.meta
434    await saveNote(io, root, rel, {
435      meta: {
436        title,
437        summary,
438        keywords: keywordList,
439        tags: tidyTags(strs(args.tags)),
440        created: previous?.created ?? now,
441        updated: now,
442        rest: previous?.rest ?? [],
443      },
444      body: str(args.content),
445      eol: before?.eol,
446    })
447    await markRead(io, [{ id: idOf(rel), title }])
448    return answer(`${exists ? 'Replaced' : 'Created'} ${idOf(rel)} (${abs}).`)
449  }
450
451  const editNote = async (io: Io, args: Args) => {
452    const root = await rootOf(io)
453    const note = find(await index(io, root), str(args.note), root)
454    if (!note) return refuse(`no note "${str(args.note)}". Search for it first.`)
455    const text = await io.read(note.abs).catch(() => undefined)
456    if (typeof text !== 'string') return refuse(`cannot read ${note.rel}.`)
457    const parsed = parse(text, note.title)
458    const content = str(args.content)
459    let body = parsed.body
460
461    switch (str(args.operation)) {
462      case 'append':
463        body = `${body.replace(/\n+$/, '')}\n\n${content.replace(/^\n+/, '')}`
464        break
465      case 'prepend': {
466        // Keep a leading H1 on top.
467        const heading = /^(#\s.*\n+)/.exec(body.replace(/^\n+/, ''))
468        const rest = heading ? body.replace(/^\n+/, '').slice(heading[0].length) : body.replace(/^\n+/, '')
469        body = `${heading ? (heading[1] ?? '').replace(/\n+$/, '\n\n') : ''}${content.replace(/\n+$/, '')}\n\n${rest}`
470        break
471      }
472      case 'find_replace': {
473        const next = findReplace(body, str(args.find), content, args.replace_all === true)
474        if (typeof next !== 'string') return refuse(next.error)
475        body = next
476        break
477      }
478      case 'replace_section':
479        if (str(args.section).trim() === '') return refuse('section is required for replace_section.')
480        body = replaceSection(body, str(args.section), content)
481        break
482      case 'replace_body':
483        body = content
484        break
485      case '':
486        if ([args.title, args.summary, args.keywords, args.tags].every(one => one === undefined)) {
487          return refuse('give an operation, or title, summary, keywords or tags to change the frontmatter.')
488        }
489        break
490      default:
491        return refuse('operation must be append, prepend, find_replace, replace_section or replace_body.')
492    }
493
494    let summary = parsed.meta.summary
495    if (args.summary !== undefined) {
496      const checked = checkSummary(str(args.summary))
497      if (typeof checked !== 'string') return refuse(checked.error)
498      summary = checked
499    }
500    let keywordList = parsed.meta.keywords
501    if (args.keywords !== undefined) {
502      const checked = checkKeywords(strs(args.keywords))
503      if (!Array.isArray(checked)) return refuse(checked.error)
504      keywordList = checked
505    }
506    const title = str(args.title).trim() || parsed.meta.title
507    const tags = args.tags === undefined ? parsed.meta.tags : tidyTags(strs(args.tags))
508    await saveNote(io, root, note.rel, {
509      meta: { ...parsed.meta, title, summary, keywords: keywordList, tags, updated: await nowIso(io) },
510      body,
511      eol: parsed.eol,
512    })
513    await markRead(io, [{ id: note.id, title }])
514
515    const notes: string[] = [`Updated ${note.id}.`]
516    const gap = [!summary && 'summary', keywordList.length === 0 && 'keywords'].filter(Boolean).join(' and ')
517    if (gap) {
518      notes.push(`This note has no ${gap}: add ${gap === 'summary' ? 'it' : 'them'} with edit_note, so hints can find it.`)
519    } else if (/^(replace_body|replace_section)$/.test(str(args.operation)) && args.summary === undefined && args.keywords === undefined) {
520      notes.push(`Check that the summary and keywords still fit the new text: summary "${summary}"; keywords ${keywordList.join(', ')}.`)
521    }
522    return answer(notes.join(' '))
523  }
524
525  const moveNote = async (io: Io, args: Args) => {
526    const root = await rootOf(io)
527    const notes = await index(io, root)
528    const note = find(notes, str(args.note), root)
529    if (!note) return refuse(`no note "${str(args.note)}". Search for it first.`)
530
531    const raw = str(args.destination).trim().replace(/\\/g, '/')
532    const isFolder =
533      raw === '' || raw.endsWith('/') || (!/\.md$/i.test(raw) && (await io.exists(`${root}/${raw}`)))
534    const target = safeRelative(raw.replace(/\.md$/i, ''))
535    if (target === undefined) return refuse('destination must be a relative path inside the memory root.')
536    const fileName = note.rel.split('/').pop() ?? `${slugify(note.title)}.md`
537    const rel = isFolder ? `${target ? `${target}/` : ''}${fileName}` : `${target}.md`
538    if (rel === note.rel && str(args.title).trim() === '') return refuse('the note is already there.')
539    if (rel !== note.rel && (await io.exists(`${root}/${rel}`))) return refuse(`${idOf(rel)} already exists.`)
540
541    const parsed = parse(await io.read(note.abs), note.title)
542    const title = str(args.title).trim() || parsed.meta.title
543    const id = idOf(rel)
544    await saveNote(io, root, rel, {
545      meta: { ...parsed.meta, title, updated: await nowIso(io) },
546      body: parsed.body,
547      eol: parsed.eol,
548    })
549    if (rel !== note.rel) {
550      await io.remove(note.abs)
551      forget(note.abs)
552    }
553
554    const relinked: string[] = []
555    if (id !== note.id) {
556      for (const other of notes) {
557        if (other.abs === note.abs || !linksTo(other.body, note.id)) continue
558        const text = await io.read(other.abs).catch(() => undefined)
559        if (text === undefined) continue
560        await io.write(other.abs, relink(text, note.id, id))
561        forget(other.abs)
562        relinked.push(other.id)
563      }
564    }
565    await io.rename(note.id, { id, title })
566    return answer(
567      `Moved ${note.id} to ${id}.${relinked.length > 0 ? ` Updated links in: ${relinked.join(', ')}.` : ''}`,
568    )
569  }
570
571  const deleteNote = async (io: Io, args: Args) => {
572    const root = await rootOf(io)
573    const notes = await index(io, root)
574    const note = find(notes, str(args.note), root)
575    if (!note) return refuse(`no note "${str(args.note)}". Search for it first.`)
576    await io.remove(note.abs)
577    forget(note.abs)
578    await io.forget(note.id)
579    const linking = notes.filter(other => other.abs !== note.abs && linksTo(other.body, note.id)).map(other => other.id)
580    return answer(
581      `Deleted ${note.id}.${linking.length > 0 ? ` These notes still link to it: ${linking.join(', ')}.` : ''}`,
582    )
583  }
584
585  const initMemory = async (io: Io, args: Args) => {
586    const root = await rootOf(io)
587    const folders = (Array.isArray(args.folders) ? args.folders : [])
588      .map(one => (typeof one === 'object' && one !== null ? (one as Args) : {}))
589      .map(one => ({ path: safeRelative(str(one.path)), purpose: str(one.purpose).trim() }))
590    if (folders.length === 0) return refuse('give at least one folder.')
591    const bad = folders.find(one => !one.path)
592    if (bad) return refuse('every folder path must be relative to the memory root, without "..".')
593
594    const tags = tidyTags(strs(args.tags))
595    const guide = [
596      '# Memory structure',
597      '',
598      str(args.overview).trim(),
599      '',
600      '## Folders',
601      '',
602      ...folders.map(one => `- \`${one.path}/\` — ${one.purpose}`),
603      '',
604      '## Note format',
605      '',
606      SCHEMA,
607      '',
608      '## Conventions',
609      '',
610      str(args.conventions).trim(),
611      ...(tags.length > 0 ? ['', '## Tags', '', tags.map(tag => `\`${tag}\``).join(', ')] : []),
612      '',
613    ].join('\n')
614
615    const existed = await io.exists(`${root}/${GUIDE}`)
616    await io.write(`${root}/${GUIDE}`, guide)
617    for (const one of folders) {
618      const dir = `${root}/${one.path}`
619      const entries = await io.list(dir).catch(() => [])
620      if (entries.length === 0) await io.write(`${dir}/.gitkeep`, '')
621    }
622    // The opening context now has a structure to show.
623    io.invalidateContext()
624    return answer(
625      `${existed ? 'Restructured' : 'Initialized'} ${root}: ${GUIDE} written, folders ${folders.map(one => `${one.path}/`).join(', ')}.`,
626    )
627  }
628
629  const serve: Record<string, (io: Io, args: Args) => Promise<{ result: string; isError?: true }>> = {
630    [TOOL('search_notes')]: searchNotes,
631    [TOOL('read_note')]: readNote,
632    [TOOL('write_note')]: writeNote,
633    [TOOL('edit_note')]: editNote,
634    [TOOL('move_note')]: moveNote,
635    [TOOL('delete_note')]: deleteNote,
636    [TOOL('init_memory')]: initMemory,
637  }
638
639  // --- Hooks ---
640
641  on('session.start', async ($, e, next) => {
642    for (const tool of tools) await $.tool.register(tool)
643    await $.command.register({
644      name: 'memory-init',
645      description: 'Set up (or restructure) the simple-memory knowledge base with a short guided interview',
646      argumentHint: '[what the knowledge base is for]',
647    })
648    // Start the first walk now, in the background: nothing waits on it, and the
649    // first prompt hints from whatever it has read by then.
650    try {
651      const home = needsHome ? ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '') : ''
652      const root = rootPath(home, needsCwd ? await $.session.cwd() : '')
653      void index(
654        {
655          exists: path => $.fs.exists(path),
656          list: path => $.fs.list(path),
657          read: async path => String(await $.fs.read(path)),
658        },
659        root,
660      ).catch(() => undefined)
661    } catch {
662      // The first prompt starts the walk instead.
663    }
664    return next(e)
665  })
666
667  on('session.end', async ($, e, next) => {
668    if (e.reason === 'clear') {
669      await update($, suggested, () => [])
670      await update($, readNotes, () => [])
671      await update($, edited, () => [])
672    }
673    return next(e)
674  })
675
676  on('tool.call', { tool: /^mcp__simple-memory__/ }, async ($, e, next) => {
677    const handler = serve[e.tool]
678    if (handler === undefined) return next(e)
679    const io: Io = {
680      read: async path => String(await $.fs.read(path)),
681      write: (path, text) => $.fs.write(path, text),
682      list: path => $.fs.list(path),
683      exists: path => $.fs.exists(path),
684      now: () => $.clock.now(),
685      home: async () => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
686      cwd: () => $.session.cwd(),
687      markRead: async refs => void (await update($, readNotes, list => merge(list, refs))),
688      invalidateContext: () => void $.ui.invalidate('prompt.context'),
689      remove: path => removeFile(argv => $.process.run(argv), file => $.fs.exists(file), path),
690      forget: async id => {
691        await update($, suggested, list => list.filter(one => one.id !== id))
692        await update($, readNotes, list => list.filter(one => one.id !== id))
693      },
694      rename: async (from, to) => {
695        await update($, suggested, list => renameIn(list, from, to))
696        await update($, readNotes, list => renameIn(list, from, to))
697      },
698    }
699    try {
700      const done = await handler(io, e as unknown as Args)
701      if (!done.isError && NOTE_TOOLS.has(e.tool)) await update($, edited, () => [])
702      return done
703    } catch (error) {
704      return refuse(error instanceof Error ? error.message : String(error))
705    }
706  }).catch(() => refuse('simple-memory failed to run this tool.'))
707
708  // A note opened with the plain Read tool counts as read too.
709  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
710    const ran = await next(e)
711    const io: Io = {
712      read: async path => String(await $.fs.read(path)),
713      write: (path, text) => $.fs.write(path, text),
714      list: path => $.fs.list(path),
715      exists: path => $.fs.exists(path),
716      now: () => $.clock.now(),
717      home: async () => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
718      cwd: () => $.session.cwd(),
719      markRead: async refs => void (await update($, readNotes, list => merge(list, refs))),
720      invalidateContext: () => void $.ui.invalidate('prompt.context'),
721      remove: path => removeFile(argv => $.process.run(argv), file => $.fs.exists(file), path),
722      forget: async id => {
723        await update($, suggested, list => list.filter(one => one.id !== id))
724        await update($, readNotes, list => list.filter(one => one.id !== id))
725      },
726      rename: async (from, to) => {
727        await update($, suggested, list => renameIn(list, from, to))
728        await update($, readNotes, list => renameIn(list, from, to))
729      },
730    }
731    const root = await rootOf(io)
732    const rel = relativeTo(e.file_path, root)
733    if (ran.deny === undefined && !ran.isError && rel !== undefined && /\.md$/i.test(rel)) {
734      const note = find(await index(io, root), rel, root)
735      if (note) await markRead(io, [refOf(note)])
736    }
737    return ran
738  }).catch(($, e, next) => next(e)) // fail open: a bookkeeping error never blocks the call
739
740  on('command.run', { command: 'memory-init' }, async ($, e) => {
741    const io: Io = {
742      read: async path => String(await $.fs.read(path)),
743      write: (path, text) => $.fs.write(path, text),
744      list: path => $.fs.list(path),
745      exists: path => $.fs.exists(path),
746      now: () => $.clock.now(),
747      home: async () => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
748      cwd: () => $.session.cwd(),
749      markRead: async refs => void (await update($, readNotes, list => merge(list, refs))),
750      invalidateContext: () => void $.ui.invalidate('prompt.context'),
751      remove: path => removeFile(argv => $.process.run(argv), file => $.fs.exists(file), path),
752      forget: async id => {
753        await update($, suggested, list => list.filter(one => one.id !== id))
754        await update($, readNotes, list => list.filter(one => one.id !== id))
755      },
756      rename: async (from, to) => {
757        await update($, suggested, list => renameIn(list, from, to))
758        await update($, readNotes, list => renameIn(list, from, to))
759      },
760    }
761    const root = await rootOf(io)
762    const existing = await io.read(`${root}/${GUIDE}`).catch(() => undefined)
763    const notes = await index(io, root)
764    const text = initPrompt(root, existing, notes.length, e.args)
765    // A command may not start a turn itself; submit once it has returned.
766    $.clock.after(0, () => void $.prompt.submit({ text }).catch(() => undefined))
767    return { text: `Starting the simple-memory setup for ${root}…` }
768  })
769
770  on('prompt.context', async ($, e, next) => {
771    const result = await next(e)
772    const io: Io = {
773      read: async path => String(await $.fs.read(path)),
774      write: (path, text) => $.fs.write(path, text),
775      list: path => $.fs.list(path),
776      exists: path => $.fs.exists(path),
777      now: () => $.clock.now(),
778      home: async () => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
779      cwd: () => $.session.cwd(),
780      markRead: async refs => void (await update($, readNotes, list => merge(list, refs))),
781      invalidateContext: () => void $.ui.invalidate('prompt.context'),
782      remove: path => removeFile(argv => $.process.run(argv), file => $.fs.exists(file), path),
783      forget: async id => {
784        await update($, suggested, list => list.filter(one => one.id !== id))
785        await update($, readNotes, list => list.filter(one => one.id !== id))
786      },
787      rename: async (from, to) => {
788        await update($, suggested, list => renameIn(list, from, to))
789        await update($, readNotes, list => renameIn(list, from, to))
790      },
791    }
792    const text = await openingContext(io).catch(() => undefined)
793    if (text === undefined) return result
794    return { ...result, blocks: [...result.blocks.filter(block => block.name !== 'simpleMemory'), { name: 'simpleMemory', text }] }
795  })
796
797  on('prompt.submit', async ($, e, next) => {
798    const isOwn = e.origin?.kind === 'plugin' && e.origin.name === PLUGIN
799    if (maxHints === 0 || isOwn || e.text.trimStart().startsWith('/')) return next(e)
800
801    const query = keywords(e.text, extra)
802    if (query.length === 0) return next(e)
803
804    const io: Io = {
805      read: async path => String(await $.fs.read(path)),
806      write: (path, text) => $.fs.write(path, text),
807      list: path => $.fs.list(path),
808      exists: path => $.fs.exists(path),
809      now: () => $.clock.now(),
810      home: async () => (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')),
811      cwd: () => $.session.cwd(),
812      markRead: async refs => void (await update($, readNotes, list => merge(list, refs))),
813      invalidateContext: () => void $.ui.invalidate('prompt.context'),
814      remove: path => removeFile(argv => $.process.run(argv), file => $.fs.exists(file), path),
815      forget: async id => {
816        await update($, suggested, list => list.filter(one => one.id !== id))
817        await update($, readNotes, list => list.filter(one => one.id !== id))
818      },
819      rename: async (from, to) => {
820        await update($, suggested, list => renameIn(list, from, to))
821        await update($, readNotes, list => renameIn(list, from, to))
822      },
823    }
824    const root = await rootOf(io)
825    // Until the first walk completes, hint from what it has read so far.
826    let notes: Note[]
827    if (isWarm(root)) notes = await index(io, root).catch(() => [])
828    else {
829      void index(io, root).catch(() => undefined)
830      notes = partial(root)
831    }
832    if (notes.length === 0) return next(e)
833
834    const known = new Set([...(await read($, suggested)), ...(await read($, readNotes))].map(one => one.id))
835    const hits = relevant(notes, query, MIN_HINT_SCORE)
836      .filter(hit => !known.has(hit.item.id))
837      .slice(0, maxHints)
838      .map(hit => hit.item)
839    if (hits.length === 0) return next(e)
840
841    await update($, suggested, list => [...list, ...hits.map(refOf)])
842    const hint = [
843      '<simple-memory-hint>',
844      'Notes in memory that may relate to this prompt (keyword match, not read yet):',
845      ...hits.map(line),
846      `Read the ones that look relevant with ${TOOL('read_note')} before answering; ignore the rest.`,
847      '</simple-memory-hint>',
848    ].join('\n')
849    return next({ ...e, context: [...(e.context ?? []), hint] })
850  }).catch(($, e, next) => next(e)) // fail open: a bookkeeping error never blocks the call
851
852  // --- The nudge: after enough code edits, ask whether a note is due. ---
853
854  on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
855    const ran = await next(e)
856    if (nudgeAfter === 0 || ran.deny !== undefined || ran.isError) return ran
857    const input = e as unknown as Args
858    const path = str(input.file_path) || str(input.notebook_path)
859    if (path === '') return ran
860    const home = needsHome ? ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '') : ''
861    const root = rootPath(home, needsCwd ? await $.session.cwd() : '')
862    // A note file written by hand is a note, not work waiting for one.
863    const rel = relativeTo(path, root)
864    if (rel !== undefined && /\.md$/i.test(rel)) await update($, edited, () => [])
865    else {
866      // One file, however a tool spelled its path, counts once.
867      const file = normalize(path)
868      await update($, edited, list => (list.some(one => pathKey(one) === pathKey(file)) ? list : [...list, file]))
869    }
870    return ran
871  }).catch(($, e, next) => next(e)) // fail open: a bookkeeping error never blocks the call
872
873  on('turn.complete', async ($, e, next) => {
874    const ended = await next(e)
875    if (nudgeAfter === 0 || e.agentId !== undefined || e.reason !== 'answer' || e.isAborted) return ended
876    const files = await read($, edited)
877    if (files.length < nudgeAfter) return ended
878    // Headless runs (-p, SDK) draw nowhere: never buy them an extra turn.
879    if ((await $.session.surfaces()).length === 0) return ended
880    await update($, edited, () => [])
881    void $.prompt.submit({ text: nudgeText(files) }).catch(() => undefined)
882    return ended
883  })
884
885  // --- The band: read notes first, then suggestions not read yet. ---
886
887  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
888    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
889    const done = await read($, readNotes)
890    const doneIds = new Set(done.map(one => one.id))
891    const pending = (await read($, suggested)).filter(one => !doneIds.has(one.id))
892    if (done.length === 0 && pending.length === 0) return next(e)
893
894    const { Box, Text, Button } = $.ui.resolve(e)
895    const insert = (id: string) => () => void $.prompt.fill({ text: `[[${id}]] `, mode: 'insert' })
896    const width = Math.max(10, Math.min(40, Math.floor(e.props.bodyColumns / 3)))
897    const label = (title: string) => (title.length > width ? `${title.slice(0, width - 1)}…` : title)
898
899    return (
900      <Box flexDirection="row" flexWrap="wrap" columnGap={2} width={e.props.bodyColumns}>
901        <Text bold>memory</Text>
902        {[...done].reverse().map(one => (
903          <Box key={`r:${one.id}`} flexDirection="row">
904            <Text color="green">● </Text>
905            <Button key={`read:${one.id}`} label={label(one.title)} plain onPress={insert(one.id)} />
906          </Box>
907        ))}
908        {[...pending].reverse().map(one => (
909          <Box key={`s:${one.id}`} flexDirection="row">
910            <Text dimColor>○ </Text>
911            <Button key={`sug:${one.id}`} label={label(one.title)} plain dimColor onPress={insert(one.id)} />
912          </Box>
913        ))}
914      </Box>
915    )
916  })
917}
918
hooks/keywords.ts 214 lines
1// Keyword extraction and scoring: no semantic search, just stemmed terms
2// weighted by where they appear in a note and how rare they are overall.
3
4const STOPWORDS = new Set(
5  `a about above after again against all almost also although always am among an and another any anybody
6  anyone anything anyway anywhere are aren't around as at be became because become been before being below
7  between both but by can can't cannot could couldn't did didn't do does doesn't doing don't done down during
8  each either else enough etc even ever every everything few for from further get gets getting give go goes
9  going gone got had hadn't has hasn't have haven't having he her here hers herself him himself his how
10  however i i'd i'll i'm i've if in into is isn't it it's its itself just know let let's like likely made make
11  makes many may maybe me might mine more most much must my myself need needs neither never no nor not nothing
12  now of off often oh ok okay on once one only or other others otherwise our ours ourselves out over own per
13  please put quite rather really said same say says see seem seems shall she should shouldn't since so some
14  somebody someone something sometimes somewhere still such sure take tell than thank thanks that that's the
15  their theirs them themselves then there there's these they they're thing things think this those though
16  through thus to too try trying under until up upon us use used using very via want wanted wants was wasn't
17  way we we're well were weren't what what's whatever when where whether which while who whom whose why will
18  with within without won't would wouldn't yeah yes yet you you'd you'll you're you've your yours yourself
19  yourselves
20  able actually add already anything anyways back can could currently does done etc find first fine good great
21  help hey hi hello last lets look looking lot lots new next right show sure today want work yeah`
22    .split(/\s+/)
23    .filter(Boolean),
24)
25
26export const isStopword = (word: string, extra?: ReadonlySet<string>) =>
27  STOPWORDS.has(word) || (extra !== undefined && extra.has(word))
28
29/** A deliberately light stemmer, applied to both the prompt and the notes. */
30export const stem = (word: string): string => {
31  if (word.length > 4 && word.endsWith('ies')) return word.slice(0, -3) + 'y'
32  if (word.length > 5 && word.endsWith('ing')) return word.slice(0, -3)
33  if (word.length > 4 && word.endsWith('ed') && !word.endsWith('eed')) return word.slice(0, -2)
34  if (word.endsWith('sses')) return word.slice(0, -2)
35  if (word.length > 3 && word.endsWith('s') && !/(ss|us|is)$/.test(word)) return word.slice(0, -1)
36  return word
37}
38
39/** Every word of `text`, lowercased and stemmed, stopwords and noise dropped; repeats kept. */
40export const terms = (text: string, extra?: ReadonlySet<string>): string[] => {
41  const words = text.toLowerCase().normalize('NFKC').match(/[\p{L}\p{N}]+(?:'[\p{L}]+)?/gu) ?? []
42  const out: string[] = []
43  for (const word of words) {
44    if (word.length < 2 || isStopword(word, extra)) continue
45    if (/^\p{N}+$/u.test(word) && word.length < 3) continue
46    out.push(stem(word.replace(/'.*$/, '')))
47  }
48  return out
49}
50
51/** The distinct terms of `text`, in first-seen order. */
52export const keywords = (text: string, extra?: ReadonlySet<string>): string[] => [...new Set(terms(text, extra))]
53
54export const parseStopwords = (list: string): ReadonlySet<string> =>
55  new Set(
56    list
57      .split(/[\s,]+/)
58      .map(word => word.trim().toLowerCase())
59      .filter(Boolean),
60  )
61
62/** What the scorer needs from a note: its frontmatter, reduced to terms. */
63export type Indexed = {
64  keywordTerms: ReadonlySet<string>
65  titleTerms: ReadonlySet<string>
66  tagTerms: ReadonlySet<string>
67  summaryTerms: ReadonlySet<string>
68  pathTerms: ReadonlySet<string>
69}
70
71export type Scored<T> = {
72  item: T
73  score: number
74  /** Distinct query terms the note matched. */
75  matched: number
76  /** Whether a term hit the keywords, title or tags, not only the summary or path. */
77  isStrong: boolean
78}
79
80/** Field weights: keywords and title count most, the folder path least. */
81const WEIGHTS = { keyword: 3, title: 3, tag: 2, summary: 1.5, path: 1 } as const
82
83export const has = (note: Indexed, term: string) =>
84  note.keywordTerms.has(term) ||
85  note.titleTerms.has(term) ||
86  note.tagTerms.has(term) ||
87  note.summaryTerms.has(term) ||
88  note.pathTerms.has(term)
89
90/** Inverse document frequency of each query term that some note has. */
91const idfOf = (notes: readonly Indexed[], query: readonly string[]) => {
92  const idf = new Map<string, number>()
93  for (const term of query) {
94    let df = 0
95    for (const note of notes) if (has(note, term)) df += 1
96    if (df > 0) idf.set(term, Math.log(1 + notes.length / df))
97  }
98  return idf
99}
100
101/**
102 * Scores every note's frontmatter against `query` (already reduced to
103 * keywords) with field weights times inverse document frequency; unmatched
104 * notes are left out.
105 */
106export const score = <T extends Indexed>(notes: readonly T[], query: readonly string[]): Scored<T>[] => {
107  if (notes.length === 0 || query.length === 0) return []
108  const idf = idfOf(notes, query)
109
110  const out: Scored<T>[] = []
111  for (const note of notes) {
112    let sum = 0
113    let matched = 0
114    let isStrong = false
115    for (const [term, weight] of idf) {
116      let s = 0
117      if (note.keywordTerms.has(term)) s += WEIGHTS.keyword
118      if (note.titleTerms.has(term)) s += WEIGHTS.title
119      if (note.tagTerms.has(term)) s += WEIGHTS.tag
120      if (s > 0) isStrong = true
121      if (note.summaryTerms.has(term)) s += WEIGHTS.summary
122      if (note.pathTerms.has(term)) s += WEIGHTS.path
123      if (s > 0) {
124        matched += 1
125        sum += s * weight
126      }
127    }
128    if (matched > 0) out.push({ item: note, score: sum, matched, isStrong })
129  }
130
131  return out.sort((a, b) => b.score - a.score)
132}
133
134/**
135 * The notes worth hinting for a prompt: a keyword, title or tag hit, or at
136 * least two distinct terms in the summary or path, and a score above the floor.
137 */
138export const relevant = <T extends Indexed>(
139  notes: readonly T[],
140  query: readonly string[],
141  minScore: number,
142): Scored<T>[] =>
143  score(notes, query).filter(
144    one => one.score >= minScore && (one.isStrong || one.matched >= Math.min(2, query.length)),
145  )
146
147const WORD_CHAR = /[\p{L}\p{N}]/u
148
149/**
150 * Finds words that start with a stemmed term, so `deploy` matches deploys,
151 * deployed and deployment but not redeploy; `policy` (stemmed from policies)
152 * matches from `polic`. A plain case-insensitive pattern plus a check of the
153 * character before each match: lookbehind is many times slower on some engines.
154 */
155export const bodyMatcher = (term: string) => {
156  const prefix = term.length > 3 && term.endsWith('y') ? term.slice(0, -1) : term
157  const pattern = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
158  const count = (text: string, cap = Infinity) => {
159    pattern.lastIndex = 0
160    let found = 0
161    for (let match = pattern.exec(text); match !== null && found < cap; match = pattern.exec(text)) {
162      if (match.index === 0 || !WORD_CHAR.test(text[match.index - 1] ?? '')) found += 1
163    }
164    return found
165  }
166  return { count, test: (text: string) => count(text, 1) > 0 }
167}
168
169export type Searched<T> = Scored<T> & {
170  /** The frontmatter's share of the score; 0 for a body-only match. */
171  front: number
172  /** Distinct query terms found in the body. */
173  inBody: number
174}
175
176/**
177 * Full-text search: frontmatter scores as for hints, plus matches of each
178 * term in the body, counted at query time (no body index is kept). Notes with
179 * a frontmatter match rank above body-only ones.
180 */
181export const search = <T extends Indexed & { body: string }>(
182  notes: readonly T[],
183  query: readonly string[],
184): Searched<T>[] => {
185  if (notes.length === 0 || query.length === 0) return []
186  const front = new Map(score(notes, query).map(hit => [hit.item, hit]))
187  const matchers = query.map(bodyMatcher)
188
189  const out: Searched<T>[] = []
190  for (const note of notes) {
191    let body = 0
192    let inBody = 0
193    for (const matcher of matchers) {
194      const count = matcher.count(note.body, 20)
195      if (count > 0) {
196        inBody += 1
197        body += Math.min(1 + Math.log(count), 3)
198      }
199    }
200    const hit = front.get(note)
201    if (!hit && inBody === 0) continue
202    out.push({
203      item: note,
204      score: (hit?.score ?? 0) + body,
205      front: hit?.score ?? 0,
206      matched: Math.max(hit?.matched ?? 0, inBody),
207      isStrong: hit?.isStrong ?? false,
208      inBody,
209    })
210  }
211
212  return out.sort((a, b) => Number(b.front > 0) - Number(a.front > 0) || b.score - a.score)
213}
214
hooks/indexer.ts 185 lines
1// The note index: every markdown file under the memory root, its
2// frontmatter reduced to terms, re-read only when a file's mtime moves.
3// Bodies are kept as text for search and links, never tokenized here.
4
5import { terms } from './keywords'
6import type { Indexed } from './keywords'
7import { firstParagraph, headings, idOf, parse } from './notes'
8
9/** The structure guide at the root; never indexed as a note. */
10export const GUIDE = 'MEMORY.md'
11/** The most notes one walk indexes. */
12export const MAX_FILES = 5000
13
14export type Note = Indexed & {
15  id: string
16  rel: string
17  abs: string
18  title: string
19  /** The frontmatter's summary, or the first paragraph when it has none. */
20  summary: string
21  keywords: string[]
22  tags: string[]
23  updated: string
24  mtimeMs: number
25  body: string
26  /** Whether the frontmatter has its own keywords and summary (not derived). */
27  hasKeywords: boolean
28  hasSummary: boolean
29}
30
31/** One directory entry, as `$.fs.list` answers it. */
32export type IndexEntry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number }
33
34/** What a walk needs from the file system. */
35export type IndexIo = {
36  exists: (path: string) => Promise<boolean>
37  list: (path: string) => Promise<readonly IndexEntry[]>
38  read: (path: string) => Promise<string>
39}
40
41export const buildNote = (
42  abs: string,
43  rel: string,
44  mtimeMs: number,
45  text: string,
46  extra?: ReadonlySet<string>,
47): Note => {
48  const id = idOf(rel)
49  const { meta, body } = parse(text, id.split('/').pop() ?? id)
50  const hasKeywords = meta.keywords.length > 0
51  const hasSummary = (meta.summary ?? '').trim() !== ''
52  const summary = hasSummary ? meta.summary!.trim() : firstParagraph(body)
53  // A note from before the schema: its headings stand in for keywords.
54  const keywordText = hasKeywords ? meta.keywords.join(' ') : headings(body).join(' ')
55  const words = (text: string) => new Set(terms(text.replace(/[-_/]/g, ' '), extra))
56  return {
57    id,
58    rel,
59    abs,
60    title: meta.title,
61    summary,
62    keywords: meta.keywords,
63    tags: meta.tags,
64    updated: meta.updated ?? meta.created ?? '',
65    mtimeMs,
66    body,
67    hasKeywords,
68    hasSummary,
69    keywordTerms: words(keywordText),
70    titleTerms: words(meta.title),
71    tagTerms: words(meta.tags.join(' ')),
72    summaryTerms: words(summary),
73    pathTerms: words(id),
74  }
75}
76
77/** A markdown note file found by a walk, before it is read. */
78export type NoteFile = { abs: string; rel: string; mtimeMs: number }
79
80/** Reads in flight at once during a walk: each read is a round trip through the engine. */
81const READ_BATCH = 16
82
83/**
84 * Every note file under the root, from directory listings alone (no reads):
85 * dot-entries, node_modules and MEMORY.md skipped, at most MAX_FILES.
86 */
87export const listNotes = async (io: IndexIo, root: string): Promise<NoteFile[]> => {
88  if (!(await io.exists(root))) return []
89  const files: NoteFile[] = []
90  const queue = ['']
91  while (queue.length > 0 && files.length < MAX_FILES) {
92    const dir = queue.shift()!
93    const entries = await io.list(dir === '' ? root : `${root}/${dir}`).catch(() => [])
94    for (const entry of entries) {
95      if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
96      const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
97      if (entry.kind === 'dir') queue.push(rel)
98      else if (entry.kind === 'file' && /\.md$/i.test(entry.name) && rel !== GUIDE && files.length < MAX_FILES) {
99        files.push({ abs: `${root}/${rel}`, rel, mtimeMs: entry.mtimeMs })
100      }
101    }
102  }
103  return files
104}
105
106/**
107 * An index with its own cache of parsed notes by absolute path.
108 *
109 * Walks are single-flight: callers asking while one runs share it, unless a
110 * note was written or forgotten after it started, in which case they get a
111 * fresh walk once it ends. The first complete walk makes the index warm;
112 * until then `partial` answers what has been read so far, so a prompt never
113 * has to wait for it.
114 */
115export const createIndexer = (extra?: ReadonlySet<string>) => {
116  const cache = new Map<string, Note>()
117  let running: { root: string; generation: number; walk: Promise<Note[]> } | undefined
118  let generation = 0
119  let warmRoot: string | undefined
120
121  const walk = async (io: IndexIo, root: string): Promise<Note[]> => {
122    const files = await listNotes(io, root)
123    const seen = new Set(files.map(file => file.abs))
124    const notes: (Note | undefined)[] = files.map(file => {
125      const cached = cache.get(file.abs)
126      return cached && cached.mtimeMs === file.mtimeMs ? cached : undefined
127    })
128    const stale = files.map((file, at) => ({ file, at })).filter(({ at }) => notes[at] === undefined)
129    for (let start = 0; start < stale.length; start += READ_BATCH) {
130      await Promise.all(
131        stale.slice(start, start + READ_BATCH).map(async ({ file, at }) => {
132          const text = await io.read(file.abs).catch(() => undefined)
133          if (typeof text !== 'string') return
134          const note = buildNote(file.abs, file.rel, file.mtimeMs, text, extra)
135          cache.set(file.abs, note)
136          notes[at] = note
137        }),
138      )
139    }
140    for (const key of [...cache.keys()]) if (key.startsWith(`${root}/`) && !seen.has(key)) cache.delete(key)
141    return notes.filter((note): note is Note => note !== undefined)
142  }
143
144  /** Every note under the root, from the cache where the file has not changed. */
145  const index = (io: IndexIo, root: string): Promise<Note[]> => {
146    if (running && running.root === root) {
147      if (running.generation === generation) return running.walk
148      // Something changed since this walk began: walk again after it.
149      return running.walk.then(
150        () => index(io, root),
151        () => index(io, root),
152      )
153    }
154    const mine = { root, generation, walk: walk(io, root) }
155    running = mine
156    void mine.walk.then(
157      () => {
158        if (mine.generation === generation) warmRoot = root
159      },
160      () => undefined,
161    )
162    void mine.walk.finally(() => {
163      if (running === mine) running = undefined
164    }).catch(() => undefined)
165    return mine.walk
166  }
167
168  /** Whether a walk of `root` has completed: from then on a walk only re-reads changed notes. */
169  const isWarm = (root: string) => warmRoot === root
170
171  /** The notes of `root` read so far, complete or not; for callers that must not wait. */
172  const partial = (root: string): Note[] => [...cache.values()].filter(note => note.abs.startsWith(`${root}/`))
173
174  /** The note cached for a path, if any. */
175  const cached = (abs: string) => cache.get(abs)
176
177  /** Drops a file from the cache, so the next walk reads it again. */
178  const forget = (abs: string) => {
179    cache.delete(abs)
180    generation += 1
181  }
182
183  return { index, isWarm, partial, cached, forget }
184}
185
hooks/paths.ts 66 lines
1// Paths on Windows and Unix alike. The plugin keeps every path it builds
2// with forward slashes (Windows file APIs accept them), and compares paths
3// that come from elsewhere (a tool's file_path, a setting) only through
4// these helpers: backslashes become slashes, and on Windows the comparison
5// ignores case, as the file system does.
6
7/** Whether a path is a Windows one: a drive (`C:\`, `c:/`) or a UNC share (`\\server`). */
8export const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
9
10/** Whether a path is absolute: `/…`, a drive or a UNC share. */
11export const isAbsolute = (path: string) => path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(path)
12
13/**
14 * The path with forward slashes, repeated slashes collapsed (a UNC share's
15 * leading pair kept), a drive letter upper-cased, and no trailing slash.
16 */
17export const normalize = (path: string): string => {
18  const slashed = path.replace(/\\/g, '/')
19  const isUnc = /^\/\/[^/]/.test(slashed)
20  let out = slashed.replace(/\/{2,}/g, '/')
21  if (isUnc) out = `/${out}`
22  out = out.replace(/^([a-z]):/, (_, drive: string) => `${drive.toUpperCase()}:`)
23  return out.length > 1 && !/^[A-Za-z]:\/$/.test(out) ? out.replace(/\/+$/, '') : out
24}
25
26/** A key two spellings of one path share: normalized, and lower-cased on Windows. */
27export const pathKey = (path: string) => {
28  const out = normalize(path)
29  return isWindowsPath(out) ? out.toLowerCase() : out
30}
31
32/** `path` relative to `root` (with forward slashes) when it lies inside it, else undefined. */
33export const relativeTo = (path: string, root: string): string | undefined => {
34  const base = pathKey(root)
35  const full = normalize(path)
36  const key = pathKey(full)
37  if (!key.startsWith(`${base}/`)) return undefined
38  return full.slice(base.length + 1)
39}
40
41/**
42 * Deletes a file through a host command: `rm` where there is one, else
43 * `cmd /c del` (Windows). Either way, it is a success only if the file is gone
44 * afterwards; `del` exits 0 even when it deleted nothing.
45 */
46export const removeFile = async (
47  run: (argv: readonly string[]) => Promise<{ exitCode: number; stderr: string }>,
48  exists: (path: string) => Promise<boolean>,
49  path: string,
50): Promise<void> => {
51  const isWindows = isWindowsPath(path)
52  const attempts: (readonly string[])[] = isWindows
53    ? [['cmd', '/c', 'del', '/f', '/q', path.replace(/\//g, '\\')], ['rm', '-f', '--', path]]
54    : [['rm', '-f', '--', path]]
55  let reason = ''
56  for (const argv of attempts) {
57    const ran = await run(argv).catch((error: unknown) => {
58      reason = error instanceof Error ? error.message : String(error)
59      return undefined
60    })
61    if (ran && ran.exitCode !== 0) reason = ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`
62    if (ran && !(await exists(path))) return
63  }
64  throw new Error(`could not delete ${path}${reason ? `: ${reason}` : ''}`)
65}
66
hooks/notes.ts 223 lines
1// Note files: markdown with a small frontmatter block, and the pure edits
2// the tools make to them.
3
4export type Meta = {
5  title: string
6  /** One line saying what the note holds; shown in hints. */
7  summary?: string
8  /** The terms the per-prompt hint matches on. */
9  keywords: string[]
10  tags: string[]
11  created?: string
12  updated?: string
13  /** Frontmatter keys this module does not manage, kept as written. */
14  rest: [string, string][]
15}
16
17/** A note split up; `eol` is the line ending its file used (Windows' `\r\n` kept on save), `\n` when absent. */
18export type Parsed = { meta: Meta; body: string; eol?: '\n' | '\r\n' }
19
20const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
21
22const unquote = (value: string) => value.trim().replace(/^(['"])(.*)\1$/, '$2')
23
24const parseList = (value: string) =>
25  value
26    .trim()
27    .replace(/^\[|\]$/g, '')
28    .split(',')
29    .map(unquote)
30    .map(tag => tag.replace(/^#/, ''))
31    .filter(Boolean)
32
33/** A list value: inline (`[a, b]`) or as `- item` lines after the key; `i` is the key's line. */
34const readList = (value: string, lines: readonly string[], i: number): { items: string[]; next: number } => {
35  if (value.trim() !== '') return { items: parseList(value), next: i }
36  const items: string[] = []
37  let at = i
38  for (let item = lines[at + 1]; item !== undefined && /^\s*-\s+/.test(item); item = lines[at + 1]) {
39    items.push(unquote(item.replace(/^\s*-\s+/, '')).replace(/^#/, ''))
40    at += 1
41  }
42  return { items, next: at }
43}
44
45/** Splits a note into frontmatter and body; a missing title falls back to the first heading or `fallback`. */
46export const parse = (raw: string, fallback: string): Parsed => {
47  // Work on \n line endings throughout; serialize puts the file's own back.
48  const eol = raw.includes('\r\n') ? '\r\n' : '\n'
49  const text = eol === '\r\n' ? raw.replace(/\r\n/g, '\n') : raw
50  const meta: Meta = { title: '', keywords: [], tags: [], rest: [] }
51  let body = text
52  const front = FRONT.exec(text)
53
54  if (front) {
55    body = text.slice(front[0].length)
56    const lines = (front[1] ?? '').split(/\r?\n/)
57    for (let i = 0; i < lines.length; i++) {
58      const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i] ?? '')
59      if (!match) continue
60      const key = match[1] ?? ''
61      const value = match[2] ?? ''
62      if (key === 'title') meta.title = unquote(value)
63      else if (key === 'created') meta.created = unquote(value)
64      else if (key === 'updated') meta.updated = unquote(value)
65      else if (key === 'summary') meta.summary = unquote(value)
66      else if (key === 'tags' || key === 'keywords') {
67        const { items, next } = readList(value, lines, i)
68        meta[key] = items
69        i = next
70      } else meta.rest.push([key, value])
71    }
72  }
73
74  if (meta.title === '') {
75    meta.title = /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? fallback
76  }
77
78  return { meta, body, eol }
79}
80
81const quote = (value: string) => (/^[\w][\w .,()/&+-]*$/.test(value) && !/:\s/.test(value) ? value : JSON.stringify(value))
82
83export const serialize = ({ meta, body, eol = '\n' }: Parsed): string => {
84  const lines = [`title: ${quote(meta.title)}`]
85  if (meta.summary) lines.push(`summary: ${quote(meta.summary)}`)
86  if (meta.keywords.length > 0) lines.push(`keywords: [${meta.keywords.map(quote).join(', ')}]`)
87  if (meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(quote).join(', ')}]`)
88  if (meta.created) lines.push(`created: ${meta.created}`)
89  if (meta.updated) lines.push(`updated: ${meta.updated}`)
90  for (const [key, value] of meta.rest) lines.push(`${key}: ${value}`)
91  const text = body.replace(/^\n+/, '')
92  const out = `---\n${lines.join('\n')}\n---\n\n${text.endsWith('\n') ? text : text + '\n'}`
93  return eol === '\r\n' ? out.replace(/\r?\n/g, '\r\n') : out
94}
95
96export const slugify = (title: string): string =>
97  title
98    .normalize('NFKD')
99    .replace(/[̀-ͯ]/g, '')
100    .toLowerCase()
101    .replace(/[^\p{L}\p{N}]+/gu, '-')
102    .replace(/^-+|-+$/g, '')
103    .slice(0, 80)
104    .replace(/-+$/, '') || 'note'
105
106/**
107 * A folder or note path relative to the memory root, normalized; undefined
108 * when it would leave the root (absolute, `..`, a drive letter).
109 */
110export const safeRelative = (path: string): string | undefined => {
111  const clean = path.trim().replace(/\\/g, '/')
112  if (clean.startsWith('/') || /^[A-Za-z]:/.test(clean) || clean.startsWith('~')) return undefined
113  const parts = clean.split('/').filter(part => part !== '' && part !== '.')
114  if (parts.some(part => part === '..' || part.startsWith('.'))) return undefined
115  return parts.join('/')
116}
117
118/** A note's id: its path under the root without `.md`. */
119export const idOf = (relPath: string) => relPath.replace(/\.md$/i, '')
120
121export const tidyTags = (tags: readonly string[]) =>
122  [...new Set(tags.map(tag => tag.trim().replace(/^#/, '').toLowerCase()).filter(Boolean))]
123
124/** Replaces exactly one occurrence of `find`, or all with `all`; an error string when that is impossible. */
125export const findReplace = (body: string, find: string, replace: string, all: boolean): string | { error: string } => {
126  if (find === '') return { error: 'find must not be empty.' }
127  const count = body.split(find).length - 1
128  if (count === 0) return { error: 'find text not found in the note.' }
129  if (count > 1 && !all) {
130    return { error: `find text occurs ${count} times; give more context or set replace_all.` }
131  }
132  return body.split(find).join(replace)
133}
134
135/**
136 * Replaces the body under the heading `section` (up to the next heading of
137 * the same or a higher level); appends a new `## section` when absent.
138 */
139export const replaceSection = (body: string, section: string, content: string): string => {
140  const wanted = section.replace(/^#+\s*/, '').trim().toLowerCase()
141  const lines = body.split('\n')
142  const start = lines.findIndex(line => {
143    const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line)
144    return match !== null && (match[2] ?? '').trim().toLowerCase() === wanted
145  })
146  const block = content.replace(/\n+$/, '')
147
148  if (start < 0) {
149    return `${body.replace(/\n+$/, '')}\n\n## ${section.replace(/^#+\s*/, '').trim()}\n\n${block}\n`
150  }
151
152  const level = /^(#+)/.exec(lines[start] ?? '')?.[1]?.length ?? 1
153  let end = lines.length
154  for (let i = start + 1; i < lines.length; i++) {
155    const match = /^(#{1,6})\s/.exec(lines[i] ?? '')
156    if (match && (match[1] ?? '').length <= level) {
157      end = i
158      break
159    }
160  }
161
162  const after = lines.slice(end)
163  return [...lines.slice(0, start + 1), '', block, ...(after.length > 0 ? ['', ...after] : [''])].join('\n')
164}
165
166/** The first line of `body` holding any of `terms` (stemmed words), trimmed, for a search snippet. */
167export const snippet = (body: string, match: (line: string) => boolean, width = 160): string => {
168  const lines = body.split('\n').map(line => line.trim()).filter(line => line !== '' && !line.startsWith('---'))
169  const line = lines.find(match) ?? lines.find(one => !one.startsWith('#')) ?? ''
170  return line.length > width ? line.slice(0, width - 1) + '…' : line
171}
172
173const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
174
175/** Matches `[[id]]`, `[[id|label]]` and `[[id#heading]]`, case-insensitive. */
176const linkPattern = (id: string) => new RegExp(`\\[\\[${escape(id)}(?=[\\]|#])`, 'gi')
177
178export const linksTo = (text: string, id: string) => linkPattern(id).test(text)
179
180/** Points every link to `from` at `to`. */
181export const relink = (text: string, from: string, to: string) => text.replace(linkPattern(from), `[[${to}`)
182
183/** Keywords and the limits write_note holds them to. */
184export const KEYWORDS_MIN = 3
185export const KEYWORDS_MAX = 12
186export const SUMMARY_MAX = 200
187
188export const tidyKeywords = (keywords: readonly string[]) =>
189  [...new Set(keywords.map(one => one.trim().replace(/\s+/g, ' ').toLowerCase()).filter(Boolean))]
190
191/** A summary on one line, or an error saying why it cannot be one. */
192export const checkSummary = (summary: string): string | { error: string } => {
193  const line = summary.replace(/\s+/g, ' ').trim()
194  if (line === '') return { error: 'summary is required: one line saying what the note holds.' }
195  if (line.length > SUMMARY_MAX) return { error: `summary is ${line.length} characters; keep it to one line of at most ${SUMMARY_MAX}.` }
196  return line
197}
198
199export const checkKeywords = (keywords: readonly string[]): string[] | { error: string } => {
200  const tidy = tidyKeywords(keywords)
201  if (tidy.length < KEYWORDS_MIN || tidy.length > KEYWORDS_MAX) {
202    return {
203      error: `keywords needs ${KEYWORDS_MIN}-${KEYWORDS_MAX} distinct terms (got ${tidy.length}): the words someone would use when this note is relevant, including synonyms not in the title.`,
204    }
205  }
206  return tidy
207}
208
209/** The headings of a body, without their marks. */
210export const headings = (body: string) =>
211  [...body.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)].map(match => match[1] ?? '').filter(Boolean)
212
213/** The first paragraph of prose (not a heading, list item or code), cut to one line. */
214export const firstParagraph = (body: string, width = SUMMARY_MAX) => {
215  for (const block of body.split(/\n\s*\n/)) {
216    const text = block.trim()
217    if (text === '' || /^(#|```|---|\||>)/.test(text)) continue
218    const line = text.replace(/^[-*+]\s+/, '').replace(/\s+/g, ' ')
219    return line.length > width ? `${line.slice(0, width - 1)}…` : line
220  }
221  return ''
222}
223
types/index.d.ts 16 lines
1/** A note as the band and the hints name it. */
2export type NoteRef = { id: string; title: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'simple-memory': {
7      /** Notes hinted this conversation, oldest first; each hinted once. */
8      suggested: NoteRef[]
9      /** Notes read this conversation, oldest first. */
10      read: NoteRef[]
11      /** Files edited (Edit, Write, ...) since the last note was written. */
12      edited: string[]
13    }
14  }
15}
16