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.
- package/LICENSE +39 -0
- package/README.md +824 -0
- package/dist/bench.d.ts +98 -0
- package/dist/bench.js +142 -0
- package/dist/benchReport.d.ts +12 -0
- package/dist/benchReport.js +128 -0
- package/dist/bounds.d.ts +40 -0
- package/dist/bounds.js +44 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +503 -0
- package/dist/compile.d.ts +187 -0
- package/dist/compile.js +516 -0
- package/dist/discover.d.ts +125 -0
- package/dist/discover.js +520 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +67 -0
- package/dist/episodic.d.ts +47 -0
- package/dist/episodic.js +130 -0
- package/dist/hook.d.ts +45 -0
- package/dist/hook.js +104 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/init.d.ts +125 -0
- package/dist/init.js +475 -0
- package/dist/instructions.d.ts +60 -0
- package/dist/instructions.js +270 -0
- package/dist/mcp.d.ts +109 -0
- package/dist/mcp.js +252 -0
- package/dist/mcpServer.d.ts +136 -0
- package/dist/mcpServer.js +997 -0
- package/dist/paths.d.ts +25 -0
- package/dist/paths.js +47 -0
- package/dist/recall.d.ts +113 -0
- package/dist/recall.js +256 -0
- package/dist/recallDir.d.ts +50 -0
- package/dist/recallDir.js +187 -0
- package/dist/reorganize.d.ts +62 -0
- package/dist/reorganize.js +216 -0
- package/dist/report.d.ts +16 -0
- package/dist/report.js +204 -0
- package/dist/router.d.ts +141 -0
- package/dist/router.js +314 -0
- package/dist/rules.d.ts +32 -0
- package/dist/rules.js +651 -0
- package/dist/scan.d.ts +110 -0
- package/dist/scan.js +173 -0
- package/dist/text.d.ts +158 -0
- package/dist/text.js +395 -0
- package/dist/tokenizer.d.ts +26 -0
- package/dist/tokenizer.js +69 -0
- package/dist/types.d.ts +156 -0
- package/dist/types.js +17 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +7 -0
- package/dist/writeProtocol.d.ts +19 -0
- package/dist/writeProtocol.js +45 -0
- package/examples/CLAUDE.md +75 -0
- package/examples/README.md +7 -0
- package/package.json +52 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest-less recall over a memory folder (Layout B): an index file (one of
|
|
3
|
+
* discover.ts's `LIST_FILE_NAMES`) plus topic `.md` files, or a Claude Code auto-memory folder.
|
|
4
|
+
* No `.minnimemory/` compile step, no manifest - the same rankUnits/selectUnits pipeline
|
|
5
|
+
* recall() in recall.ts runs against a compiled workspace's manifest, built straight off files on
|
|
6
|
+
* disk instead. This is the "memory-dir" launch mode (detectPhase() in discover.ts), a new
|
|
7
|
+
* surface, not a change to the existing compiled or setup surfaces.
|
|
8
|
+
*
|
|
9
|
+
* Lexical only, offline, deterministic - same posture as recall()/router.ts: BM25 over stemmed
|
|
10
|
+
* terms, no embeddings, no model call, no network. The index file's hook lines
|
|
11
|
+
* ("- [Title](file.md) - hook text") are parsed into per-file triggers the same way
|
|
12
|
+
* Research/Pipeline/replay_ranker.mjs already does for its own offline replay.
|
|
13
|
+
*
|
|
14
|
+
* Never follows a `@path` import: this module simply does not implement that resolution, so a
|
|
15
|
+
* topic file naming one outside the directory has nothing to follow it with. Every file read
|
|
16
|
+
* passes paths.ts's containment check first, so a symlinked topic file cannot point recall()
|
|
17
|
+
* outside the directory it was asked to serve.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { LIST_FILE_NAMES } from "./discover.js";
|
|
22
|
+
import { containedIn } from "./paths.js";
|
|
23
|
+
import { buildSearchIndex, RECALL_MAX_TOKENS, rankUnits, selectUnits, unitsOf } from "./router.js";
|
|
24
|
+
export class RecallDirError extends Error {
|
|
25
|
+
}
|
|
26
|
+
/** Some hook lines use Unicode code point U+2014 (a long dash) as the separator instead of a
|
|
27
|
+
* plain hyphen. Built from a character code, not a literal character pasted into source (house
|
|
28
|
+
* style: no such characters in anything written here). */
|
|
29
|
+
const LONG_DASH = String.fromCharCode(0x2014);
|
|
30
|
+
/** A hook line in an index file: "- [Title](file.md) - hook text", with either separator above. */
|
|
31
|
+
const RE_HOOK_LINE = new RegExp(`^-\\s*\\[([^\\]]+)\\]\\(([^)]+)\\)\\s*[${LONG_DASH}-]\\s*(.+)$`);
|
|
32
|
+
function stripMdExt(name) {
|
|
33
|
+
return name.replace(/\.md$/i, "");
|
|
34
|
+
}
|
|
35
|
+
/** The index file present in `dir`, by LIST_FILE_NAMES priority (stat only). */
|
|
36
|
+
function findIndexFile(dir) {
|
|
37
|
+
for (const name of LIST_FILE_NAMES) {
|
|
38
|
+
const abs = path.join(dir, name);
|
|
39
|
+
try {
|
|
40
|
+
if (fs.statSync(abs).isFile())
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every top-level `.md` file in `dir`, the index file and `README.md` excluded, name-sorted.
|
|
51
|
+
* Non-recursive on purpose - `archive/` and any other subdirectory is never descended into, the
|
|
52
|
+
* same rule discover.ts's readAutoMemoryFiles applies to an auto-memory folder's topic files.
|
|
53
|
+
*/
|
|
54
|
+
function topicFiles(dir, indexName) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
return entries
|
|
63
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".md"))
|
|
64
|
+
.filter((e) => e.name !== indexName && e.name.toLowerCase() !== "readme.md")
|
|
65
|
+
.map((e) => ({ name: stripMdExt(e.name), file: e.name, abs: path.join(dir, e.name) }))
|
|
66
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
67
|
+
}
|
|
68
|
+
/** Parse an index file's hook lines into `[title, hook]` pairs keyed by topic file name
|
|
69
|
+
* (extension stripped). A line that does not match the hook shape is not an error - prose above
|
|
70
|
+
* or below the list is common - it is simply not a trigger source. */
|
|
71
|
+
function parseTriggers(dir, indexName) {
|
|
72
|
+
const triggers = new Map();
|
|
73
|
+
let content;
|
|
74
|
+
try {
|
|
75
|
+
content = fs.readFileSync(path.join(dir, indexName), "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return triggers;
|
|
79
|
+
}
|
|
80
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
81
|
+
const m = RE_HOOK_LINE.exec(raw.trim());
|
|
82
|
+
if (!m)
|
|
83
|
+
continue;
|
|
84
|
+
const title = m[1] ?? "";
|
|
85
|
+
const target = stripMdExt(path.basename((m[2] ?? "").trim()));
|
|
86
|
+
const hook = (m[3] ?? "").trim();
|
|
87
|
+
triggers.set(target, [title, hook]);
|
|
88
|
+
}
|
|
89
|
+
return triggers;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* One cache entry per directory, the same idea as recall.ts's unitIndexCache: rebuilding means
|
|
93
|
+
* re-reading and re-splitting every topic file, real work a per-turn recall() call should not
|
|
94
|
+
* redo against an unchanged directory within one server session.
|
|
95
|
+
*/
|
|
96
|
+
const dirIndexCache = new Map();
|
|
97
|
+
function fileSignature(abs) {
|
|
98
|
+
try {
|
|
99
|
+
const st = fs.statSync(abs);
|
|
100
|
+
return `${abs}:${st.mtimeMs}:${st.size}`;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return `${abs}:(missing)`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function dirSignature(dir, indexName, files) {
|
|
107
|
+
const parts = files.map((f) => fileSignature(f.abs));
|
|
108
|
+
if (indexName)
|
|
109
|
+
parts.push(fileSignature(path.join(dir, indexName)));
|
|
110
|
+
return parts.join("|");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The section-level unit index for a memory directory, rebuilt only when a topic file's or the
|
|
114
|
+
* index file's mtime/size has changed since the last call for this directory.
|
|
115
|
+
*/
|
|
116
|
+
export function getDirIndex(dir) {
|
|
117
|
+
const abs = path.resolve(dir);
|
|
118
|
+
const indexName = findIndexFile(abs);
|
|
119
|
+
const files = topicFiles(abs, indexName);
|
|
120
|
+
const signature = dirSignature(abs, indexName, files);
|
|
121
|
+
const cached = dirIndexCache.get(abs);
|
|
122
|
+
if (cached && cached.signature === signature)
|
|
123
|
+
return cached.index;
|
|
124
|
+
const triggers = indexName ? parseTriggers(abs, indexName) : new Map();
|
|
125
|
+
const units = [];
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
// Defensive: a symlinked topic file that resolves outside the directory is skipped, not
|
|
128
|
+
// followed. Every real file in the directory already passes this trivially.
|
|
129
|
+
if (!containedIn(abs, f.abs))
|
|
130
|
+
continue;
|
|
131
|
+
let content;
|
|
132
|
+
try {
|
|
133
|
+
content = fs.readFileSync(f.abs, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
units.push(...unitsOf({ name: f.name, file: f.file }, content));
|
|
139
|
+
}
|
|
140
|
+
const index = buildSearchIndex(units, triggers);
|
|
141
|
+
dirIndexCache.set(abs, { signature, index });
|
|
142
|
+
return index;
|
|
143
|
+
}
|
|
144
|
+
/** Test-only: forget every cached directory index, so a test that edits files on disk between
|
|
145
|
+
* calls does not also need to fake mtime/size to bust the cache. */
|
|
146
|
+
export function clearDirIndexCache() {
|
|
147
|
+
dirIndexCache.clear();
|
|
148
|
+
}
|
|
149
|
+
export function listDirFiles(dir) {
|
|
150
|
+
const abs = path.resolve(dir);
|
|
151
|
+
const indexName = findIndexFile(abs);
|
|
152
|
+
const files = topicFiles(abs, indexName);
|
|
153
|
+
const triggers = indexName ? parseTriggers(abs, indexName) : new Map();
|
|
154
|
+
return files.map((f) => ({ name: f.name, file: f.file, triggers: triggers.get(f.name) ?? [] }));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The best section-level units for a query over a memory directory with no manifest, same shape
|
|
158
|
+
* as recall.ts's recall(). `drifted` is always empty here: drift (MM010) is a manifest concept, and
|
|
159
|
+
* a manifest-less directory has none to drift from.
|
|
160
|
+
*/
|
|
161
|
+
export function recallDir(dir, query, options = {}) {
|
|
162
|
+
const abs = path.resolve(dir);
|
|
163
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
164
|
+
throw new RecallDirError(`not a directory: ${abs}`);
|
|
165
|
+
}
|
|
166
|
+
const index = getDirIndex(abs);
|
|
167
|
+
const maxTokens = options.maxTokens ?? RECALL_MAX_TOKENS;
|
|
168
|
+
let ranked = rankUnits(index, query);
|
|
169
|
+
if (options.file !== undefined) {
|
|
170
|
+
const wanted = options.file;
|
|
171
|
+
ranked = ranked.filter((r) => r.unit.onDemandFile === wanted);
|
|
172
|
+
}
|
|
173
|
+
const picked = selectUnits(ranked, maxTokens);
|
|
174
|
+
const matches = picked.map(({ unit, score }) => ({
|
|
175
|
+
onDemandFile: unit.onDemandFile,
|
|
176
|
+
file: unit.file,
|
|
177
|
+
heading: unit.heading,
|
|
178
|
+
path: unit.path,
|
|
179
|
+
kind: unit.kind,
|
|
180
|
+
startLine: unit.startLine,
|
|
181
|
+
endLine: unit.endLine,
|
|
182
|
+
tokens: unit.tokens,
|
|
183
|
+
score,
|
|
184
|
+
content: unit.content,
|
|
185
|
+
}));
|
|
186
|
+
return { query, unit: "section", maxTokens, matches, drifted: [] };
|
|
187
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies an agent-proposed reorganize plan to a memory directory.
|
|
3
|
+
*
|
|
4
|
+
* This is the write half of "AI-scanned reorganization" (project_minnimemory memory,
|
|
5
|
+
* 2026-09-03). It does no scanning and no judgment - scan.ts and the calling agent do that -
|
|
6
|
+
* it only executes a small, explicit set of file operations, gated on the one hard requirement
|
|
7
|
+
* already locked in for this feature: every `.md` file under the target directory (the only
|
|
8
|
+
* shape a plan's ops can touch - see isMemoryShaped below) is backed up verbatim, inside the
|
|
9
|
+
* directory itself, before a single byte of the plan is applied.
|
|
10
|
+
*/
|
|
11
|
+
export declare class ReorganizeError extends Error {
|
|
12
|
+
}
|
|
13
|
+
export type ReorganizeOp = {
|
|
14
|
+
op: "write_file";
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
} | {
|
|
18
|
+
op: "delete_file";
|
|
19
|
+
path: string;
|
|
20
|
+
} | {
|
|
21
|
+
op: "rename_file";
|
|
22
|
+
from: string;
|
|
23
|
+
to: string;
|
|
24
|
+
};
|
|
25
|
+
export interface ReorganizePlan {
|
|
26
|
+
/** absolute or relative path to the memory directory these ops apply to */
|
|
27
|
+
root: string;
|
|
28
|
+
ops: ReorganizeOp[];
|
|
29
|
+
}
|
|
30
|
+
export interface AppliedOp {
|
|
31
|
+
op: ReorganizeOp["op"];
|
|
32
|
+
path: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ReorganizeResult {
|
|
35
|
+
root: string;
|
|
36
|
+
backupDir: string;
|
|
37
|
+
applied: AppliedOp[];
|
|
38
|
+
}
|
|
39
|
+
/** Name of the backup directory reorganize creates inside the memory directory it operates on.
|
|
40
|
+
* Exported so every place that spells out the backup path (a tool description, a doc) says the
|
|
41
|
+
* same thing the code actually does. */
|
|
42
|
+
export declare const BACKUP_DIRNAME = ".minnimemory-backup";
|
|
43
|
+
/**
|
|
44
|
+
* Ceiling on a single write_file's content, checked before any op runs. `reorganize` is a memory
|
|
45
|
+
* tool, not a general file writer; a plan this large is almost certainly a mistake, and refusing
|
|
46
|
+
* it before the backup and the rest of the plan run keeps the failure cheap and obvious.
|
|
47
|
+
*/
|
|
48
|
+
export declare const MAX_WRITE_CHARS = 1000000;
|
|
49
|
+
/**
|
|
50
|
+
* How many timestamped backups to keep. Every apply copies the whole directory again, so
|
|
51
|
+
* without a ceiling a handful of reorganizes quietly multiplies a memory folder on disk, and
|
|
52
|
+
* pointing this at something larger than a memory folder multiplies that instead.
|
|
53
|
+
*/
|
|
54
|
+
export declare const BACKUP_KEEP = 10;
|
|
55
|
+
/**
|
|
56
|
+
* Apply every op in `plan.ops`, in order, after backing up `plan.root` verbatim. All-or-nothing
|
|
57
|
+
* in intent: every op's path is validated against `root` before any op runs, so a bad path fails
|
|
58
|
+
* before touching disk rather than mid-way through a partially-applied plan. A later op is still
|
|
59
|
+
* free to fail on its own (disk full, permissions) - the backup exists either way, so a partial
|
|
60
|
+
* apply is always recoverable by hand from `.minnimemory-backup/<timestamp>/`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function applyPlan(plan: ReorganizePlan): ReorganizeResult;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies an agent-proposed reorganize plan to a memory directory.
|
|
3
|
+
*
|
|
4
|
+
* This is the write half of "AI-scanned reorganization" (project_minnimemory memory,
|
|
5
|
+
* 2026-09-03). It does no scanning and no judgment - scan.ts and the calling agent do that -
|
|
6
|
+
* it only executes a small, explicit set of file operations, gated on the one hard requirement
|
|
7
|
+
* already locked in for this feature: every `.md` file under the target directory (the only
|
|
8
|
+
* shape a plan's ops can touch - see isMemoryShaped below) is backed up verbatim, inside the
|
|
9
|
+
* directory itself, before a single byte of the plan is applied.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { SKIP_DIRS } from "./discover.js";
|
|
14
|
+
import { containedIn, hasSegment, isEscapingRelative } from "./paths.js";
|
|
15
|
+
export class ReorganizeError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/** Name of the backup directory reorganize creates inside the memory directory it operates on.
|
|
18
|
+
* Exported so every place that spells out the backup path (a tool description, a doc) says the
|
|
19
|
+
* same thing the code actually does. */
|
|
20
|
+
export const BACKUP_DIRNAME = ".minnimemory-backup";
|
|
21
|
+
function timestamp() {
|
|
22
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Is `rel` shaped like a memory file this tool understands: a `.md` leaf? A reorganize op only
|
|
26
|
+
* ever needs to touch the markdown files a memory directory is made of - containment alone still
|
|
27
|
+
* lets a write land on an extensionless executable like `.git/hooks/pre-commit` inside the
|
|
28
|
+
* confined root, which is a filesystem location, not a memory file. Confirmed live in the
|
|
29
|
+
* 2026-09-10 audit: with `--allow-write`, `write_file` happily created a working git hook. The
|
|
30
|
+
* `.md` requirement alone closes it - git hooks are matched by exact filename, so no `.md`-named
|
|
31
|
+
* file is ever run as one. Deliberately not also banning dot-segments in general: an existing
|
|
32
|
+
* test (backup directory guard) allows a directory that merely starts with the backup dirname.
|
|
33
|
+
*/
|
|
34
|
+
function isMemoryShaped(rel) {
|
|
35
|
+
return rel.toLowerCase().endsWith(".md");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Confine a plan-supplied relative path to `root`: no absolute paths, no `..` segments, must be
|
|
39
|
+
* memory-shaped (see isMemoryShaped), and the resolved real path (symlinks followed) must land
|
|
40
|
+
* inside `root`'s real path. Mirrors the same containment check recall.ts's loadOnDemandContent
|
|
41
|
+
* already applies to manifest-supplied module paths.
|
|
42
|
+
*/
|
|
43
|
+
function resolveInRoot(root, rel, label) {
|
|
44
|
+
if (isEscapingRelative(rel)) {
|
|
45
|
+
throw new ReorganizeError(`refusing ${label} outside the memory directory: ${rel}`);
|
|
46
|
+
}
|
|
47
|
+
// A path segment, not a string prefix: `sub/.minnimemory-backup/x` used to slip past this
|
|
48
|
+
// while the innocent `.minnimemory-backupX/x` was refused.
|
|
49
|
+
if (hasSegment(rel, BACKUP_DIRNAME)) {
|
|
50
|
+
throw new ReorganizeError(`refusing to touch the backup directory itself: ${rel}`);
|
|
51
|
+
}
|
|
52
|
+
if (!isMemoryShaped(rel)) {
|
|
53
|
+
throw new ReorganizeError(`refusing ${label} outside the memory file shape (must end in .md): ${rel}`);
|
|
54
|
+
}
|
|
55
|
+
const abs = path.join(root, rel);
|
|
56
|
+
if (!containedIn(root, abs)) {
|
|
57
|
+
throw new ReorganizeError(`refusing ${label} that resolves outside the memory directory: ${rel}`);
|
|
58
|
+
}
|
|
59
|
+
return abs;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Ceiling on a single write_file's content, checked before any op runs. `reorganize` is a memory
|
|
63
|
+
* tool, not a general file writer; a plan this large is almost certainly a mistake, and refusing
|
|
64
|
+
* it before the backup and the rest of the plan run keeps the failure cheap and obvious.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_WRITE_CHARS = 1_000_000;
|
|
67
|
+
/**
|
|
68
|
+
* How many timestamped backups to keep. Every apply copies the whole directory again, so
|
|
69
|
+
* without a ceiling a handful of reorganizes quietly multiplies a memory folder on disk, and
|
|
70
|
+
* pointing this at something larger than a memory folder multiplies that instead.
|
|
71
|
+
*/
|
|
72
|
+
export const BACKUP_KEEP = 10;
|
|
73
|
+
/** Drop all but the newest BACKUP_KEEP backups. Names are ISO timestamps, so sort order is age. */
|
|
74
|
+
function pruneBackups(backupRoot) {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = fs
|
|
78
|
+
.readdirSync(backupRoot, { withFileTypes: true })
|
|
79
|
+
.filter((e) => e.isDirectory())
|
|
80
|
+
.map((e) => e.name)
|
|
81
|
+
.sort();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const old of entries.slice(0, Math.max(0, entries.length - BACKUP_KEEP))) {
|
|
87
|
+
fs.rmSync(path.join(backupRoot, old), { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Every `.md` file under `root`, recursively, copied to the same relative path inside the backup
|
|
92
|
+
* directory. This is the only shape `reorganize`'s ops can touch (see isMemoryShaped and
|
|
93
|
+
* resolveInRoot above), so it is the only shape worth backing up.
|
|
94
|
+
*
|
|
95
|
+
* Before this, the whole directory was copied verbatim. The MCP root is wherever `mcp` was
|
|
96
|
+
* launched - the plugin launches at the project root - so one `reorganize` call in a real repo
|
|
97
|
+
* copied `.git`, `node_modules`, `dist`, everything, into `.minnimemory-backup/<ts>/`, up to
|
|
98
|
+
* BACKUP_KEEP times over (2026-09-13 audit, B1). `SKIP_DIRS` (the same list discover.ts uses to
|
|
99
|
+
* avoid descending into build output and dependency trees) and symlinks are skipped; a symlinked
|
|
100
|
+
* `.md` is not copied because following it could read a file outside `root` into the backup.
|
|
101
|
+
*/
|
|
102
|
+
function collectMarkdownFiles(root, dir, out) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry.name === BACKUP_DIRNAME)
|
|
112
|
+
continue;
|
|
113
|
+
const abs = path.join(dir, entry.name);
|
|
114
|
+
let stat;
|
|
115
|
+
try {
|
|
116
|
+
stat = fs.lstatSync(abs);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (stat.isSymbolicLink())
|
|
122
|
+
continue;
|
|
123
|
+
if (stat.isDirectory()) {
|
|
124
|
+
if (SKIP_DIRS.has(entry.name))
|
|
125
|
+
continue;
|
|
126
|
+
collectMarkdownFiles(root, abs, out);
|
|
127
|
+
}
|
|
128
|
+
else if (stat.isFile() && entry.name.toLowerCase().endsWith(".md")) {
|
|
129
|
+
out.push(abs);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Every `.md` file under `root`, `.minnimemory-backup/`, `node_modules`, `.git`, `dist` and
|
|
134
|
+
* `build` skipped, copied verbatim to the same relative path. The only files a plan can touch. */
|
|
135
|
+
function backupVerbatim(root) {
|
|
136
|
+
const backupRoot = path.join(root, BACKUP_DIRNAME);
|
|
137
|
+
const dir = path.join(backupRoot, timestamp());
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
const files = [];
|
|
140
|
+
collectMarkdownFiles(root, root, files);
|
|
141
|
+
for (const abs of files) {
|
|
142
|
+
const rel = path.relative(root, abs);
|
|
143
|
+
const dest = path.join(dir, rel);
|
|
144
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
145
|
+
fs.cpSync(abs, dest);
|
|
146
|
+
}
|
|
147
|
+
const gitignore = path.join(backupRoot, ".gitignore");
|
|
148
|
+
if (!fs.existsSync(gitignore))
|
|
149
|
+
fs.writeFileSync(gitignore, "*\n");
|
|
150
|
+
pruneBackups(backupRoot);
|
|
151
|
+
return dir;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Apply every op in `plan.ops`, in order, after backing up `plan.root` verbatim. All-or-nothing
|
|
155
|
+
* in intent: every op's path is validated against `root` before any op runs, so a bad path fails
|
|
156
|
+
* before touching disk rather than mid-way through a partially-applied plan. A later op is still
|
|
157
|
+
* free to fail on its own (disk full, permissions) - the backup exists either way, so a partial
|
|
158
|
+
* apply is always recoverable by hand from `.minnimemory-backup/<timestamp>/`.
|
|
159
|
+
*/
|
|
160
|
+
export function applyPlan(plan) {
|
|
161
|
+
const root = path.resolve(plan.root);
|
|
162
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
163
|
+
throw new ReorganizeError(`not a directory: ${root}`);
|
|
164
|
+
}
|
|
165
|
+
if (plan.ops.length === 0) {
|
|
166
|
+
throw new ReorganizeError("empty plan - nothing to apply");
|
|
167
|
+
}
|
|
168
|
+
// Validate every path before touching disk.
|
|
169
|
+
for (const op of plan.ops) {
|
|
170
|
+
if (op.op === "rename_file") {
|
|
171
|
+
const from = resolveInRoot(root, op.from, "rename source");
|
|
172
|
+
const to = resolveInRoot(root, op.to, "rename destination");
|
|
173
|
+
if (!fs.existsSync(from) || !fs.statSync(from).isFile()) {
|
|
174
|
+
throw new ReorganizeError(`rename source does not exist: ${op.from}`);
|
|
175
|
+
}
|
|
176
|
+
if (fs.existsSync(to)) {
|
|
177
|
+
throw new ReorganizeError(`rename destination already exists: ${op.to}; delete_file it first`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
const abs = resolveInRoot(root, op.path, `${op.op} path`);
|
|
182
|
+
// Caught here, not left for fs.rmSync to throw EISDIR mid-apply: every other bad path
|
|
183
|
+
// fails before any op runs, and this one should too.
|
|
184
|
+
if (op.op === "delete_file" && fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
185
|
+
throw new ReorganizeError(`refusing delete_file on a directory: ${op.path}`);
|
|
186
|
+
}
|
|
187
|
+
if (op.op === "write_file" && op.content.length > MAX_WRITE_CHARS) {
|
|
188
|
+
throw new ReorganizeError(`write_file content is ${op.content.length} characters, over the ${MAX_WRITE_CHARS} limit: ${op.path}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const backupDir = backupVerbatim(root);
|
|
193
|
+
const applied = [];
|
|
194
|
+
for (const op of plan.ops) {
|
|
195
|
+
if (op.op === "write_file") {
|
|
196
|
+
const abs = resolveInRoot(root, op.path, "write_file path");
|
|
197
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
198
|
+
fs.writeFileSync(abs, op.content);
|
|
199
|
+
applied.push({ op: op.op, path: op.path });
|
|
200
|
+
}
|
|
201
|
+
else if (op.op === "delete_file") {
|
|
202
|
+
const abs = resolveInRoot(root, op.path, "delete_file path");
|
|
203
|
+
if (fs.existsSync(abs))
|
|
204
|
+
fs.rmSync(abs);
|
|
205
|
+
applied.push({ op: op.op, path: op.path });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
const from = resolveInRoot(root, op.from, "rename source");
|
|
209
|
+
const to = resolveInRoot(root, op.to, "rename destination");
|
|
210
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
211
|
+
fs.renameSync(from, to);
|
|
212
|
+
applied.push({ op: op.op, path: `${op.from} -> ${op.to}` });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { root, backupDir, applied };
|
|
216
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for doctor results. Human output first, JSON for tooling.
|
|
3
|
+
* No dependencies: colour is a handful of ANSI codes, disabled unless asked for.
|
|
4
|
+
*/
|
|
5
|
+
import { type DoctorConfig, type DoctorResult, type Finding, type Severity } from "./types.js";
|
|
6
|
+
export interface RenderOptions {
|
|
7
|
+
color: boolean;
|
|
8
|
+
/** terse output suitable for a build log */
|
|
9
|
+
ci: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Findings shown per rule before the human view collapses the rest into one marker line. */
|
|
12
|
+
export declare const DISPLAY_LIMIT = 10;
|
|
13
|
+
export declare function renderHuman(result: DoctorResult, config: DoctorConfig, opts: RenderOptions): string;
|
|
14
|
+
export declare function renderJson(result: DoctorResult, config: DoctorConfig): string;
|
|
15
|
+
/** 0 clean, 1 findings at or above the threshold. */
|
|
16
|
+
export declare function exitCodeFor(findings: Finding[], failOn: Severity): 0 | 1;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for doctor results. Human output first, JSON for tooling.
|
|
3
|
+
* No dependencies: colour is a handful of ANSI codes, disabled unless asked for.
|
|
4
|
+
*/
|
|
5
|
+
import { formatTokens, TOKENIZER_ID } from "./tokenizer.js";
|
|
6
|
+
import { SEVERITY_ORDER } from "./types.js";
|
|
7
|
+
const MSG_WIDTH = 58;
|
|
8
|
+
const GUTTER = " ";
|
|
9
|
+
const ANSI = {
|
|
10
|
+
reset: "\u001b[0m",
|
|
11
|
+
dim: "\u001b[2m",
|
|
12
|
+
red: "\u001b[31m",
|
|
13
|
+
yellow: "\u001b[33m",
|
|
14
|
+
blue: "\u001b[34m",
|
|
15
|
+
bold: "\u001b[1m",
|
|
16
|
+
};
|
|
17
|
+
function paint(text, code, color) {
|
|
18
|
+
return color ? `${code}${text}${ANSI.reset}` : text;
|
|
19
|
+
}
|
|
20
|
+
function severityColor(s) {
|
|
21
|
+
if (s === "high")
|
|
22
|
+
return ANSI.red;
|
|
23
|
+
if (s === "med")
|
|
24
|
+
return ANSI.yellow;
|
|
25
|
+
return ANSI.blue;
|
|
26
|
+
}
|
|
27
|
+
/** Greedy word wrap. Long unbreakable tokens are allowed to overflow rather than be cut. */
|
|
28
|
+
function wrap(text, width) {
|
|
29
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
30
|
+
const lines = [];
|
|
31
|
+
let line = "";
|
|
32
|
+
for (const word of words) {
|
|
33
|
+
if (line === "") {
|
|
34
|
+
line = word;
|
|
35
|
+
}
|
|
36
|
+
else if (line.length + 1 + word.length <= width) {
|
|
37
|
+
line += ` ${word}`;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
lines.push(line);
|
|
41
|
+
line = word;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (line)
|
|
45
|
+
lines.push(line);
|
|
46
|
+
return lines.length ? lines : [""];
|
|
47
|
+
}
|
|
48
|
+
function location(f) {
|
|
49
|
+
if (f.startLine === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const range = f.endLine !== undefined && f.endLine !== f.startLine ? `${f.startLine}-${f.endLine}` : `${f.startLine}`;
|
|
52
|
+
return `${f.file}:${range}`;
|
|
53
|
+
}
|
|
54
|
+
function renderFinding(f, color) {
|
|
55
|
+
const out = [];
|
|
56
|
+
const body = wrap(f.message, MSG_WIDTH);
|
|
57
|
+
const head = body[0] ?? "";
|
|
58
|
+
out.push(` ${paint(f.rule.padEnd(7), ANSI.bold, color)}${head.padEnd(MSG_WIDTH)} ${paint(f.severity, severityColor(f.severity), color)}`);
|
|
59
|
+
for (const extra of body.slice(1)) {
|
|
60
|
+
out.push(`${GUTTER}${extra}`);
|
|
61
|
+
}
|
|
62
|
+
const loc = location(f);
|
|
63
|
+
if (loc)
|
|
64
|
+
out.push(`${GUTTER}${paint(`at ${loc}`, ANSI.dim, color)}`);
|
|
65
|
+
if (f.hint) {
|
|
66
|
+
for (const hintLine of wrap(`-> ${f.hint}`, MSG_WIDTH + 8)) {
|
|
67
|
+
out.push(`${GUTTER}${paint(hintLine, ANSI.dim, color)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function counts(findings) {
|
|
73
|
+
const c = { high: 0, med: 0, low: 0 };
|
|
74
|
+
for (const f of findings)
|
|
75
|
+
c[f.severity]++;
|
|
76
|
+
return c;
|
|
77
|
+
}
|
|
78
|
+
/** Findings shown per rule before the human view collapses the rest into one marker line. */
|
|
79
|
+
export const DISPLAY_LIMIT = 10;
|
|
80
|
+
/**
|
|
81
|
+
* Truncate for the terminal only, one group per rule, in the order the findings arrive.
|
|
82
|
+
* Everything else in the pipeline (JSON, exit code, the summary count) sees the full list:
|
|
83
|
+
* the cap used to live in the rules themselves, which hid findings from every output mode
|
|
84
|
+
* while the marker line advised re-running with --json to see them.
|
|
85
|
+
*/
|
|
86
|
+
function forDisplay(findings, limit = DISPLAY_LIMIT) {
|
|
87
|
+
const perRule = new Map();
|
|
88
|
+
const out = [];
|
|
89
|
+
const hidden = new Map();
|
|
90
|
+
for (const f of findings) {
|
|
91
|
+
const n = perRule.get(f.rule) ?? 0;
|
|
92
|
+
perRule.set(f.rule, n + 1);
|
|
93
|
+
if (n < limit) {
|
|
94
|
+
out.push(f);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const rest = hidden.get(f.rule) ?? [];
|
|
98
|
+
rest.push(f);
|
|
99
|
+
hidden.set(f.rule, rest);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const [rule, rest] of hidden) {
|
|
103
|
+
const last = out.filter((f) => f.rule === rule).pop();
|
|
104
|
+
if (!last)
|
|
105
|
+
continue;
|
|
106
|
+
out.splice(out.lastIndexOf(last) + 1, 0, {
|
|
107
|
+
rule,
|
|
108
|
+
severity: last.severity,
|
|
109
|
+
file: last.file,
|
|
110
|
+
message: `and ${rest.length} more ${rule} finding${rest.length === 1 ? "" : "s"} not shown`,
|
|
111
|
+
hint: "re-run with --json for the full list",
|
|
112
|
+
overflow: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
export function renderHuman(result, config, opts) {
|
|
118
|
+
const { findings } = result;
|
|
119
|
+
const shown = forDisplay(findings);
|
|
120
|
+
const lines = [];
|
|
121
|
+
const color = opts.color;
|
|
122
|
+
lines.push("");
|
|
123
|
+
if (findings.length === 0) {
|
|
124
|
+
lines.push(` ${paint("no findings", ANSI.blue, color)}`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
for (const f of shown) {
|
|
128
|
+
lines.push(...renderFinding(f, color));
|
|
129
|
+
if (!opts.ci)
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!opts.ci && findings.length === 0)
|
|
134
|
+
lines.push("");
|
|
135
|
+
const c = counts(findings);
|
|
136
|
+
const breakdown = [
|
|
137
|
+
c.high ? `${c.high} high` : "",
|
|
138
|
+
c.med ? `${c.med} med` : "",
|
|
139
|
+
c.low ? `${c.low} low` : "",
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(", ");
|
|
143
|
+
// The count is of everything found, not of what fit on screen; say so when they differ, so a
|
|
144
|
+
// truncated run never reads as though the hidden findings do not exist.
|
|
145
|
+
const displayed = shown.filter((f) => !f.overflow).length;
|
|
146
|
+
const truncated = displayed < findings.length ? `, showing ${displayed}` : "";
|
|
147
|
+
const summary = findings.length === 0
|
|
148
|
+
? " 0 findings"
|
|
149
|
+
: ` ${findings.length} finding${findings.length === 1 ? "" : "s"}${breakdown ? ` (${breakdown})` : ""}${truncated}`;
|
|
150
|
+
lines.push(summary);
|
|
151
|
+
const over = result.alwaysLoadedTokens > config.budget;
|
|
152
|
+
const budgetNote = `budget ${formatTokens(config.budget)}${over ? ", over" : ", within"}`;
|
|
153
|
+
lines.push(` always-loaded prefix: ${formatTokens(result.alwaysLoadedTokens)} tokens across ` +
|
|
154
|
+
`${result.alwaysLoadedFiles} file${result.alwaysLoadedFiles === 1 ? "" : "s"} (${budgetNote})`);
|
|
155
|
+
lines.push(paint(` tokenizer: ${result.tokenizer}, an offline estimate`, ANSI.dim, color));
|
|
156
|
+
// An unfollowed import means the always-loaded total above is short by whatever it points at.
|
|
157
|
+
// Say so: a number that is quietly incomplete is worse than one that admits it.
|
|
158
|
+
const skipped = result.workspace.skippedImports ?? [];
|
|
159
|
+
if (skipped.length > 0) {
|
|
160
|
+
lines.push("");
|
|
161
|
+
lines.push(` ${skipped.length} import${skipped.length === 1 ? "" : "s"} outside this directory not followed, so the total above excludes them:`);
|
|
162
|
+
for (const raw of skipped.slice(0, 5))
|
|
163
|
+
lines.push(` ${raw}`);
|
|
164
|
+
if (skipped.length > 5)
|
|
165
|
+
lines.push(` and ${skipped.length - 5} more`);
|
|
166
|
+
lines.push(" pass --follow-external-imports to include them, if this memory file is yours to trust");
|
|
167
|
+
}
|
|
168
|
+
// The re-apply case: a workspace optimised earlier. Say what state it is in and what the
|
|
169
|
+
// one command to bring it current is, so doctor is the whole front door for coming back.
|
|
170
|
+
if (result.workspace.shape === "compiled") {
|
|
171
|
+
const onDemandFiles = result.workspace.files.filter((f) => f.kind === "onDemand").length;
|
|
172
|
+
const drift = findings.filter((f) => f.rule === "MM010").length;
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(` compiled workspace: ${onDemandFiles} OnDemandMemory file${onDemandFiles === 1 ? "" : "s"} in .minnimemory/`);
|
|
175
|
+
if (!result.workspace.manifest) {
|
|
176
|
+
lines.push(" manifest.json unreadable, so drift cannot be checked; re-apply with: minnimemory init --update");
|
|
177
|
+
}
|
|
178
|
+
else if (drift === 0) {
|
|
179
|
+
lines.push(" no drift since init: the stub and every compiled file match the manifest. Nothing to re-apply.");
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
lines.push(` drift: ${drift} file${drift === 1 ? "" : "s"} changed since init (MM010). Re-apply with: minnimemory init --update`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
lines.push("");
|
|
186
|
+
return lines.join("\n");
|
|
187
|
+
}
|
|
188
|
+
export function renderJson(result, config) {
|
|
189
|
+
return JSON.stringify({
|
|
190
|
+
tokenizer: TOKENIZER_ID,
|
|
191
|
+
root: result.workspace.root,
|
|
192
|
+
shape: result.workspace.shape,
|
|
193
|
+
budget: config.budget,
|
|
194
|
+
alwaysLoadedTokens: result.alwaysLoadedTokens,
|
|
195
|
+
alwaysLoadedFiles: result.alwaysLoadedFiles,
|
|
196
|
+
counts: counts(result.findings),
|
|
197
|
+
findings: result.findings,
|
|
198
|
+
}, null, 2);
|
|
199
|
+
}
|
|
200
|
+
/** 0 clean, 1 findings at or above the threshold. */
|
|
201
|
+
export function exitCodeFor(findings, failOn) {
|
|
202
|
+
const threshold = SEVERITY_ORDER[failOn];
|
|
203
|
+
return findings.some((f) => SEVERITY_ORDER[f.severity] >= threshold) ? 1 : 0;
|
|
204
|
+
}
|