claude-memory-admin 1.0.0 → 1.1.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/README.md +111 -9
- package/package.json +8 -2
- package/public/app.mjs +633 -362
- package/public/graph.mjs +32 -76
- package/public/index.html +34 -29
- package/public/markdown.mjs +74 -0
- package/public/styles.css +2 -402
- package/public/ui.mjs +291 -0
- package/server.mjs +61 -20
- package/src/instructions.mjs +423 -0
- package/src/model.mjs +44 -11
- package/src/mutate.mjs +17 -23
- package/src/pathcache.mjs +93 -0
- package/src/projects.mjs +120 -34
- package/src/search.mjs +13 -11
- package/src/settings.mjs +151 -0
- package/src/stats.mjs +105 -12
- package/src/stores.mjs +146 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// What Claude Code actually loads as instructions when a session starts in a
|
|
2
|
+
// directory: the CLAUDE.md chain, the files those import, and `.claude/rules/`.
|
|
3
|
+
//
|
|
4
|
+
// This is the other half of the startup context. MEMORY.md has a hard limit and
|
|
5
|
+
// this side does not, but the two are spent from the same budget, and the rules
|
|
6
|
+
// that decide which files load are fiddly enough that people are routinely
|
|
7
|
+
// surprised. Everything here is READ ONLY.
|
|
8
|
+
//
|
|
9
|
+
// The resolution is a re-derivation of documented behaviour, not a report from
|
|
10
|
+
// Claude Code itself. Where a rule is subtle - imports inside code spans do not
|
|
11
|
+
// count, a `paths:` glob with an unbalanced bracket matches nothing - the
|
|
12
|
+
// subtlety is implemented rather than smoothed over, because smoothing over it
|
|
13
|
+
// would hide exactly the problem this is meant to surface.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { parseFrontmatter } from './parse.mjs';
|
|
19
|
+
import { estimateTokens } from './stats.mjs';
|
|
20
|
+
import { lookup, settingsLayers } from './settings.mjs';
|
|
21
|
+
|
|
22
|
+
/** Claude Code's own guidance for a CLAUDE.md. Guidance, not a cutoff. */
|
|
23
|
+
export const CLAUDE_MD_TARGET_LINES = 200;
|
|
24
|
+
|
|
25
|
+
/** An import chain deeper than this is not followed. */
|
|
26
|
+
export const MAX_IMPORT_DEPTH = 4;
|
|
27
|
+
|
|
28
|
+
/** A rule's whole `paths:` list shares this budget of expanded patterns. */
|
|
29
|
+
export const BRACE_PATTERN_BUDGET = 1000;
|
|
30
|
+
|
|
31
|
+
function managedClaudeMd() {
|
|
32
|
+
if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode/CLAUDE.md';
|
|
33
|
+
if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md';
|
|
34
|
+
return '/etc/claude-code/CLAUDE.md';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readIfFile(file) {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.statSync(file).isFile()) return null;
|
|
40
|
+
return fs.readFileSync(file, 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ imports */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Blank out fenced code blocks and code spans, keeping the byte length so every
|
|
50
|
+
* offset still lines up with the original.
|
|
51
|
+
*
|
|
52
|
+
* Import parsing skips both, which is the documented way to write `@README` in a
|
|
53
|
+
* CLAUDE.md without importing it. Scanning the raw text instead would report
|
|
54
|
+
* imports that never happen, and the file being wrong about its own examples is
|
|
55
|
+
* worse than not checking.
|
|
56
|
+
*/
|
|
57
|
+
export function maskCode(text) {
|
|
58
|
+
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
59
|
+
return text
|
|
60
|
+
.replace(/^[ \t]*(```|~~~)[\s\S]*?^[ \t]*\1[ \t]*$/gm, blank)
|
|
61
|
+
.replace(/`+[^`\n]*`+/g, blank);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A bare @ that is part of an email address or an npm scope is not an import,
|
|
65
|
+
// so the character before it has to be a boundary.
|
|
66
|
+
const IMPORT = /(^|[\s(<'"])@((?:~\/|\.{0,2}\/)?[A-Za-z0-9._~\-][A-Za-z0-9._~\-/]*)/g;
|
|
67
|
+
|
|
68
|
+
/** Every `@path` import in a file's text, with the offset it was found at. */
|
|
69
|
+
export function findImports(text) {
|
|
70
|
+
const masked = maskCode(text);
|
|
71
|
+
const found = [];
|
|
72
|
+
for (const match of masked.matchAll(IMPORT)) {
|
|
73
|
+
const end = match.index + match[0].length;
|
|
74
|
+
// "@team@example.com" opens with something that looks exactly like an
|
|
75
|
+
// import until the second @ arrives.
|
|
76
|
+
if (masked[end] === '@') continue;
|
|
77
|
+
found.push({ spec: match[2], index: match.index + match[1].length });
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveImport(spec, fromFile) {
|
|
83
|
+
if (spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(2));
|
|
84
|
+
if (path.isAbsolute(spec)) return spec;
|
|
85
|
+
// Relative to the file holding the import, not to the working directory.
|
|
86
|
+
return path.resolve(path.dirname(fromFile), spec);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Follow a file's imports, breadth-first, to the documented maximum depth.
|
|
91
|
+
*
|
|
92
|
+
* Returns every file reached and every import that did not work out. A cycle is
|
|
93
|
+
* reported once and not followed, because a file importing itself back is a
|
|
94
|
+
* mistake worth naming rather than a loop worth running.
|
|
95
|
+
*/
|
|
96
|
+
export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
|
|
97
|
+
const files = [];
|
|
98
|
+
const problems = [];
|
|
99
|
+
const seen = new Set([rootFile]);
|
|
100
|
+
let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
|
|
101
|
+
|
|
102
|
+
while (frontier.length) {
|
|
103
|
+
const next = [];
|
|
104
|
+
for (const current of frontier) {
|
|
105
|
+
for (const { spec } of findImports(current.text)) {
|
|
106
|
+
const resolved = resolveImport(spec, current.file);
|
|
107
|
+
|
|
108
|
+
if (current.depth + 1 > MAX_IMPORT_DEPTH) {
|
|
109
|
+
problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(resolved)) {
|
|
113
|
+
problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const text = readIfFile(resolved);
|
|
118
|
+
if (text === null) {
|
|
119
|
+
problems.push({ kind: 'missing', spec, from: current.file, file: resolved });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
seen.add(resolved);
|
|
124
|
+
// An import resolving outside the project is the kind Claude Code asks
|
|
125
|
+
// you to approve once; a declined one then stays silently disabled.
|
|
126
|
+
const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
|
|
127
|
+
const entry = { file: resolved, text, depth: current.depth + 1, importedBy: current.file, external };
|
|
128
|
+
files.push(entry);
|
|
129
|
+
if (external) problems.push({ kind: 'external', spec, from: current.file, file: resolved });
|
|
130
|
+
next.push(entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
frontier = next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { files, problems };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* -------------------------------------------------------------------- globs */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check a `paths:` glob the way it will actually behave.
|
|
143
|
+
*
|
|
144
|
+
* Two documented failure modes make a rule silently inert, and both look
|
|
145
|
+
* completely normal in the file:
|
|
146
|
+
* - a `[` that cannot be read as a bracket expression makes the pattern match
|
|
147
|
+
* nothing at all;
|
|
148
|
+
* - brace expansion past the budget makes the pattern get used unexpanded, so
|
|
149
|
+
* its literal braces match no file either.
|
|
150
|
+
*/
|
|
151
|
+
export function checkGlob(pattern) {
|
|
152
|
+
let expansions = 1;
|
|
153
|
+
for (const group of pattern.matchAll(/\{([^{}]*)\}/g)) {
|
|
154
|
+
expansions *= group[1].split(',').length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Walk the pattern looking for a bracket expression that never closes.
|
|
158
|
+
let open = -1;
|
|
159
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
160
|
+
if (pattern[i] === '\\') { i += 1; continue; }
|
|
161
|
+
if (pattern[i] === '[' && open === -1) open = i;
|
|
162
|
+
else if (pattern[i] === ']' && open !== -1 && i > open + 1) open = -1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (open !== -1) {
|
|
166
|
+
return { pattern, expansions, valid: false, reason: 'unclosed [ - glob syntax reads it as a bracket expression, so the pattern matches nothing' };
|
|
167
|
+
}
|
|
168
|
+
return { pattern, expansions, valid: true, reason: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether a rule's whole `paths:` list fits the shared expansion budget. */
|
|
172
|
+
export function checkGlobList(patterns) {
|
|
173
|
+
const checks = patterns.map(checkGlob);
|
|
174
|
+
// Patterns without braces do not count against the budget.
|
|
175
|
+
const total = checks.reduce((sum, check) => sum + (check.expansions > 1 ? check.expansions : 0), 0);
|
|
176
|
+
return {
|
|
177
|
+
checks,
|
|
178
|
+
expansions: total,
|
|
179
|
+
overBudget: total > BRACE_PATTERN_BUDGET,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* -------------------------------------------------------------------- rules */
|
|
184
|
+
|
|
185
|
+
function listRuleFiles(dir, { depth = 8, seenReal = new Set() } = {}) {
|
|
186
|
+
let real;
|
|
187
|
+
try {
|
|
188
|
+
real = fs.realpathSync(dir);
|
|
189
|
+
} catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
// Symlinked rule directories are supported, and circular ones must not hang.
|
|
193
|
+
if (seenReal.has(real) || depth < 0) return [];
|
|
194
|
+
seenReal.add(real);
|
|
195
|
+
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
205
|
+
const full = path.join(dir, entry.name);
|
|
206
|
+
let stat;
|
|
207
|
+
try {
|
|
208
|
+
stat = fs.statSync(full); // follows symlinks, which is the point
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (stat.isDirectory()) out.push(...listRuleFiles(full, { depth: depth - 1, seenReal }));
|
|
213
|
+
else if (entry.name.endsWith('.md')) out.push(full);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function unquote(value) {
|
|
219
|
+
const trimmed = value.trim();
|
|
220
|
+
const first = trimmed[0];
|
|
221
|
+
const last = trimmed[trimmed.length - 1];
|
|
222
|
+
if (trimmed.length >= 2 && ((first === '"' && last === '"') || (first === "'" && last === "'"))) {
|
|
223
|
+
return trimmed.slice(1, -1);
|
|
224
|
+
}
|
|
225
|
+
return trimmed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The `paths:` patterns of a rule, read straight from the frontmatter block.
|
|
230
|
+
*
|
|
231
|
+
* The frontmatter reader shared with memory files models scalars and one level
|
|
232
|
+
* of nesting, which is everything memory frontmatter uses and not a YAML list.
|
|
233
|
+
* Rather than widen a parser the whole app depends on, the one construct rules
|
|
234
|
+
* need is read here.
|
|
235
|
+
*/
|
|
236
|
+
export function readPathsList(frontmatterRaw) {
|
|
237
|
+
const patterns = [];
|
|
238
|
+
let inPaths = false;
|
|
239
|
+
|
|
240
|
+
for (const line of frontmatterRaw.split('\n')) {
|
|
241
|
+
const key = line.match(/^([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
|
|
242
|
+
if (key) {
|
|
243
|
+
inPaths = key[1] === 'paths';
|
|
244
|
+
if (!inPaths) continue;
|
|
245
|
+
const value = key[2].trim();
|
|
246
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
247
|
+
for (const part of value.slice(1, -1).split(',')) {
|
|
248
|
+
const pattern = unquote(part);
|
|
249
|
+
if (pattern) patterns.push(pattern);
|
|
250
|
+
}
|
|
251
|
+
inPaths = false;
|
|
252
|
+
} else if (value) {
|
|
253
|
+
patterns.push(unquote(value));
|
|
254
|
+
inPaths = false;
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!inPaths) continue;
|
|
259
|
+
const item = line.match(/^[ \t]*-[ \t]+(.*)$/);
|
|
260
|
+
if (item) {
|
|
261
|
+
const pattern = unquote(item[1]);
|
|
262
|
+
if (pattern) patterns.push(pattern);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return patterns;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function ruleEntry(file, scope) {
|
|
269
|
+
const text = readIfFile(file);
|
|
270
|
+
if (text === null) return null;
|
|
271
|
+
const { raw } = parseFrontmatter(text);
|
|
272
|
+
const patterns = readPathsList(raw);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
file,
|
|
276
|
+
scope,
|
|
277
|
+
kind: 'rule',
|
|
278
|
+
text,
|
|
279
|
+
conditional: patterns.length > 0,
|
|
280
|
+
globs: patterns.length ? checkGlobList(patterns) : null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ------------------------------------------------------------------ loading */
|
|
285
|
+
|
|
286
|
+
function entry(file, scope, kind, { conditional = false } = {}) {
|
|
287
|
+
const text = readIfFile(file);
|
|
288
|
+
return text === null ? null : { file, scope, kind, text, conditional, globs: null };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Directories from the filesystem root down to `dir`, in load order. */
|
|
292
|
+
function ancestors(dir) {
|
|
293
|
+
const parts = path.resolve(dir).split(path.sep);
|
|
294
|
+
const out = [];
|
|
295
|
+
for (let i = 1; i < parts.length; i++) {
|
|
296
|
+
out.push(parts.slice(0, i + 1).join(path.sep) || path.sep);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function globToRegExp(pattern) {
|
|
302
|
+
// Only used for claudeMdExcludes, which matches absolute paths.
|
|
303
|
+
let out = '';
|
|
304
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
305
|
+
const char = pattern[i];
|
|
306
|
+
if (char === '*') {
|
|
307
|
+
if (pattern[i + 1] === '*') { out += '.*'; i += 1; if (pattern[i + 1] === '/') i += 1; }
|
|
308
|
+
else out += '[^/]*';
|
|
309
|
+
} else if (char === '?') out += '[^/]';
|
|
310
|
+
else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
311
|
+
}
|
|
312
|
+
return new RegExp(`^${out}$`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Everything that would load for a session started in `projectDir`, in the order
|
|
317
|
+
* Claude Code loads it: broadest scope first, so the most specific instruction is
|
|
318
|
+
* the last thing read.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveInstructions(projectDir) {
|
|
321
|
+
const home = os.homedir();
|
|
322
|
+
const layers = settingsLayers({ projectDir });
|
|
323
|
+
const excludeFound = lookup(layers, 'claudeMdExcludes');
|
|
324
|
+
const excludes = Array.isArray(excludeFound?.value) ? excludeFound.value.map(String) : [];
|
|
325
|
+
const excluded = [];
|
|
326
|
+
|
|
327
|
+
const candidates = [];
|
|
328
|
+
candidates.push(entry(managedClaudeMd(), 'managed', 'claude-md'));
|
|
329
|
+
|
|
330
|
+
const managedInline = lookup(layers, 'claudeMd');
|
|
331
|
+
const inline = typeof managedInline?.value === 'string' && managedInline.value.trim()
|
|
332
|
+
&& (managedInline.scope === 'managed')
|
|
333
|
+
? { file: managedInline.file, scope: 'managed', kind: 'managed-settings', text: managedInline.value, conditional: false, globs: null }
|
|
334
|
+
: null;
|
|
335
|
+
if (inline) candidates.push(inline);
|
|
336
|
+
|
|
337
|
+
candidates.push(entry(path.join(home, '.claude', 'CLAUDE.md'), 'user', 'claude-md'));
|
|
338
|
+
for (const file of listRuleFiles(path.join(home, '.claude', 'rules'))) {
|
|
339
|
+
candidates.push(ruleEntry(file, 'user'));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Root-to-cwd, CLAUDE.md then CLAUDE.local.md at each level.
|
|
343
|
+
for (const dir of ancestors(projectDir)) {
|
|
344
|
+
candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'project', 'claude-md'));
|
|
345
|
+
candidates.push(entry(path.join(dir, 'CLAUDE.local.md'), 'local', 'claude-md'));
|
|
346
|
+
}
|
|
347
|
+
candidates.push(entry(path.join(projectDir, '.claude', 'CLAUDE.md'), 'project', 'claude-md'));
|
|
348
|
+
for (const file of listRuleFiles(path.join(projectDir, '.claude', 'rules'))) {
|
|
349
|
+
candidates.push(ruleEntry(file, 'project'));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const matchers = excludes.map(globToRegExp);
|
|
353
|
+
const loaded = [];
|
|
354
|
+
const problems = [];
|
|
355
|
+
|
|
356
|
+
for (const candidate of candidates.filter(Boolean)) {
|
|
357
|
+
// Managed policy cannot be excluded; that is the point of managed policy.
|
|
358
|
+
if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
|
|
359
|
+
excluded.push(candidate.file);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
loaded.push(candidate);
|
|
363
|
+
|
|
364
|
+
if (candidate.kind === 'managed-settings') continue;
|
|
365
|
+
const expanded = expandImports(candidate.file, candidate.text, { projectDir });
|
|
366
|
+
problems.push(...expanded.problems.map((p) => ({ ...p, scope: candidate.scope })));
|
|
367
|
+
for (const imported of expanded.files) {
|
|
368
|
+
loaded.push({
|
|
369
|
+
file: imported.file,
|
|
370
|
+
scope: candidate.scope,
|
|
371
|
+
kind: 'import',
|
|
372
|
+
text: imported.text,
|
|
373
|
+
conditional: candidate.conditional,
|
|
374
|
+
globs: null,
|
|
375
|
+
importedBy: imported.importedBy,
|
|
376
|
+
depth: imported.depth,
|
|
377
|
+
external: imported.external,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// AGENTS.md is read by other tools, not by Claude Code, so one sitting next to
|
|
383
|
+
// an unrelated CLAUDE.md is a real and easy-to-miss divergence.
|
|
384
|
+
const agentsFile = path.join(projectDir, 'AGENTS.md');
|
|
385
|
+
if (fs.existsSync(agentsFile) && !loaded.some((item) => item.file === agentsFile)) {
|
|
386
|
+
problems.push({ kind: 'agents-md-not-imported', file: agentsFile, scope: 'project' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const item of loaded) {
|
|
390
|
+
item.lines = item.text.split('\n').length;
|
|
391
|
+
item.bytes = Buffer.byteLength(item.text, 'utf8');
|
|
392
|
+
item.tokens = estimateTokens(item.text);
|
|
393
|
+
if (item.globs && !item.globs.checks.every((c) => c.valid)) {
|
|
394
|
+
for (const check of item.globs.checks.filter((c) => !c.valid)) {
|
|
395
|
+
problems.push({ kind: 'invalid-glob', file: item.file, scope: item.scope, pattern: check.pattern, reason: check.reason });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (item.globs?.overBudget) {
|
|
399
|
+
problems.push({ kind: 'glob-budget', file: item.file, scope: item.scope, expansions: item.globs.expansions });
|
|
400
|
+
}
|
|
401
|
+
if (item.kind === 'claude-md' && item.lines > CLAUDE_MD_TARGET_LINES) {
|
|
402
|
+
problems.push({ kind: 'long-claude-md', file: item.file, scope: item.scope, lines: item.lines });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const unconditional = loaded.filter((item) => !item.conditional);
|
|
407
|
+
return {
|
|
408
|
+
projectDir,
|
|
409
|
+
files: loaded,
|
|
410
|
+
excluded,
|
|
411
|
+
problems,
|
|
412
|
+
totals: {
|
|
413
|
+
files: loaded.length,
|
|
414
|
+
// Conditional rules only load when Claude reads a matching file, so they
|
|
415
|
+
// are counted apart from what every session pays for.
|
|
416
|
+
alwaysLines: unconditional.reduce((sum, item) => sum + item.lines, 0),
|
|
417
|
+
alwaysBytes: unconditional.reduce((sum, item) => sum + item.bytes, 0),
|
|
418
|
+
alwaysTokens: unconditional.reduce((sum, item) => sum + item.tokens, 0),
|
|
419
|
+
conditionalFiles: loaded.length - unconditional.length,
|
|
420
|
+
conditionalTokens: loaded.filter((i) => i.conditional).reduce((sum, item) => sum + item.tokens, 0),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
package/src/model.mjs
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
// Builds the full view model for one
|
|
2
|
-
// wikilink graph between them, and the consistency problems worth surfacing.
|
|
1
|
+
// Builds the full view model for one memory store: its index, its memory files,
|
|
2
|
+
// the wikilink graph between them, and the consistency problems worth surfacing.
|
|
3
|
+
//
|
|
4
|
+
// A store is addressed by its directory, not by a root and a slug. Auto memory
|
|
5
|
+
// and subagent memory sit in unrelated places on disk but hold the same shape, so
|
|
6
|
+
// everything below is written against the shape and knows nothing about which
|
|
7
|
+
// kind it was handed.
|
|
3
8
|
//
|
|
4
9
|
// The whole store is a few hundred kilobytes, so this runs per request. No
|
|
5
10
|
// cache, no watcher, no invalidation bugs.
|
|
@@ -8,6 +13,7 @@ import fs from 'node:fs';
|
|
|
8
13
|
import path from 'node:path';
|
|
9
14
|
import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
|
|
10
15
|
import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
|
|
16
|
+
import { autoMemoryState } from './settings.mjs';
|
|
11
17
|
import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
|
|
12
18
|
|
|
13
19
|
export const TRASH_DIR = '.trash';
|
|
@@ -46,6 +52,13 @@ export function loadMemory(dir, file) {
|
|
|
46
52
|
stat = fs.statSync(path.join(dir, file));
|
|
47
53
|
} catch { /* raced with a delete */ }
|
|
48
54
|
|
|
55
|
+
// Claude Code stamps `modified` only on files that already begin with
|
|
56
|
+
// frontmatter, and never adds frontmatter to a file that has none, so for those
|
|
57
|
+
// files the mtime fallback is permanent. mtime is also reset by any copy, rsync
|
|
58
|
+
// or restore - including this tool's own trash round-trip - so which of the two
|
|
59
|
+
// a date came from is the difference between evidence and a guess.
|
|
60
|
+
const stamped = typeof metadata.modified === 'string' && metadata.modified ? metadata.modified : null;
|
|
61
|
+
|
|
49
62
|
return {
|
|
50
63
|
file,
|
|
51
64
|
stem,
|
|
@@ -60,7 +73,8 @@ export function loadMemory(dir, file) {
|
|
|
60
73
|
body,
|
|
61
74
|
raw,
|
|
62
75
|
bytes: stat ? stat.size : raw.length,
|
|
63
|
-
modified:
|
|
76
|
+
modified: stamped || (stat ? new Date(stat.mtimeMs).toISOString() : null),
|
|
77
|
+
modifiedFrom: stamped ? 'frontmatter' : (stat ? 'mtime' : null),
|
|
64
78
|
tokens: estimateTokens(raw),
|
|
65
79
|
outbound: extractWikilinks(body).map((w) => w.target),
|
|
66
80
|
};
|
|
@@ -80,10 +94,8 @@ function buildResolver(memories) {
|
|
|
80
94
|
return (target) => byName.get(target) || byStem.get(target) || null;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
export function
|
|
84
|
-
const dir =
|
|
85
|
-
const projectDir = path.join(root, slug);
|
|
86
|
-
const resolved = resolveProjectPath(projectDir, slug);
|
|
97
|
+
export function buildStore(store) {
|
|
98
|
+
const { dir } = store;
|
|
87
99
|
const hasMemoryDir = fs.existsSync(dir);
|
|
88
100
|
|
|
89
101
|
const indexRaw = readIfExists(path.join(dir, 'MEMORY.md'));
|
|
@@ -165,10 +177,7 @@ export function buildProject(root, slug) {
|
|
|
165
177
|
health.issueCount = health.issues.length;
|
|
166
178
|
|
|
167
179
|
return {
|
|
168
|
-
|
|
169
|
-
path: resolved.path,
|
|
170
|
-
label: shortLabel(resolved.path),
|
|
171
|
-
resolvedBy: resolved.resolvedBy,
|
|
180
|
+
...store,
|
|
172
181
|
hasMemoryDir,
|
|
173
182
|
hasIndex: index !== null,
|
|
174
183
|
index: index
|
|
@@ -197,6 +206,30 @@ export function buildProject(root, slug) {
|
|
|
197
206
|
};
|
|
198
207
|
}
|
|
199
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The auto-memory store for one project. Everything the project model carries
|
|
211
|
+
* beyond the store itself - its real path, the directories that feed it, whether
|
|
212
|
+
* Claude is still writing to it - is resolved here and handed to buildStore.
|
|
213
|
+
*/
|
|
214
|
+
export function buildProject(root, slug) {
|
|
215
|
+
const resolved = resolveProjectPath(path.join(root, slug), slug);
|
|
216
|
+
return buildStore({
|
|
217
|
+
slug,
|
|
218
|
+
kind: 'auto',
|
|
219
|
+
dir: memoryDir(root, slug),
|
|
220
|
+
path: resolved.path,
|
|
221
|
+
label: shortLabel(resolved.path),
|
|
222
|
+
resolvedBy: resolved.resolvedBy,
|
|
223
|
+
// The decode that could not be confirmed, offered as a starting point when
|
|
224
|
+
// the user is asked to name the path themselves.
|
|
225
|
+
guess: resolved.guess || null,
|
|
226
|
+
workingDirs: resolved.workingDirs || [],
|
|
227
|
+
// Off means this store will never grow again, which on disk looks exactly
|
|
228
|
+
// like a project Claude has not learned anything about yet.
|
|
229
|
+
autoMemory: autoMemoryState({ projectDir: resolved.exists ? resolved.path : null }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
/** Restore records left behind by soft deletes, newest first. */
|
|
201
234
|
export function listTrash(dir) {
|
|
202
235
|
const trashPath = path.join(dir, TRASH_DIR);
|
package/src/mutate.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
// The only code
|
|
1
|
+
// The only code that writes inside a memory store. Everything else is read-only.
|
|
2
|
+
//
|
|
3
|
+
// Every entry point takes the store's directory rather than a root and a slug:
|
|
4
|
+
// auto memory and subagent memory live in unrelated places on disk but hold the
|
|
5
|
+
// same MEMORY.md-plus-topic-files shape, so nothing below needs to know which
|
|
6
|
+
// kind it is working on.
|
|
2
7
|
//
|
|
3
8
|
// Deletes are soft: the file moves into memory/.trash/ and a restore record
|
|
4
9
|
// captures the MEMORY.md lines that were removed, with their original indices,
|
|
@@ -8,7 +13,7 @@ import crypto from 'node:crypto';
|
|
|
8
13
|
import fs from 'node:fs';
|
|
9
14
|
import path from 'node:path';
|
|
10
15
|
import { parseIndex, removeIndexEntries, removeLine, insertLines, unwrapWikilink } from './parse.mjs';
|
|
11
|
-
import { listMemoryFiles
|
|
16
|
+
import { listMemoryFiles } from './projects.mjs';
|
|
12
17
|
import { TRASH_DIR, loadMemory } from './model.mjs';
|
|
13
18
|
|
|
14
19
|
function sha256(text) {
|
|
@@ -94,8 +99,7 @@ const writeIndexAtomic = (dir, text) => writeFileAtomic(dir, 'MEMORY.md', text);
|
|
|
94
99
|
* What a delete would do, without doing it. This is what the confirm dialog
|
|
95
100
|
* renders, so it has to be exhaustive about the collateral.
|
|
96
101
|
*/
|
|
97
|
-
export function deletePreview(
|
|
98
|
-
const dir = memoryDir(root, slug);
|
|
102
|
+
export function deletePreview(dir, file) {
|
|
99
103
|
const full = safeMemoryPath(dir, file);
|
|
100
104
|
const exists = fs.existsSync(full);
|
|
101
105
|
const indexText = readIndex(dir);
|
|
@@ -165,8 +169,7 @@ export function deletePreview(root, slug, file) {
|
|
|
165
169
|
* clearing a whole project both come through here, so there is a single restore
|
|
166
170
|
* path rather than three.
|
|
167
171
|
*/
|
|
168
|
-
export function deleteMemories(
|
|
169
|
-
const dir = memoryDir(root, slug);
|
|
172
|
+
export function deleteMemories(dir, files, { includeIndex = false, label = null } = {}) {
|
|
170
173
|
const wanted = [...new Set([].concat(files))];
|
|
171
174
|
if (!wanted.length) throw new Error('Nothing selected to delete');
|
|
172
175
|
|
|
@@ -221,7 +224,6 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
|
|
|
221
224
|
id: `${stamp}_${includeIndex ? 'project' : targets[0].file}`,
|
|
222
225
|
label: label || (targets.length === 1 ? targets[0].name : `${targets.length} memories`),
|
|
223
226
|
deletedAt: new Date().toISOString(),
|
|
224
|
-
slug,
|
|
225
227
|
files: moved.map(({ file, trashedFile, name, description }) => ({ file, trashedFile, name, description })),
|
|
226
228
|
indexTrashedFile: moved.indexTrashed || null,
|
|
227
229
|
removedLines: removed,
|
|
@@ -233,18 +235,16 @@ export function deleteMemories(root, slug, files, { includeIndex = false, label
|
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
/** Single-memory delete, optionally cascading to the memories that link to it. */
|
|
236
|
-
export function deleteMemory(
|
|
238
|
+
export function deleteMemory(dir, file, alsoDelete = []) {
|
|
237
239
|
const extra = [].concat(alsoDelete).filter((f) => f && f !== file);
|
|
238
|
-
return deleteMemories(
|
|
240
|
+
return deleteMemories(dir, [file, ...extra]);
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
/** What clearing a whole project would remove. */
|
|
242
|
-
export function projectDeletePreview(
|
|
243
|
-
const dir = memoryDir(root, slug);
|
|
244
|
+
export function projectDeletePreview(dir) {
|
|
244
245
|
const files = listMemoryFiles(dir);
|
|
245
246
|
const indexText = readIndex(dir);
|
|
246
247
|
return {
|
|
247
|
-
slug,
|
|
248
248
|
files: files.map((file) => {
|
|
249
249
|
const memory = loadMemory(dir, file);
|
|
250
250
|
return { file, name: memory?.name || file, description: memory?.description || '' };
|
|
@@ -255,8 +255,7 @@ export function projectDeletePreview(root, slug) {
|
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** Trash every memory in a project, MEMORY.md included, as one operation. */
|
|
258
|
-
export function deleteProject(
|
|
259
|
-
const dir = memoryDir(root, slug);
|
|
258
|
+
export function deleteProject(dir) {
|
|
260
259
|
const files = listMemoryFiles(dir);
|
|
261
260
|
const indexText = readIndex(dir);
|
|
262
261
|
if (!files.length && indexText === null) throw new Error('This project has no memory to delete');
|
|
@@ -274,7 +273,6 @@ export function deleteProject(root, slug) {
|
|
|
274
273
|
id: `${stamp}_project`,
|
|
275
274
|
label: 'MEMORY.md',
|
|
276
275
|
deletedAt: new Date().toISOString(),
|
|
277
|
-
slug,
|
|
278
276
|
files: [],
|
|
279
277
|
indexTrashedFile: trashedIndex,
|
|
280
278
|
removedLines: [],
|
|
@@ -285,7 +283,7 @@ export function deleteProject(root, slug) {
|
|
|
285
283
|
return { deleted: true, record };
|
|
286
284
|
}
|
|
287
285
|
|
|
288
|
-
return deleteMemories(
|
|
286
|
+
return deleteMemories(dir, files, {
|
|
289
287
|
includeIndex: indexText !== null,
|
|
290
288
|
label: `whole project (${files.length} memories)`,
|
|
291
289
|
});
|
|
@@ -295,8 +293,7 @@ export function deleteProject(root, slug) {
|
|
|
295
293
|
* Turn a broken `[[target]]` into plain text in one memory. The original file
|
|
296
294
|
* is copied into .trash first so the edit can be undone like any delete.
|
|
297
295
|
*/
|
|
298
|
-
export function removeWikilink(
|
|
299
|
-
const dir = memoryDir(root, slug);
|
|
296
|
+
export function removeWikilink(dir, file, target) {
|
|
300
297
|
const full = safeMemoryPath(dir, file);
|
|
301
298
|
if (!fs.existsSync(full)) throw new Error(`No such memory: ${file}`);
|
|
302
299
|
if (typeof target !== 'string' || !target.trim()) throw new Error('No link target given');
|
|
@@ -324,7 +321,6 @@ export function removeWikilink(root, slug, file, target) {
|
|
|
324
321
|
id: `${stamp}_${file}.unlink`,
|
|
325
322
|
label: `[[${target}]] in ${file}`,
|
|
326
323
|
deletedAt: new Date().toISOString(),
|
|
327
|
-
slug,
|
|
328
324
|
sourceFile: file,
|
|
329
325
|
target,
|
|
330
326
|
occurrences: count,
|
|
@@ -337,8 +333,7 @@ export function removeWikilink(root, slug, file, target) {
|
|
|
337
333
|
}
|
|
338
334
|
|
|
339
335
|
/** Undo any trashed operation: a delete, a cascade, a project clear, or an unlink. */
|
|
340
|
-
export function restoreMemory(
|
|
341
|
-
const dir = memoryDir(root, slug);
|
|
336
|
+
export function restoreMemory(dir, id) {
|
|
342
337
|
const trashPath = path.join(dir, TRASH_DIR);
|
|
343
338
|
const recordPath = path.join(trashPath, `${id}.restore.json`);
|
|
344
339
|
if (!fs.existsSync(recordPath)) throw new Error('No such trash record');
|
|
@@ -403,8 +398,7 @@ export function restoreMemory(root, slug, id) {
|
|
|
403
398
|
}
|
|
404
399
|
|
|
405
400
|
/** Drop a single MEMORY.md line, used to clear a pointer whose file is gone. */
|
|
406
|
-
export function deleteIndexLine(
|
|
407
|
-
const dir = memoryDir(root, slug);
|
|
401
|
+
export function deleteIndexLine(dir, lineIndex, expectedText) {
|
|
408
402
|
const current = readIndex(dir);
|
|
409
403
|
if (current === null) throw new Error('This project has no MEMORY.md');
|
|
410
404
|
|