minnimemory 1.0.0-beta.1

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.
Files changed (59) hide show
  1. package/LICENSE +39 -0
  2. package/README.md +824 -0
  3. package/dist/bench.d.ts +98 -0
  4. package/dist/bench.js +142 -0
  5. package/dist/benchReport.d.ts +12 -0
  6. package/dist/benchReport.js +128 -0
  7. package/dist/bounds.d.ts +40 -0
  8. package/dist/bounds.js +44 -0
  9. package/dist/cli.d.ts +15 -0
  10. package/dist/cli.js +503 -0
  11. package/dist/compile.d.ts +187 -0
  12. package/dist/compile.js +516 -0
  13. package/dist/discover.d.ts +125 -0
  14. package/dist/discover.js +520 -0
  15. package/dist/doctor.d.ts +9 -0
  16. package/dist/doctor.js +67 -0
  17. package/dist/episodic.d.ts +47 -0
  18. package/dist/episodic.js +130 -0
  19. package/dist/hook.d.ts +45 -0
  20. package/dist/hook.js +104 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +18 -0
  23. package/dist/init.d.ts +125 -0
  24. package/dist/init.js +475 -0
  25. package/dist/instructions.d.ts +60 -0
  26. package/dist/instructions.js +270 -0
  27. package/dist/mcp.d.ts +109 -0
  28. package/dist/mcp.js +252 -0
  29. package/dist/mcpServer.d.ts +136 -0
  30. package/dist/mcpServer.js +997 -0
  31. package/dist/paths.d.ts +25 -0
  32. package/dist/paths.js +47 -0
  33. package/dist/recall.d.ts +113 -0
  34. package/dist/recall.js +256 -0
  35. package/dist/recallDir.d.ts +50 -0
  36. package/dist/recallDir.js +187 -0
  37. package/dist/reorganize.d.ts +62 -0
  38. package/dist/reorganize.js +216 -0
  39. package/dist/report.d.ts +16 -0
  40. package/dist/report.js +204 -0
  41. package/dist/router.d.ts +141 -0
  42. package/dist/router.js +314 -0
  43. package/dist/rules.d.ts +32 -0
  44. package/dist/rules.js +651 -0
  45. package/dist/scan.d.ts +110 -0
  46. package/dist/scan.js +173 -0
  47. package/dist/text.d.ts +158 -0
  48. package/dist/text.js +395 -0
  49. package/dist/tokenizer.d.ts +26 -0
  50. package/dist/tokenizer.js +69 -0
  51. package/dist/types.d.ts +156 -0
  52. package/dist/types.js +17 -0
  53. package/dist/version.d.ts +7 -0
  54. package/dist/version.js +7 -0
  55. package/dist/writeProtocol.d.ts +19 -0
  56. package/dist/writeProtocol.js +45 -0
  57. package/examples/CLAUDE.md +75 -0
  58. package/examples/README.md +7 -0
  59. package/package.json +52 -0
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Work out what kind of memory setup we are pointed at, and which files the model
3
+ * actually pays for on every turn.
4
+ */
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { ALWAYS_ON_FILE_NAME, COMPILED_DIR_NAME, isStub, ON_DEMAND_DIR_NAME } from "./compile.js";
9
+ import { containedIn } from "./paths.js";
10
+ import { LIST_END, LIST_START, regionOnly } from "./text.js";
11
+ import { estimateTokens } from "./tokenizer.js";
12
+ /** Memory files a host agent loads automatically. */
13
+ export const HOST_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
14
+ /** Names that mean "this is the OnDemandMemory list for the directory". */
15
+ export const LIST_FILE_NAMES = ["MEMORY.md", "index.md", "INDEX.md", "README.md"];
16
+ const COMPILED_DIR = COMPILED_DIR_NAME;
17
+ /**
18
+ * Directories never descended into when looking for memory content.
19
+ * This governs traversal only. A directory named here is still audited when the user
20
+ * points at it explicitly, which is how an archived corpus gets inspected.
21
+ */
22
+ export const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "__pycache__"]);
23
+ export class DiscoveryError extends Error {
24
+ }
25
+ function toRel(root, abs) {
26
+ return path.relative(root, abs).split(path.sep).join("/");
27
+ }
28
+ function readFile(root, abs, kind, alwaysLoadedFlag, generated = false) {
29
+ const content = fs.readFileSync(abs, "utf8");
30
+ return {
31
+ abs,
32
+ rel: toRel(root, abs) || path.basename(abs),
33
+ content,
34
+ lines: content.split(/\r?\n/),
35
+ tokens: estimateTokens(content),
36
+ alwaysLoaded: alwaysLoadedFlag,
37
+ kind,
38
+ generated,
39
+ };
40
+ }
41
+ function listMarkdown(dir) {
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(dir, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ return entries
50
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".md"))
51
+ .map((e) => path.join(dir, e.name))
52
+ .sort();
53
+ }
54
+ /** Compiled OnDemandMemory files: Markdown, or JSON for an episodic file written under O3. */
55
+ function listOnDemandFiles(dir) {
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ return entries
64
+ .filter((e) => e.isFile() && /\.(md|json)$/i.test(e.name))
65
+ .map((e) => path.join(dir, e.name))
66
+ .sort();
67
+ }
68
+ /** True for a regular file that is not a symlink. `lstat` on purpose: a cloned repo can ship a link. */
69
+ export function isPlainFile(abs) {
70
+ try {
71
+ return fs.lstatSync(abs).isFile();
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /** Cheap, read-free mirror of discover()'s own branching, stopping as soon as the answer is
78
+ * known: a host file present means `host-file` shape (not a memory directory), three or more
79
+ * loose markdown files means `memory-dir`, and only then is the OS-level auto-memory folder
80
+ * checked. Mirrors discover()'s priority order without reading any file's content. */
81
+ function looksLikeMemoryDir(root) {
82
+ try {
83
+ if (findHostFile(root))
84
+ return false;
85
+ }
86
+ catch {
87
+ // A symlinked host file: discover() would throw; detectPhase resolves every failure to
88
+ // "setup", so this is not a memory-dir either.
89
+ return false;
90
+ }
91
+ if (listMarkdown(root).length >= 3)
92
+ return true;
93
+ const autoMemoryRoot = locateAutoMemoryDir(root);
94
+ if (autoMemoryRoot && listMarkdown(autoMemoryRoot).length > 0)
95
+ return true;
96
+ return false;
97
+ }
98
+ export function detectPhase(root) {
99
+ try {
100
+ const compiledDir = path.join(root, COMPILED_DIR);
101
+ if (fs.existsSync(compiledDir) && fs.statSync(compiledDir).isDirectory()) {
102
+ const manifestPath = path.join(compiledDir, "manifest.json");
103
+ return fs.existsSync(manifestPath) && fs.statSync(manifestPath).isFile() ? "compiled" : "setup";
104
+ }
105
+ return looksLikeMemoryDir(root) ? "memory-dir" : "setup";
106
+ }
107
+ catch {
108
+ return "setup";
109
+ }
110
+ }
111
+ function findHostFile(dir) {
112
+ for (const name of HOST_FILES) {
113
+ const candidate = path.join(dir, name);
114
+ if (isPlainFile(candidate))
115
+ return candidate;
116
+ let link = false;
117
+ try {
118
+ link = fs.lstatSync(candidate).isSymbolicLink();
119
+ }
120
+ catch {
121
+ link = false;
122
+ }
123
+ if (link) {
124
+ throw new DiscoveryError(`${name} in ${dir} is a symlink; refusing to follow it\n` +
125
+ ` a cloned repo can point a memory file at anything on your machine`);
126
+ }
127
+ }
128
+ return undefined;
129
+ }
130
+ /** Claude Code `@path` import syntax, read outside code spans and fenced blocks. */
131
+ const RE_IMPORT = /(?:^|[\s(])@((?:~\/|\.{0,2}\/)?[\w.\-~][\w.\-~/\\]*)/g;
132
+ const RE_FENCE = /^\s*(```|~~~)/;
133
+ const MAX_IMPORT_DEPTH = 5;
134
+ function stripCode(content) {
135
+ let inFence = false;
136
+ return content
137
+ .split(/\r?\n/)
138
+ .map((line) => {
139
+ if (RE_FENCE.test(line)) {
140
+ inFence = !inFence;
141
+ return "";
142
+ }
143
+ return inFence ? "" : line.replace(/`[^`]*`/g, " ");
144
+ })
145
+ .join("\n");
146
+ }
147
+ /**
148
+ * Resolve the files a memory file pulls into the always-loaded prefix through `@path` imports,
149
+ * the way Claude Code does: relative to the importing file, `~/` allowed, recursive to a depth
150
+ * of five, each file once. A path that does not resolve is ignored rather than reported: an
151
+ * `@` in prose is not an error.
152
+ *
153
+ * Imports that land outside `root` are NOT followed unless `followExternal` is set.
154
+ *
155
+ * The memory file being audited is frequently one you did not write - the whole pitch is
156
+ * pointing this at a repo, including in CI - and an `@` line is content that repo controls.
157
+ * Following `@~/.claude/CLAUDE.md` from a cloned repo read seventeen files out of the auditor's
158
+ * home directory, whose headings, frontmatter, body-derived keywords and credential prefixes
159
+ * then surfaced in doctor and scan output. This is the same threat findHostFile already refuses
160
+ * symlinks for ("a cloned repo can point a memory file at anything on your machine"); imports
161
+ * were the second door to the same room.
162
+ *
163
+ * Following them remains correct for your own repo, where an out-of-root import genuinely is
164
+ * part of the prefix you pay for, so it stays available - as a decision the person running the
165
+ * tool makes, not one the scanned repo makes for them.
166
+ */
167
+ export function resolveImports(hostAbs, root, followExternal = false) {
168
+ const seen = new Set();
169
+ const files = [];
170
+ const skipped = [];
171
+ const visit = (abs, depth) => {
172
+ if (depth > MAX_IMPORT_DEPTH)
173
+ return;
174
+ let content;
175
+ try {
176
+ content = fs.readFileSync(abs, "utf8");
177
+ }
178
+ catch {
179
+ return;
180
+ }
181
+ const text = stripCode(content);
182
+ // A fresh regex per call, not the module-level RE_IMPORT: that one carries lastIndex state
183
+ // on its own instance, so a recursive visit() call sharing it resets the outer scan's
184
+ // position mid-loop.
185
+ const re = new RegExp(RE_IMPORT.source, "g");
186
+ let m;
187
+ while ((m = re.exec(text)) !== null) {
188
+ const raw = m[1] ?? "";
189
+ const target = raw.startsWith("~/")
190
+ ? path.join(os.homedir(), raw.slice(2))
191
+ : path.resolve(path.dirname(abs), raw);
192
+ if (seen.has(target) || target === hostAbs)
193
+ continue;
194
+ // Containment is decided before the file is touched: no existsSync, no stat, no read, so
195
+ // an out-of-root import cannot even be probed for existence.
196
+ if (!followExternal && !containedIn(root, target)) {
197
+ seen.add(target);
198
+ if (!skipped.includes(`@${raw}`))
199
+ skipped.push(`@${raw}`);
200
+ continue;
201
+ }
202
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile())
203
+ continue;
204
+ seen.add(target);
205
+ files.push(target);
206
+ visit(target, depth + 1);
207
+ }
208
+ };
209
+ visit(hostAbs, 1);
210
+ return { files, skipped };
211
+ }
212
+ /**
213
+ * A pointer stub is a host file whose entire content, headings and comments aside, names one
214
+ * other memory file: "@AGENTS.md", "AGENTS.md", "See `AGENTS.md` for the shared instructions".
215
+ * Eight of the twenty-three public CLAUDE.md files in the 2026-09-02 sweep were exactly this.
216
+ * Returns the absolute path of the named file when it exists next to the host, else undefined.
217
+ */
218
+ export function pointerTargetOf(hostAbs, content) {
219
+ const meaningful = content
220
+ .split(/\r?\n/)
221
+ .map((l) => l.trim())
222
+ .filter((l) => l !== "" && !l.startsWith("#") && !l.startsWith("<!--"));
223
+ if (meaningful.length === 0 || meaningful.length > 2)
224
+ return undefined;
225
+ if (content.length > 400)
226
+ return undefined;
227
+ const line = meaningful[meaningful.length - 1] ?? "";
228
+ const m = /(?:^|[\s`@])([\w.\-]+\.md)\b/i.exec(line);
229
+ if (!m?.[1])
230
+ return undefined;
231
+ const target = path.join(path.dirname(hostAbs), m[1]);
232
+ if (path.resolve(target) === path.resolve(hostAbs))
233
+ return undefined;
234
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile())
235
+ return undefined;
236
+ return target;
237
+ }
238
+ /** Read the host file plus everything it imports, all always-loaded. */
239
+ function readHostWithImports(root, hostAbs, followExternal = false) {
240
+ const host = readFile(root, hostAbs, "host", true);
241
+ const files = [host];
242
+ const included = new Set([path.resolve(hostAbs)]);
243
+ const { files: imported, skipped: skippedImports } = resolveImports(hostAbs, root, followExternal);
244
+ for (const abs of imported) {
245
+ if (included.has(path.resolve(abs)))
246
+ continue;
247
+ included.add(path.resolve(abs));
248
+ files.push(readFile(root, abs, "import", true));
249
+ }
250
+ const pointer = pointerTargetOf(hostAbs, host.content);
251
+ let pointerTarget;
252
+ if (pointer && !included.has(path.resolve(pointer))) {
253
+ files.push(readFile(root, pointer, "import", true));
254
+ pointerTarget = toRel(root, pointer);
255
+ }
256
+ else if (pointer) {
257
+ pointerTarget = toRel(root, pointer);
258
+ }
259
+ return { files, pointerTarget, skippedImports };
260
+ }
261
+ function findListFile(files) {
262
+ for (const name of LIST_FILE_NAMES) {
263
+ const hit = files.find((f) => path.basename(f) === name);
264
+ if (hit)
265
+ return hit;
266
+ }
267
+ return undefined;
268
+ }
269
+ /**
270
+ * Walk up from `start` looking for a `.git` entry (directory for a normal repo, file for a
271
+ * worktree checkout - either marks a repo root). Returns the repo root, or undefined outside
272
+ * any git repo. This mirrors Claude Code's own auto-memory location rule: memory is keyed to
273
+ * the git repo root, not to wherever a host file happens to live.
274
+ */
275
+ function findGitRoot(start) {
276
+ let dir = start;
277
+ for (;;) {
278
+ if (fs.existsSync(path.join(dir, ".git")))
279
+ return dir;
280
+ const parent = path.dirname(dir);
281
+ if (parent === dir)
282
+ return undefined;
283
+ dir = parent;
284
+ }
285
+ }
286
+ /**
287
+ * Claude Code's own project-key algorithm, reverse-engineered empirically (not published), and
288
+ * verified against this machine's real `~/.claude/projects/` folder names across a dozen
289
+ * independent examples, including nested repos and a path containing `&`: every character that
290
+ * is not a-z, A-Z, or 0-9 becomes a single `-`. No collapsing of adjacent replacements, no case
291
+ * change. `C:\Code` -> `C--Code`; `C:\Code\Widgets\App` -> `C--Code-Widgets-App`.
292
+ */
293
+ export function claudeProjectSlug(absPath) {
294
+ return absPath.replace(/[^a-zA-Z0-9]/g, "-");
295
+ }
296
+ /** `CLAUDE_CONFIG_DIR` if set, else `~/.claude` - where a dev's Claude Code config actually lives. */
297
+ function claudeConfigDir() {
298
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
299
+ }
300
+ /**
301
+ * Locate the auto-memory folder that belongs to `target`, the same way Claude Code itself would
302
+ * resolve it: git-repo-root if `target` is inside a repo, `target` itself otherwise, then
303
+ * Claude Code's own slug algorithm. Returns undefined, never throws, when nothing is found there -
304
+ * most targets simply have never been launched with Claude Code and that is not an error.
305
+ */
306
+ export function locateAutoMemoryDir(target) {
307
+ const abs = path.resolve(target);
308
+ const projectRoot = findGitRoot(abs) ?? abs;
309
+ const slug = claudeProjectSlug(projectRoot);
310
+ const candidate = path.join(claudeConfigDir(), "projects", slug, "memory");
311
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory())
312
+ return candidate;
313
+ return undefined;
314
+ }
315
+ /**
316
+ * Read an auto-memory folder's files as MemoryFiles. MEMORY.md, when present, is the always-loaded
317
+ * OnDemandMemory list (capped at 200 lines/25KB by Claude Code itself, matched here as
318
+ * informational only - this tool does not enforce that cap, MM001's own budget check covers the
319
+ * always-loaded cost). Every other top-level .md file is a topic file, read on demand only,
320
+ * matching how Claude Code actually loads them. `archive/` and other subdirectories are not
321
+ * descended into - stale/historical content a dev deliberately moved out of the way, not live
322
+ * memory to audit.
323
+ *
324
+ * `rel` is computed against the auto-memory folder itself, not the caller's workspace root - the
325
+ * two roots are genuinely different directories on disk - and prefixed so a finding referencing
326
+ * one of these files reads unambiguously in a report.
327
+ */
328
+ function readAutoMemoryFiles(memoryDir) {
329
+ const files = [];
330
+ let onDemandList;
331
+ const markdown = listMarkdown(memoryDir);
332
+ for (const abs of markdown) {
333
+ const isList = path.basename(abs) === "MEMORY.md";
334
+ const content = fs.readFileSync(abs, "utf8");
335
+ const file = {
336
+ abs,
337
+ rel: `(auto-memory)/${path.basename(abs)}`,
338
+ content,
339
+ lines: content.split(/\r?\n/),
340
+ tokens: estimateTokens(content),
341
+ alwaysLoaded: isList,
342
+ kind: isList ? "onDemandList" : "loose",
343
+ };
344
+ if (isList)
345
+ onDemandList = file;
346
+ files.push(file);
347
+ }
348
+ return { files, onDemandList };
349
+ }
350
+ /**
351
+ * Inspect `target` and build a Workspace.
352
+ *
353
+ * The important output is which files carry `alwaysLoaded: true`, because that set is what
354
+ * the whole tool is trying to shrink.
355
+ */
356
+ export function discover(target, options = {}) {
357
+ const followExternal = options.followExternalImports === true;
358
+ const includeAutoMemory = options.includeAutoMemory !== false;
359
+ const abs = path.resolve(target);
360
+ if (!fs.existsSync(abs)) {
361
+ throw new DiscoveryError(`no such file or directory: ${abs}`);
362
+ }
363
+ const stat = fs.statSync(abs);
364
+ // A single file passed directly. It is the whole always-loaded prefix.
365
+ if (stat.isFile()) {
366
+ const root = path.dirname(abs);
367
+ return {
368
+ root,
369
+ shape: "single-file",
370
+ files: [readFile(root, abs, "host", true)],
371
+ };
372
+ }
373
+ const root = abs;
374
+ const compiledDir = path.join(root, COMPILED_DIR);
375
+ // Already compiled.
376
+ if (fs.existsSync(compiledDir) && fs.statSync(compiledDir).isDirectory()) {
377
+ const files = [];
378
+ let onDemandList;
379
+ // When the host file is a generated stub it already contains the AlwaysOnMemory body and
380
+ // the OnDemandMemory list inline, so counting those files again would double-charge the
381
+ // prefix.
382
+ const hostPath = findHostFile(root);
383
+ const inlined = hostPath !== undefined && isStub(fs.readFileSync(hostPath, "utf8"));
384
+ // AlwaysOnMemory.md holds always-on prose and the OnDemandMemory list in one file (the same
385
+ // join the stub uses). It is pushed once, whole, as the "alwaysOn" file so the always-loaded
386
+ // token total is not double-charged; `onDemandList` is a second view over the same bytes,
387
+ // its lines blanked outside the list's own marked region so a finding against it still names
388
+ // a real line number in AlwaysOnMemory.md, without a second copy of the shared content in
389
+ // ws.files.
390
+ const alwaysOnPath = path.join(compiledDir, ALWAYS_ON_FILE_NAME);
391
+ if (fs.existsSync(alwaysOnPath)) {
392
+ const content = fs.readFileSync(alwaysOnPath, "utf8");
393
+ const rel = toRel(root, alwaysOnPath) || path.basename(alwaysOnPath);
394
+ const lines = content.split(/\r?\n/);
395
+ files.push({
396
+ abs: alwaysOnPath,
397
+ rel,
398
+ content,
399
+ lines,
400
+ tokens: estimateTokens(content),
401
+ alwaysLoaded: !inlined,
402
+ kind: "alwaysOn",
403
+ generated: inlined,
404
+ });
405
+ const listLines = regionOnly(lines, LIST_START, LIST_END);
406
+ const listContent = listLines.join("\n");
407
+ if (listContent.trim()) {
408
+ onDemandList = {
409
+ abs: alwaysOnPath,
410
+ rel,
411
+ content: listContent,
412
+ lines: listLines,
413
+ tokens: estimateTokens(listContent),
414
+ alwaysLoaded: false,
415
+ kind: "onDemandList",
416
+ generated: true,
417
+ };
418
+ }
419
+ }
420
+ for (const m of listOnDemandFiles(path.join(compiledDir, ON_DEMAND_DIR_NAME))) {
421
+ files.push(readFile(root, m, "onDemand", false));
422
+ }
423
+ // The host file is re-sent every turn, and so is anything it imports.
424
+ let skippedImports = [];
425
+ if (hostPath) {
426
+ const hostRead = readHostWithImports(root, hostPath, followExternal);
427
+ files.push(...hostRead.files);
428
+ skippedImports = hostRead.skippedImports;
429
+ }
430
+ // Fold in auto-memory files too. The compiled OnDemandMemory list keeps the ws.onDemandList
431
+ // slot; the auto-memory MEMORY.md goes in autoMemoryList so MM004/MM006 can check each side
432
+ // against the list that routes to it.
433
+ const autoMemoryRoot = includeAutoMemory ? locateAutoMemoryDir(root) : undefined;
434
+ let autoMemoryList;
435
+ if (autoMemoryRoot) {
436
+ const autoMemory = readAutoMemoryFiles(autoMemoryRoot);
437
+ files.push(...autoMemory.files);
438
+ autoMemoryList = autoMemory.onDemandList;
439
+ }
440
+ // The manifest, for rules that compare disk against what init wrote (MM010). An unreadable
441
+ // or malformed manifest is not this function's problem: the workspace is still auditable
442
+ // without it, and mcp reports the manifest error itself when it needs one.
443
+ let manifest;
444
+ try {
445
+ const raw = JSON.parse(fs.readFileSync(path.join(compiledDir, "manifest.json"), "utf8"));
446
+ if (raw && typeof raw.sourceHash === "string" && raw.always && Array.isArray(raw.onDemandFiles)) {
447
+ manifest = raw;
448
+ }
449
+ }
450
+ catch {
451
+ manifest = undefined;
452
+ }
453
+ return { root, shape: "compiled", files, onDemandList, autoMemoryRoot, autoMemoryList, manifest, skippedImports };
454
+ }
455
+ // A repo with a host memory file but no compilation yet. This is the common case, and the one
456
+ // that gets the auto-memory folder folded in: there is no existing compiled OnDemandMemory
457
+ // list competing for the ws.onDemandList slot here, so the auto-memory MEMORY.md can take it,
458
+ // and MM004/MM006 (which key off ws.onDemandList) get real coverage over the auto-memory side,
459
+ // not just MM001/MM003 (which scan ws.files broadly regardless of which file holds the list
460
+ // slot).
461
+ const host = findHostFile(root);
462
+ if (host) {
463
+ const { files, pointerTarget, skippedImports } = readHostWithImports(root, host, followExternal);
464
+ let onDemandList;
465
+ const autoMemoryRoot = includeAutoMemory ? locateAutoMemoryDir(root) : undefined;
466
+ if (autoMemoryRoot) {
467
+ const autoMemory = readAutoMemoryFiles(autoMemoryRoot);
468
+ files.push(...autoMemory.files);
469
+ onDemandList = autoMemory.onDemandList;
470
+ }
471
+ return {
472
+ root,
473
+ shape: "host-file",
474
+ files,
475
+ onDemandList,
476
+ autoMemoryRoot,
477
+ autoMemoryList: onDemandList,
478
+ pointerTarget,
479
+ skippedImports,
480
+ };
481
+ }
482
+ // A bare directory of memory files.
483
+ const markdown = listMarkdown(root);
484
+ if (markdown.length >= 3) {
485
+ const listPath = findListFile(markdown);
486
+ const files = [];
487
+ let onDemandList;
488
+ for (const f of markdown) {
489
+ const isList = f === listPath;
490
+ // With an OnDemandMemory list present, only the list is always loaded and the rest are
491
+ // routed. With no list, there is nothing to route with, so the whole directory gets read.
492
+ const always = isList ? true : listPath === undefined;
493
+ const file = readFile(root, f, isList ? "onDemandList" : "loose", always);
494
+ if (isList)
495
+ onDemandList = file;
496
+ files.push(file);
497
+ }
498
+ return { root, shape: "memory-dir", files, onDemandList };
499
+ }
500
+ // A project with no host file of its own can still carry real memory: Claude Code keeps the
501
+ // auto-memory folder outside the project entirely, keyed by the git root. A project that has
502
+ // only ever been driven through auto-memory is memory worth auditing, not an empty target.
503
+ const autoMemoryRoot = includeAutoMemory ? locateAutoMemoryDir(root) : undefined;
504
+ if (autoMemoryRoot) {
505
+ const autoMemory = readAutoMemoryFiles(autoMemoryRoot);
506
+ if (autoMemory.files.length > 0) {
507
+ return {
508
+ root,
509
+ shape: "auto-memory",
510
+ files: autoMemory.files,
511
+ onDemandList: autoMemory.onDemandList,
512
+ autoMemoryRoot,
513
+ autoMemoryList: autoMemory.onDemandList,
514
+ };
515
+ }
516
+ }
517
+ throw new DiscoveryError(`no memory files found in ${abs}\n` +
518
+ ` looked for: ${HOST_FILES.join(", ")}, a ${COMPILED_DIR}/ directory, ` +
519
+ `a directory holding at least 3 markdown files, or a Claude Code auto-memory folder for it`);
520
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The doctor orchestrator: discover a workspace, run the rule set over it, sort the results.
3
+ */
4
+ import { type DoctorConfig, type DoctorResult, type Workspace } from "./types.js";
5
+ export declare function resolveConfig(partial?: Partial<DoctorConfig>): DoctorConfig;
6
+ /** Run the rule set against an already-discovered workspace. */
7
+ export declare function auditWorkspace(workspace: Workspace, partial?: Partial<DoctorConfig>): DoctorResult;
8
+ /** Discover and audit in one step. */
9
+ export declare function doctor(target: string, partial?: Partial<DoctorConfig>): DoctorResult;
package/dist/doctor.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The doctor orchestrator: discover a workspace, run the rule set over it, sort the results.
3
+ */
4
+ import { discover } from "./discover.js";
5
+ import { RULES } from "./rules.js";
6
+ import { TOKENIZER_ID } from "./tokenizer.js";
7
+ import { alwaysLoaded, DEFAULT_CONFIG, SEVERITY_ORDER, } from "./types.js";
8
+ export function resolveConfig(partial = {}) {
9
+ return { ...DEFAULT_CONFIG, ...partial };
10
+ }
11
+ function selectedRules(config) {
12
+ const only = config.only.map((s) => s.toUpperCase());
13
+ const ignore = config.ignore.map((s) => s.toUpperCase());
14
+ return RULES.filter((rule) => {
15
+ const id = rule.id.toUpperCase();
16
+ if (only.length > 0 && !only.includes(id))
17
+ return false;
18
+ if (ignore.includes(id))
19
+ return false;
20
+ return true;
21
+ });
22
+ }
23
+ /** Most severe first, then by rule id, then by file and line, so output is stable. */
24
+ function sortFindings(findings) {
25
+ return [...findings].sort((a, b) => {
26
+ const sev = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
27
+ if (sev !== 0)
28
+ return sev;
29
+ if (a.rule !== b.rule)
30
+ return a.rule.localeCompare(b.rule);
31
+ if (a.file !== b.file)
32
+ return a.file.localeCompare(b.file);
33
+ return (a.startLine ?? 0) - (b.startLine ?? 0);
34
+ });
35
+ }
36
+ /** Run the rule set against an already-discovered workspace. */
37
+ export function auditWorkspace(workspace, partial = {}) {
38
+ const config = resolveConfig(partial);
39
+ const findings = [];
40
+ for (const rule of selectedRules(config)) {
41
+ // A crashing rule must not take the whole run down with it.
42
+ try {
43
+ findings.push(...rule.run({ workspace, config }));
44
+ }
45
+ catch (err) {
46
+ findings.push({
47
+ rule: rule.id,
48
+ severity: "low",
49
+ message: `rule failed to run: ${err.message}`,
50
+ file: ".",
51
+ });
52
+ }
53
+ }
54
+ const loaded = alwaysLoaded(workspace);
55
+ return {
56
+ workspace,
57
+ findings: sortFindings(findings),
58
+ alwaysLoadedTokens: loaded.reduce((n, f) => n + f.tokens, 0),
59
+ alwaysLoadedFiles: loaded.length,
60
+ tokenizer: TOKENIZER_ID,
61
+ };
62
+ }
63
+ /** Discover and audit in one step. */
64
+ export function doctor(target, partial = {}) {
65
+ const config = resolveConfig(partial);
66
+ return auditWorkspace(discover(target, { followExternalImports: config.followExternalImports, includeAutoMemory: config.includeAutoMemory }), partial);
67
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * O2 and O3: what kind of memory a section holds, and the JSON form for the episodic kind.
3
+ *
4
+ * O2 classifies every long-term unit as semantic (facts true now, edited in place),
5
+ * episodic (things that happened, append-only) or procedural (a learned rule, edited
6
+ * rarely). The compiler decides by section, not by filename, because a `project` file blends
7
+ * all three. O3 writes episodic content as JSON, on the documented finding that a model is
8
+ * less likely to rewrite or summarize JSON it was only meant to append to. The JSON form is
9
+ * lossless and reversible: `episodicToMarkdown(fromMarkdown(x))` gives back x.
10
+ */
11
+ export type MemoryKind = "semantic" | "episodic" | "procedural";
12
+ /** Decide a section's kind from its heading and body (O2). */
13
+ export declare function classifyKind(heading: string, bodyLines: string[]): MemoryKind;
14
+ export interface EpisodicEntry {
15
+ /** the first date found in the entry's opening line, when there is one */
16
+ date: string | null;
17
+ /** the entry's lines, verbatim, joined with newlines */
18
+ text: string;
19
+ }
20
+ export interface EpisodicFile {
21
+ format: "minnimemory-episodic";
22
+ version: 1;
23
+ heading: string;
24
+ kind: "episodic";
25
+ /** lines between the heading and the first entry, verbatim */
26
+ preamble: string;
27
+ entries: EpisodicEntry[];
28
+ }
29
+ /**
30
+ * Parse an OnDemandMemory file's markdown (a heading line then a body of mostly dated bullets) into the
31
+ * JSON form. Every line of the source lands in exactly one field, in order, so the markdown
32
+ * can be rebuilt byte for byte (trailing newlines aside).
33
+ */
34
+ export declare function episodicFromMarkdown(markdown: string): EpisodicFile;
35
+ /** The exact markdown the JSON came from. */
36
+ export declare function episodicToMarkdown(mod: EpisodicFile, headingLevel?: number): string;
37
+ /** Serialize for disk: two-space JSON, trailing newline, stable key order. */
38
+ export declare function episodicToJson(mod: EpisodicFile): string;
39
+ /**
40
+ * Read a `.json` OnDemandMemory file back into markdown for the router and the agent-facing tools.
41
+ *
42
+ * Validated field by field, not cast. This parses a file from the workspace, which on a cloned
43
+ * repo is a file someone else wrote; a bare `as EpisodicFile` turned a wrong-shaped `entries`
44
+ * into an unhandled TypeError surfacing as "unexpected error", where the manifest right beside
45
+ * it has been schema-checked all along.
46
+ */
47
+ export declare function episodicJsonToMarkdown(json: string): string;