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

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.
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.
| Piece | Behaviour |
|---|---|
| Keyword hints | On 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. |
| Tools | mcp__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. |
| Band | A 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 start | The conversation's opening context gets the usage rules, the structure guide (MEMORY.md) and the most recently updated notes. |
| Memory nudge | Edits 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-init | A 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).
~/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.
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 notes | 1,000 notes | 5,000 notes | |
|---|---|---|---|
| First prompt of a session (the index builds in the background) | 5 ms | 2 ms | 2 ms |
| Background index build, until complete | 100 ms | 470 ms | 1.6 s |
| Later prompts | 31 ms | 27 ms | 66 ms |
search_notes (frontmatter + full text) | 26 ms | 38 ms | 130 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).
Set these in /config or under pluginConfigs["simple-memory"].options in settings:
| Option | Default | Meaning |
|---|---|---|
directory | ~/simple-memory | Where the notes live. Absolute, ~/…, or relative to the project (e.g. docs/memory for a per-repo KB). |
maxHints | 5 | The most notes hinted for one prompt (0 turns hints off). |
nudgeAfterFiles | 3 | Distinct edited files before the memory nudge (0 turns it off). |
recentNotes | 10 | Recently updated notes listed at session start. |
extraStopwords | "" | More words to ignore, comma- or space-separated (handy for prompts in another language). |
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).
hooks/register.tsx 918 lines1import { 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}
918hooks/keywords.ts 214 lines1// 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}
214hooks/indexer.ts 185 lines1// 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}
185hooks/paths.ts 66 lines1// 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}
66hooks/notes.ts 223 lines1// 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}
223types/index.d.ts 16 lines1/** 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