claude-memory-admin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parse.mjs ADDED
@@ -0,0 +1,255 @@
1
+ // Parsers for the on-disk memory format.
2
+ //
3
+ // These are written against the real data in ~/.claude/projects rather than the
4
+ // idealised format, because three properties of the real files break the obvious
5
+ // implementations:
6
+ //
7
+ // 1. MEMORY.md is not a flat index. It mixes index bullets with `#` headings,
8
+ // free-prose bullets, indented sub-bullets, and links embedded mid-sentence.
9
+ // 2. The hook separator is an em dash in most projects but " - " in projects
10
+ // that have opted out of em dashes. A regex anchored on `—` silently drops
11
+ // every entry in those projects.
12
+ // 3. Frontmatter has exactly one level of nesting (`metadata:`), so a flat
13
+ // `key: value` reader loses `metadata.type` -- the field the UI keys on.
14
+
15
+ /** A top-level `- [Title](file.md) — hook` bullet. Indent is captured, not assumed. */
16
+ const INDEX_LINE = /^(\s*)-[ \t]+\[([^\]]*)\]\(([^)\s]+\.md)\)[ \t]*(.*)$/;
17
+
18
+ /** Any markdown link to a .md file, wherever it appears (including mid-sentence). */
19
+ const MD_LINK = /\[([^\]]*)\]\(([^)\s]+\.md)\)/g;
20
+
21
+ /** `[[name]]` or `[[name|alias]]`. */
22
+ const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g;
23
+
24
+ /** Leading hook separator: em dash, en dash, hyphen or colon. */
25
+ const HOOK_SEP = /^[ \t]*(?:—|–|-|:)[ \t]*/;
26
+
27
+ function unquote(value) {
28
+ const trimmed = value.trim();
29
+ if (trimmed.length >= 2) {
30
+ const first = trimmed[0];
31
+ const last = trimmed[trimmed.length - 1];
32
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
33
+ return trimmed.slice(1, -1);
34
+ }
35
+ }
36
+ return trimmed;
37
+ }
38
+
39
+ /**
40
+ * Minimal YAML reader for frontmatter: scalars at the root plus one level of
41
+ * nesting. Anything it does not understand is skipped rather than guessed at,
42
+ * and the raw block is kept by the caller so nothing is ever rewritten from it.
43
+ */
44
+ function readYaml(lines) {
45
+ const data = {};
46
+ const nestedKeys = new Set();
47
+ let parent = null;
48
+
49
+ for (const raw of lines) {
50
+ const withoutComment = raw.trimStart().startsWith('#') ? '' : raw;
51
+ if (!withoutComment.trim()) continue;
52
+
53
+ const match = withoutComment.match(/^([ \t]*)([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
54
+ if (!match) continue;
55
+
56
+ const [, indent, key, rest] = match;
57
+ const value = unquote(rest);
58
+
59
+ if (indent.length === 0) {
60
+ if (value === '') {
61
+ // Might open a nested block, or might just be an empty scalar. Decided
62
+ // after the loop, once we know whether anything nested under it.
63
+ data[key] = {};
64
+ parent = key;
65
+ } else {
66
+ data[key] = value;
67
+ parent = null;
68
+ }
69
+ } else if (parent !== null) {
70
+ if (typeof data[parent] !== 'object' || data[parent] === null) data[parent] = {};
71
+ data[parent][key] = value;
72
+ nestedKeys.add(parent);
73
+ }
74
+ }
75
+
76
+ // An empty scalar that never got children is a string, not an object.
77
+ for (const [key, value] of Object.entries(data)) {
78
+ if (value && typeof value === 'object' && !nestedKeys.has(key)) data[key] = '';
79
+ }
80
+ return data;
81
+ }
82
+
83
+ /**
84
+ * Split frontmatter from body. Scans for the closing `---` on its own line
85
+ * rather than splitting on '---', which would break on any horizontal rule in
86
+ * the body.
87
+ */
88
+ export function parseFrontmatter(text) {
89
+ const lines = text.split('\n');
90
+ if (lines.length === 0 || lines[0].trim() !== '---') {
91
+ return { data: {}, raw: '', body: text, hasFrontmatter: false };
92
+ }
93
+
94
+ let end = -1;
95
+ for (let i = 1; i < lines.length; i++) {
96
+ if (lines[i].trim() === '---') { end = i; break; }
97
+ }
98
+ if (end === -1) {
99
+ return { data: {}, raw: '', body: text, hasFrontmatter: false };
100
+ }
101
+
102
+ const block = lines.slice(1, end);
103
+ return {
104
+ data: readYaml(block),
105
+ raw: block.join('\n'),
106
+ body: lines.slice(end + 1).join('\n').replace(/^\n+/, ''),
107
+ hasFrontmatter: true,
108
+ };
109
+ }
110
+
111
+ /** Every `[[wikilink]]` in a body, in order, de-duplicated. */
112
+ export function extractWikilinks(body) {
113
+ const found = [];
114
+ const seen = new Set();
115
+ for (const match of body.matchAll(WIKILINK)) {
116
+ const target = match[1].trim();
117
+ if (!target || seen.has(target)) continue;
118
+ seen.add(target);
119
+ found.push({ target, alias: match[2]?.trim() || null });
120
+ }
121
+ return found;
122
+ }
123
+
124
+ /**
125
+ * Parse MEMORY.md into a line-addressed model.
126
+ *
127
+ * Every line is preserved verbatim so the file can be rewritten byte-for-byte.
128
+ * Lines are classified, never rewritten:
129
+ * - `index` a top-level `- [Title](file.md)` bullet
130
+ * - `heading` a `#`-prefixed line, used to group entries in the UI
131
+ * - `text` everything else, including prose and nested bullets
132
+ * `links` additionally records every .md link anywhere in the file, which is how
133
+ * a file referenced only mid-sentence is told apart from a genuine orphan.
134
+ */
135
+ export function parseIndex(text) {
136
+ const lines = text.split('\n');
137
+ const entries = [];
138
+ const links = [];
139
+ const parsedLines = [];
140
+ let section = null;
141
+
142
+ lines.forEach((line, i) => {
143
+ const heading = line.match(/^(#{1,6})[ \t]+(.*)$/);
144
+ if (heading) {
145
+ section = heading[2].trim();
146
+ parsedLines.push({ index: i, kind: 'heading', text: line, level: heading[1].length, section });
147
+ } else {
148
+ const indexMatch = line.match(INDEX_LINE);
149
+ // Only an unindented bullet is an index entry. An indented one is a
150
+ // sub-bullet of surrounding prose and must not be treated as a pointer.
151
+ if (indexMatch && indexMatch[1] === '') {
152
+ const [, , title, file, tail] = indexMatch;
153
+ entries.push({
154
+ index: i,
155
+ title: title.trim(),
156
+ file: file.trim(),
157
+ hook: tail.replace(HOOK_SEP, '').trim(),
158
+ section,
159
+ text: line,
160
+ });
161
+ parsedLines.push({ index: i, kind: 'index', text: line, file: file.trim(), section });
162
+ } else {
163
+ parsedLines.push({ index: i, kind: 'text', text: line, section });
164
+ }
165
+ }
166
+
167
+ for (const link of line.matchAll(MD_LINK)) {
168
+ links.push({ index: i, label: link[1], file: link[2].trim(), text: line });
169
+ }
170
+ });
171
+
172
+ const indexedFiles = new Set(entries.map((e) => e.file));
173
+ return {
174
+ lines,
175
+ parsedLines,
176
+ entries,
177
+ links,
178
+ // Links that are not index bullets, e.g. a mention inside a prose sentence.
179
+ inlineLinks: links.filter((l) => !entries.some((e) => e.index === l.index)),
180
+ indexedFiles,
181
+ referencedFiles: new Set(links.map((l) => l.file)),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Remove index bullets pointing at `fileOrFiles`, plus the more-indented
187
+ * continuation lines that belong to them. Every other byte is untouched.
188
+ * Returns the new text and the removed lines with their ORIGINAL indices.
189
+ *
190
+ * Several files are handled in one pass on purpose: removing them one at a time
191
+ * would shift the line numbers under each subsequent removal, and the recorded
192
+ * indices would no longer describe the file we started from.
193
+ */
194
+ export function removeIndexEntries(text, fileOrFiles) {
195
+ const files = new Set([].concat(fileOrFiles));
196
+ const parsed = parseIndex(text);
197
+ const targets = parsed.entries.filter((e) => files.has(e.file));
198
+ if (targets.length === 0) return { text, removed: [] };
199
+
200
+ const drop = new Set();
201
+ for (const entry of targets) {
202
+ drop.add(entry.index);
203
+ // Continuation lines: indented, non-blank, until the next unindented line.
204
+ for (let i = entry.index + 1; i < parsed.lines.length; i++) {
205
+ const line = parsed.lines[i];
206
+ if (!line.trim()) break;
207
+ if (!/^[ \t]/.test(line)) break;
208
+ drop.add(i);
209
+ }
210
+ }
211
+
212
+ const removed = [...drop].sort((a, b) => a - b).map((i) => ({ index: i, text: parsed.lines[i] }));
213
+ const kept = parsed.lines.filter((_, i) => !drop.has(i));
214
+ return { text: kept.join('\n'), removed };
215
+ }
216
+
217
+ /** Remove a single line by index, guarded by the text the caller expected to find. */
218
+ export function removeLine(text, index, expectedText) {
219
+ const lines = text.split('\n');
220
+ if (index < 0 || index >= lines.length) {
221
+ throw new Error(`Line ${index} is out of range`);
222
+ }
223
+ if (typeof expectedText === 'string' && lines[index] !== expectedText) {
224
+ throw new Error('MEMORY.md changed since this was loaded - reload and try again');
225
+ }
226
+ const removed = [{ index, text: lines[index] }];
227
+ lines.splice(index, 1);
228
+ return { text: lines.join('\n'), removed };
229
+ }
230
+
231
+ /** Re-insert previously removed lines at their original indices. */
232
+ export function insertLines(text, removed) {
233
+ const lines = text.split('\n');
234
+ for (const entry of [...removed].sort((a, b) => a.index - b.index)) {
235
+ const at = Math.min(entry.index, lines.length);
236
+ lines.splice(at, 0, entry.text);
237
+ }
238
+ return lines.join('\n');
239
+ }
240
+
241
+ /**
242
+ * Turn `[[target]]` into plain text wherever it appears, leaving the sentence
243
+ * around it intact. `[[target|alias]]` collapses to the alias.
244
+ * Used to clear links whose target no longer exists.
245
+ */
246
+ export function unwrapWikilink(text, target) {
247
+ let count = 0;
248
+ const pattern = new RegExp(WIKILINK.source, 'g');
249
+ const out = text.replace(pattern, (match, name, alias) => {
250
+ if (name.trim() !== target) return match;
251
+ count += 1;
252
+ return (alias || name).trim();
253
+ });
254
+ return { text: out, count };
255
+ }
@@ -0,0 +1,190 @@
1
+ // Discovery of project directories under the Claude projects root, and the
2
+ // recovery of their real filesystem paths.
3
+ //
4
+ // Directory names are slugified cwds ("-Users-me-repos-Blog"), and the
5
+ // slugification is lossy: a literal dash in a folder name is indistinguishable
6
+ // from a path separator. So decoding the slug is a fallback, not the primary
7
+ // method. The reliable source is the session transcripts sitting next to the
8
+ // memory dir, whose JSONL lines carry the true `cwd`.
9
+
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ export const DEFAULT_ROOT = path.join(os.homedir(), '.claude', 'projects');
15
+ const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
16
+
17
+ function expandHome(value) {
18
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
19
+ return value;
20
+ }
21
+
22
+ /**
23
+ * Claude Code lets `autoMemoryDirectory` in settings.json move the memory store
24
+ * somewhere else entirely. Honour it, or this tool would confidently show an
25
+ * empty store to anyone who has set it.
26
+ */
27
+ export function configuredMemoryDirectory() {
28
+ try {
29
+ const settings = JSON.parse(fs.readFileSync(USER_SETTINGS, 'utf8'));
30
+ const configured = settings?.autoMemoryDirectory;
31
+ if (typeof configured === 'string' && configured.trim()) return expandHome(configured.trim());
32
+ } catch {
33
+ // No settings file, or not readable/parseable: fall through to the default.
34
+ }
35
+ return null;
36
+ }
37
+
38
+ export function projectsRoot() {
39
+ if (process.env.MEMORY_ROOT) return expandHome(process.env.MEMORY_ROOT);
40
+ return configuredMemoryDirectory() || DEFAULT_ROOT;
41
+ }
42
+
43
+ /** Read the first chunk of a file without pulling a multi-megabyte transcript into memory. */
44
+ function readHead(file, bytes = 131072) {
45
+ const fd = fs.openSync(file, 'r');
46
+ try {
47
+ const buf = Buffer.alloc(bytes);
48
+ const read = fs.readSync(fd, buf, 0, bytes, 0);
49
+ return buf.subarray(0, read).toString('utf8');
50
+ } finally {
51
+ fs.closeSync(fd);
52
+ }
53
+ }
54
+
55
+ function findTranscripts(dir, depth = 2) {
56
+ const out = [];
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ return out;
62
+ }
63
+ for (const entry of entries) {
64
+ const full = path.join(dir, entry.name);
65
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
66
+ try {
67
+ out.push({ file: full, mtime: fs.statSync(full).mtimeMs });
68
+ } catch { /* raced with a delete */ }
69
+ } else if (entry.isDirectory() && depth > 0 && entry.name !== 'memory') {
70
+ out.push(...findTranscripts(full, depth - 1));
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Pull `cwd` out of the newest transcripts. Authoritative when present. */
77
+ function cwdFromTranscripts(dir) {
78
+ const transcripts = findTranscripts(dir).sort((a, b) => b.mtime - a.mtime);
79
+ for (const { file } of transcripts.slice(0, 3)) {
80
+ let head;
81
+ try {
82
+ head = readHead(file);
83
+ } catch {
84
+ continue;
85
+ }
86
+ for (const line of head.split('\n')) {
87
+ if (!line.includes('"cwd"')) continue;
88
+ try {
89
+ const parsed = JSON.parse(line);
90
+ if (typeof parsed?.cwd === 'string' && parsed.cwd.startsWith('/')) return parsed.cwd;
91
+ } catch {
92
+ // Truncated final line of the chunk, or a non-object line. Skip it.
93
+ }
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Best-effort slug decode, used only when there is no transcript to read.
101
+ * Candidates are verified against the filesystem so a wrong guess is reported
102
+ * as unresolved rather than presented as fact.
103
+ */
104
+ function decodeSlug(slug) {
105
+ const body = slug.replace(/^-/, '');
106
+ const candidates = [
107
+ '/' + body.replace(/--/g, '/.').replace(/-/g, '/'),
108
+ '/' + body.replace(/-/g, '/'),
109
+ ];
110
+ for (const candidate of candidates) {
111
+ try {
112
+ if (fs.existsSync(candidate)) return { path: candidate, verified: true };
113
+ } catch { /* unreadable path */ }
114
+ }
115
+ return { path: candidates[0], verified: false };
116
+ }
117
+
118
+ export function resolveProjectPath(dir, slug) {
119
+ const fromTranscript = cwdFromTranscripts(dir);
120
+ if (fromTranscript) {
121
+ return {
122
+ path: fromTranscript,
123
+ resolvedBy: 'transcript',
124
+ exists: fs.existsSync(fromTranscript),
125
+ };
126
+ }
127
+ const decoded = decodeSlug(slug);
128
+ if (decoded.verified) {
129
+ return { path: decoded.path, resolvedBy: 'slug', exists: true };
130
+ }
131
+ // Nothing confirmed the guess, so show the slug rather than a path that
132
+ // looks authoritative and is probably wrong.
133
+ return { path: slug, resolvedBy: 'unresolved', exists: false, guess: decoded.path };
134
+ }
135
+
136
+ /** A short label for the sidebar: the last two path segments. */
137
+ export function shortLabel(fullPath) {
138
+ const parts = fullPath.split('/').filter(Boolean);
139
+ return parts.slice(-2).join('/') || fullPath;
140
+ }
141
+
142
+ export function memoryDir(root, slug) {
143
+ return path.join(root, slug, 'memory');
144
+ }
145
+
146
+ /** List memory filenames, excluding the index itself and the trash folder. */
147
+ export function listMemoryFiles(dir) {
148
+ let entries;
149
+ try {
150
+ entries = fs.readdirSync(dir, { withFileTypes: true });
151
+ } catch {
152
+ return [];
153
+ }
154
+ return entries
155
+ .filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'MEMORY.md')
156
+ .map((e) => e.name)
157
+ .sort();
158
+ }
159
+
160
+ /** Every project dir under the root, whether or not it has memory. */
161
+ export function listProjects(root = projectsRoot()) {
162
+ let entries;
163
+ try {
164
+ entries = fs.readdirSync(root, { withFileTypes: true });
165
+ } catch {
166
+ return [];
167
+ }
168
+
169
+ return entries
170
+ .filter((e) => e.isDirectory())
171
+ .map((e) => {
172
+ const slug = e.name;
173
+ const dir = path.join(root, slug);
174
+ const mem = memoryDir(root, slug);
175
+ const hasMemoryDir = fs.existsSync(mem);
176
+ const resolved = resolveProjectPath(dir, slug);
177
+ const files = hasMemoryDir ? listMemoryFiles(mem) : [];
178
+ return {
179
+ slug,
180
+ path: resolved.path,
181
+ label: shortLabel(resolved.path),
182
+ resolvedBy: resolved.resolvedBy,
183
+ pathExists: resolved.exists,
184
+ hasMemoryDir,
185
+ hasIndex: hasMemoryDir && fs.existsSync(path.join(mem, 'MEMORY.md')),
186
+ memoryCount: files.length,
187
+ };
188
+ })
189
+ .sort((a, b) => b.memoryCount - a.memoryCount || a.label.localeCompare(b.label));
190
+ }
package/src/search.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // Full-text search across every project's memory.
2
+ //
3
+ // The whole store is a few hundred kilobytes, so this scans it directly on each
4
+ // query rather than maintaining an index that could go stale.
5
+
6
+ import { buildProject } from './model.mjs';
7
+ import { listProjects } from './projects.mjs';
8
+
9
+ const FIELD_WEIGHT = { name: 6, description: 3, hook: 2, body: 1 };
10
+
11
+ function fold(text) {
12
+ return String(text || '').toLowerCase();
13
+ }
14
+
15
+ /** Character offsets of every occurrence of `term` in `haystack`. */
16
+ function positions(haystack, term) {
17
+ const found = [];
18
+ let at = haystack.indexOf(term);
19
+ while (at !== -1 && found.length < 20) {
20
+ found.push(at);
21
+ at = haystack.indexOf(term, at + term.length);
22
+ }
23
+ return found;
24
+ }
25
+
26
+ /** A short excerpt around the first hit, with the match marked by offsets. */
27
+ function snippet(body, term, radius = 90) {
28
+ const at = fold(body).indexOf(term);
29
+ if (at === -1) return null;
30
+ const start = Math.max(0, at - radius);
31
+ const end = Math.min(body.length, at + term.length + radius);
32
+ return {
33
+ text: (start > 0 ? '…' : '') + body.slice(start, end).replace(/\s+/g, ' ').trim() + (end < body.length ? '…' : ''),
34
+ term,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Search one project's already-built model. Every term must appear somewhere in
40
+ * a memory for it to match, so extra words narrow rather than widen - which is
41
+ * what people expect from a search box.
42
+ */
43
+ export function searchProject(project, terms) {
44
+ const results = [];
45
+
46
+ for (const memory of project.memories) {
47
+ const fields = {
48
+ name: fold(memory.name),
49
+ description: fold(memory.description),
50
+ hook: fold(memory.entry?.hook || ''),
51
+ body: fold(memory.body),
52
+ };
53
+
54
+ let score = 0;
55
+ const matchedFields = new Set();
56
+ const everyTermPresent = terms.every((term) => {
57
+ let present = false;
58
+ for (const [field, text] of Object.entries(fields)) {
59
+ const hits = positions(text, term);
60
+ if (hits.length) {
61
+ present = true;
62
+ matchedFields.add(field);
63
+ score += FIELD_WEIGHT[field] * Math.min(hits.length, 3);
64
+ }
65
+ }
66
+ return present;
67
+ });
68
+ if (!everyTermPresent) continue;
69
+
70
+ // Prefer the most specific field for the excerpt.
71
+ const first = terms[0];
72
+ const excerpt = snippet(memory.body, first)
73
+ || (memory.description ? { text: memory.description, term: first } : null);
74
+
75
+ results.push({
76
+ slug: project.slug,
77
+ projectLabel: project.label,
78
+ file: memory.file,
79
+ name: memory.name,
80
+ description: memory.description,
81
+ type: memory.type,
82
+ status: memory.status,
83
+ score,
84
+ fields: [...matchedFields],
85
+ snippet: excerpt,
86
+ });
87
+ }
88
+
89
+ // Index lines that mention the terms but whose file did not match, so a hit in
90
+ // MEMORY.md prose is still findable.
91
+ const indexHits = [];
92
+ if (project.index) {
93
+ for (const line of project.index.lines) {
94
+ const text = fold(line.text);
95
+ if (!line.text.trim()) continue;
96
+ if (!terms.every((term) => text.includes(term))) continue;
97
+ if (line.kind === 'index' && results.some((r) => r.file === line.file)) continue;
98
+ indexHits.push({ slug: project.slug, projectLabel: project.label, index: line.index, text: line.text, kind: line.kind, file: line.file || null });
99
+ }
100
+ }
101
+
102
+ return { results: results.sort((a, b) => b.score - a.score), indexHits };
103
+ }
104
+
105
+ /** Search every project under `root`. */
106
+ export function searchAll(root, query, { limit = 200 } = {}) {
107
+ const terms = fold(query).split(/\s+/).filter(Boolean);
108
+ if (!terms.length) return { terms, total: 0, projects: [] };
109
+
110
+ const projects = [];
111
+ let total = 0;
112
+ for (const listed of listProjects(root)) {
113
+ if (!listed.hasMemoryDir) continue;
114
+ const project = buildProject(root, listed.slug);
115
+ const { results, indexHits } = searchProject(project, terms);
116
+ if (!results.length && !indexHits.length) continue;
117
+ total += results.length;
118
+ projects.push({
119
+ slug: listed.slug,
120
+ label: project.label,
121
+ results: results.slice(0, limit),
122
+ indexHits: indexHits.slice(0, 20),
123
+ });
124
+ }
125
+
126
+ return { terms, total, projects: projects.sort((a, b) => b.results.length - a.results.length) };
127
+ }