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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* minnimemory CLI.
|
|
4
|
+
*
|
|
5
|
+
* Exit codes: 0 clean, 1 findings at or above the --fail-on threshold, 2 execution error.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { BenchError, bounds, model, readWorkload } from "./bench.js";
|
|
13
|
+
import { renderBounds, renderModel, renderNoWorkload } from "./benchReport.js";
|
|
14
|
+
import { detectPhase, DiscoveryError } from "./discover.js";
|
|
15
|
+
import { doctor } from "./doctor.js";
|
|
16
|
+
import { parseHookInput, renderHook } from "./hook.js";
|
|
17
|
+
import { init, InitError, renderPlan } from "./init.js";
|
|
18
|
+
import { isProfileName } from "./instructions.js";
|
|
19
|
+
import { loadManifest, McpTargetError, recall } from "./recall.js";
|
|
20
|
+
import { createDisabledMcpServer, createMcpServer, DISABLED_MESSAGE } from "./mcpServer.js";
|
|
21
|
+
import { recallDir, RecallDirError } from "./recallDir.js";
|
|
22
|
+
import { exitCodeFor, renderHuman, renderJson } from "./report.js";
|
|
23
|
+
import { RULES } from "./rules.js";
|
|
24
|
+
import { VERSION } from "./version.js";
|
|
25
|
+
/**
|
|
26
|
+
* Single switch for whether `mcp` starts the real server or the disabled placeholder. Exported
|
|
27
|
+
* so tests/cli.test.ts can pin it without needing a live stdio transport in the test process.
|
|
28
|
+
*
|
|
29
|
+
* Re-enabled 2026-09-10 for internal testing, then shipped publicly in the 1.0.0-beta.1 npm
|
|
30
|
+
* release (2026-09-15). Flip back to true to re-stash the server in a future release.
|
|
31
|
+
*/
|
|
32
|
+
export const MCP_STASHED = false;
|
|
33
|
+
const USAGE = `
|
|
34
|
+
minnimemory ${VERSION} the agent memory compiler
|
|
35
|
+
|
|
36
|
+
usage
|
|
37
|
+
minnimemory doctor [path] audit a memory setup, change nothing
|
|
38
|
+
minnimemory init [path] compile into .minnimemory/, dry run by default
|
|
39
|
+
minnimemory bench [path] what the compiled shape costs over a session
|
|
40
|
+
minnimemory mcp [path] start the MCP server. Serves check/recall by default;
|
|
41
|
+
--allow-write and --profile add more tools
|
|
42
|
+
minnimemory recall <path> <query...>
|
|
43
|
+
recall against a compiled workspace or a manifest-less memory
|
|
44
|
+
directory (Layout B), whichever <path> is, change nothing
|
|
45
|
+
minnimemory hook Claude Code UserPromptSubmit hook: reads hook JSON from stdin,
|
|
46
|
+
prints a fenced memory block to stdout, or nothing on no hit.
|
|
47
|
+
Experimental until the gate in
|
|
48
|
+
Research/FlagShipInterfaces/README.md part (c) runs
|
|
49
|
+
|
|
50
|
+
recall options
|
|
51
|
+
--max-tokens <n> cap on returned tokens (default 1500); the top hit is always returned
|
|
52
|
+
--headings print only "file > heading path (n tokens)" lines, not the content
|
|
53
|
+
|
|
54
|
+
hook options
|
|
55
|
+
--max-tokens <n> cap on returned tokens (default 600)
|
|
56
|
+
--headings print only "file > heading path (n tokens)" lines, not the content
|
|
57
|
+
--dir <path> an explicit memory directory (compiled or manifest-less), overriding
|
|
58
|
+
the hook JSON's own cwd resolution (cwd/.minnimemory, else cwd's
|
|
59
|
+
Claude Code auto-memory folder)
|
|
60
|
+
|
|
61
|
+
init options
|
|
62
|
+
--write apply the compile. Without it, init only prints the plan
|
|
63
|
+
--force overwrite an existing .minnimemory/
|
|
64
|
+
--update recompile a compiled workspace from its stub and OnDemandMemory files
|
|
65
|
+
on disk
|
|
66
|
+
--budget <n> always-loaded token budget AlwaysOnMemory must fit (default 2000)
|
|
67
|
+
--profile <name> how much token-discipline guidance to embed (default auto)
|
|
68
|
+
auto none under the budget, routing at or over it
|
|
69
|
+
none structure only, embed nothing
|
|
70
|
+
routing the rules that make routing work, about 200 tokens
|
|
71
|
+
full the complete set, about 690 tokens
|
|
72
|
+
--allow-secrets compile even when the source holds a credential-shaped string
|
|
73
|
+
--episodic-json write episodic OnDemandMemory files (changelogs, logs) as JSON, not
|
|
74
|
+
Markdown (O3)
|
|
75
|
+
|
|
76
|
+
mcp options
|
|
77
|
+
Every tool definition is charged to the client's prefix on every turn, called or not, so the
|
|
78
|
+
server only advertises the profile you asked for, and the basic profile only the half its
|
|
79
|
+
workspace can use. Real costs (Research/TokenTest/tool_cost.mjs, approx-v2): basic on an uncompiled
|
|
80
|
+
root 368 tokens read-only, 1,025 with --allow-write; basic on a compiled root 329 read-only,
|
|
81
|
+
605 with --allow-write; full read-only 1,666, full --allow-write 2,668.
|
|
82
|
+
--profile <name> basic (default) or full
|
|
83
|
+
basic the four verbs a developer actually uses, two per launch,
|
|
84
|
+
chosen from the workspace on disk: before a compile
|
|
85
|
+
(no .minnimemory/) check, plus optimize with --allow-write;
|
|
86
|
+
once compiled recall, plus sync with --allow-write. The
|
|
87
|
+
surface is fixed per process: after compiling, relaunch
|
|
88
|
+
full recall, modules, outline, scan, doctor, plan, always;
|
|
89
|
+
apply, update, reorganize with --allow-write. Today's nine
|
|
90
|
+
tools, unchanged - for maintainers and for an agent driving
|
|
91
|
+
a folder reorganization directly
|
|
92
|
+
--allow-write adds the profile's write tools (basic: optimize, sync; full: apply,
|
|
93
|
+
update, reorganize). Without it those tools are not registered at
|
|
94
|
+
all: they are absent from tools/list, not present-and-refusing
|
|
95
|
+
--follow-external-imports
|
|
96
|
+
let doctor/plan/scan (full) and check/optimize (basic) follow @path
|
|
97
|
+
imports outside the server root. A launch flag, never a tool
|
|
98
|
+
argument: a connected client must not be able to widen the server's
|
|
99
|
+
reach by asking
|
|
100
|
+
--include-auto-memory let doctor/plan/apply/update/scan (full) and check/optimize (basic)
|
|
101
|
+
see the operator's OS-level Claude Code auto-memory folder, and
|
|
102
|
+
allow "auto-memory" as a target where that tool resolves one. Off by
|
|
103
|
+
default and separate from --follow-external-imports: reading the
|
|
104
|
+
operator's own memory and following an imported file's @path are
|
|
105
|
+
different reaches. A launch flag, never a tool argument
|
|
106
|
+
|
|
107
|
+
bench options
|
|
108
|
+
--turns <n> session length for the exact bounds (default 50)
|
|
109
|
+
--workload <file> one task description per line. Without it, only the
|
|
110
|
+
assumption-free bounds are reported
|
|
111
|
+
--whole-files model an agent that reads the whole file the OnDemandMemory list named,
|
|
112
|
+
instead of the sections recall() would return
|
|
113
|
+
--json machine-readable output
|
|
114
|
+
|
|
115
|
+
doctor options
|
|
116
|
+
--json machine-readable output
|
|
117
|
+
--ci terse output, no colour
|
|
118
|
+
--follow-external-imports
|
|
119
|
+
follow @path imports that resolve outside the target directory.
|
|
120
|
+
Off by default: an @ line is content the scanned repo controls,
|
|
121
|
+
and following one reads and reports files elsewhere on this machine
|
|
122
|
+
--fail-on <sev> exit 1 at this severity or above: low, med, high (default high)
|
|
123
|
+
--budget <n> always-loaded token budget for MM001 (default 2000)
|
|
124
|
+
--only <ids> run only these rules, comma separated
|
|
125
|
+
--ignore <ids> skip these rules, comma separated
|
|
126
|
+
--rules list the rule set and exit
|
|
127
|
+
-h, --help this message
|
|
128
|
+
-v, --version print the version
|
|
129
|
+
|
|
130
|
+
path defaults to the current directory. It may be a repo, a memory directory, or a single file.
|
|
131
|
+
`;
|
|
132
|
+
function listRules() {
|
|
133
|
+
const lines = ["", " rule set", ""];
|
|
134
|
+
for (const r of RULES) {
|
|
135
|
+
lines.push(` ${r.id.padEnd(8)}${r.severity.padEnd(6)}${r.title}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
|
140
|
+
function parseSeverity(value, fallback) {
|
|
141
|
+
if (value === undefined)
|
|
142
|
+
return fallback;
|
|
143
|
+
if (value === "low" || value === "med" || value === "high")
|
|
144
|
+
return value;
|
|
145
|
+
throw new Error(`--fail-on must be one of low, med, high (got "${value}")`);
|
|
146
|
+
}
|
|
147
|
+
function parseIds(value) {
|
|
148
|
+
if (!value)
|
|
149
|
+
return [];
|
|
150
|
+
return value
|
|
151
|
+
.split(",")
|
|
152
|
+
.map((s) => s.trim())
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
/** `minnimemory recall`'s own rendering: full units by default, or with --headings only the
|
|
156
|
+
* "file > heading path (n tokens)" line an agent needs to decide whether to widen the query. */
|
|
157
|
+
function renderRecall(result, headingsOnly) {
|
|
158
|
+
if (result.matches.length === 0) {
|
|
159
|
+
return `\n no match for "${result.query}"\n\n`;
|
|
160
|
+
}
|
|
161
|
+
if (headingsOnly) {
|
|
162
|
+
return `${result.matches.map((m) => `${m.onDemandFile} > ${m.path.join(" > ")} (${m.tokens} tokens)`).join("\n")}\n`;
|
|
163
|
+
}
|
|
164
|
+
return `${result.matches
|
|
165
|
+
.map((m) => `## ${m.onDemandFile} > ${m.path.join(" > ")} (lines ${m.startLine}-${m.endLine}, ${m.tokens} tokens, score ${m.score})\n\n${m.content}`)
|
|
166
|
+
.join("\n\n---\n\n")}\n`;
|
|
167
|
+
}
|
|
168
|
+
function parseBudget(value, fallback) {
|
|
169
|
+
if (value === undefined)
|
|
170
|
+
return fallback;
|
|
171
|
+
const n = Number(value);
|
|
172
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
173
|
+
throw new Error(`--budget must be a positive number (got "${value}")`);
|
|
174
|
+
return Math.floor(n);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Starts the MCP server. Called from main() for the `mcp` command unless MCP_STASHED is true
|
|
178
|
+
* (see that constant); also exercised directly by tests/mcp.test.ts and tests/mcpServer.test.ts.
|
|
179
|
+
*/
|
|
180
|
+
function runMcpCommand(target, values) {
|
|
181
|
+
let root;
|
|
182
|
+
try {
|
|
183
|
+
root = path.resolve(target);
|
|
184
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
185
|
+
throw new McpTargetError(`not a directory: ${root}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (err instanceof McpTargetError) {
|
|
190
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
process.stderr.write(`\n unexpected error: ${err.stack ?? String(err)}\n\n`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
const profileName = values.profile ?? "basic";
|
|
197
|
+
if (profileName !== "basic" && profileName !== "full") {
|
|
198
|
+
process.stderr.write(`\n error: --profile must be one of basic, full (got "${profileName}")\n\n`);
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
|
201
|
+
// check/recall (basic) and recall/modules/outline (full) need a compiled workspace and
|
|
202
|
+
// report that themselves per call, the same way they already handle a missing manifest
|
|
203
|
+
// today; doctor/plan/scan (full) and check/optimize (basic) work on any target, compiled or
|
|
204
|
+
// not, and the write tools (behind --allow-write) are what makes one - which is the point of
|
|
205
|
+
// this command not failing fast here.
|
|
206
|
+
const server = createMcpServer(root, {
|
|
207
|
+
profile: profileName,
|
|
208
|
+
allowWrite: values["allow-write"] === true,
|
|
209
|
+
followExternalImports: values["follow-external-imports"] === true,
|
|
210
|
+
includeAutoMemory: values["include-auto-memory"] === true,
|
|
211
|
+
});
|
|
212
|
+
const transport = new StdioServerTransport();
|
|
213
|
+
// Fire-and-forget: the transport takes over stdin, which keeps the process alive on its own.
|
|
214
|
+
// main() stays synchronous everywhere else, so this is the one command that does not "finish"
|
|
215
|
+
// in the normal sense - it runs until the client disconnects (stdin closes).
|
|
216
|
+
server.connect(transport).catch((err) => {
|
|
217
|
+
process.stderr.write(`\n mcp server failed: ${err.message ?? String(err)}\n`);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
});
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
export function main(argv) {
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = parseArgs({
|
|
226
|
+
args: argv,
|
|
227
|
+
allowPositionals: true,
|
|
228
|
+
strict: true,
|
|
229
|
+
options: {
|
|
230
|
+
json: { type: "boolean", default: false },
|
|
231
|
+
write: { type: "boolean", default: false },
|
|
232
|
+
force: { type: "boolean", default: false },
|
|
233
|
+
update: { type: "boolean", default: false },
|
|
234
|
+
"allow-secrets": { type: "boolean", default: false },
|
|
235
|
+
"episodic-json": { type: "boolean", default: false },
|
|
236
|
+
"allow-write": { type: "boolean", default: false },
|
|
237
|
+
"follow-external-imports": { type: "boolean", default: false },
|
|
238
|
+
"include-auto-memory": { type: "boolean", default: false },
|
|
239
|
+
turns: { type: "string" },
|
|
240
|
+
workload: { type: "string" },
|
|
241
|
+
"whole-files": { type: "boolean", default: false },
|
|
242
|
+
"max-tokens": { type: "string" },
|
|
243
|
+
headings: { type: "boolean", default: false },
|
|
244
|
+
dir: { type: "string" },
|
|
245
|
+
profile: { type: "string" },
|
|
246
|
+
ci: { type: "boolean", default: false },
|
|
247
|
+
rules: { type: "boolean", default: false },
|
|
248
|
+
"fail-on": { type: "string" },
|
|
249
|
+
budget: { type: "string" },
|
|
250
|
+
only: { type: "string" },
|
|
251
|
+
ignore: { type: "string" },
|
|
252
|
+
help: { type: "boolean", short: "h", default: false },
|
|
253
|
+
version: { type: "boolean", short: "v", default: false },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
process.stderr.write(`\n error: ${err.message}\n${USAGE}`);
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
const { values, positionals } = parsed;
|
|
262
|
+
if (values.help) {
|
|
263
|
+
process.stdout.write(USAGE);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
if (values.version) {
|
|
267
|
+
process.stdout.write(`${VERSION}\n`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
if (values.rules) {
|
|
271
|
+
process.stdout.write(listRules());
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
const command = positionals[0] ?? "doctor";
|
|
275
|
+
const target = positionals[1] ?? ".";
|
|
276
|
+
if (command === "init") {
|
|
277
|
+
const profileName = values.profile ?? "auto";
|
|
278
|
+
if (profileName !== "auto" && !isProfileName(profileName)) {
|
|
279
|
+
process.stderr.write(`\n error: --profile must be one of auto, none, routing, full (got "${profileName}")\n\n`);
|
|
280
|
+
return 2;
|
|
281
|
+
}
|
|
282
|
+
let budget;
|
|
283
|
+
try {
|
|
284
|
+
budget = parseBudget(values.budget, 2000);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
const options = {
|
|
291
|
+
write: values.write === true,
|
|
292
|
+
force: values.force === true,
|
|
293
|
+
update: values.update === true,
|
|
294
|
+
allowSecrets: values["allow-secrets"] === true,
|
|
295
|
+
episodicJson: values["episodic-json"] === true,
|
|
296
|
+
profile: profileName,
|
|
297
|
+
budget,
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
const plan = init(target, options);
|
|
301
|
+
process.stdout.write(renderPlan(plan, options));
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
if (err instanceof InitError || err instanceof DiscoveryError) {
|
|
306
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
process.stderr.write(`\n unexpected error: ${err.stack ?? String(err)}\n\n`);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (command === "mcp") {
|
|
314
|
+
if (!MCP_STASHED)
|
|
315
|
+
return runMcpCommand(target, values);
|
|
316
|
+
// Re-stash path, dead while MCP_STASHED is false (see the constant's own comment above) but
|
|
317
|
+
// kept intact and reachable: flip the flag back to instantly return to a zero-tool
|
|
318
|
+
// placeholder instead of deleting this and having to rebuild it later. A connecting client
|
|
319
|
+
// gets a clean handshake either way - zero tools, DISABLED_MESSAGE in `instructions` - rather
|
|
320
|
+
// than a bare process-startup failure. tests/cli.test.ts pins MCP_STASHED, so either flip is
|
|
321
|
+
// a deliberate, visible change, not a silent one.
|
|
322
|
+
process.stderr.write(`\n mcp: ${DISABLED_MESSAGE}\n\n`);
|
|
323
|
+
const server = createDisabledMcpServer();
|
|
324
|
+
const transport = new StdioServerTransport();
|
|
325
|
+
server.connect(transport).catch((err) => {
|
|
326
|
+
process.stderr.write(`\n mcp server failed: ${err.message ?? String(err)}\n`);
|
|
327
|
+
process.exitCode = 1;
|
|
328
|
+
});
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (command === "recall") {
|
|
332
|
+
const query = positionals.slice(2).join(" ").trim();
|
|
333
|
+
if (!query) {
|
|
334
|
+
process.stderr.write(`\n error: recall needs a query: minnimemory recall <path> <query...>\n\n`);
|
|
335
|
+
return 2;
|
|
336
|
+
}
|
|
337
|
+
let maxTokens;
|
|
338
|
+
if (values["max-tokens"] !== undefined) {
|
|
339
|
+
const n = Number(values["max-tokens"]);
|
|
340
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
341
|
+
process.stderr.write(`\n error: --max-tokens must be a positive number (got "${values["max-tokens"]}")\n\n`);
|
|
342
|
+
return 2;
|
|
343
|
+
}
|
|
344
|
+
maxTokens = Math.floor(n);
|
|
345
|
+
}
|
|
346
|
+
const root = path.resolve(target);
|
|
347
|
+
try {
|
|
348
|
+
const phase = detectPhase(root);
|
|
349
|
+
let result;
|
|
350
|
+
if (phase === "compiled") {
|
|
351
|
+
result = recall(root, loadManifest(root), query, { maxTokens });
|
|
352
|
+
}
|
|
353
|
+
else if (phase === "memory-dir") {
|
|
354
|
+
result = recallDir(root, query, { maxTokens });
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
process.stderr.write(`\n error: ${root} is neither a compiled workspace (.minnimemory/manifest.json) nor a manifest-less ` +
|
|
358
|
+
`memory directory (an index file plus topic files) - nothing for recall to query\n\n`);
|
|
359
|
+
return 2;
|
|
360
|
+
}
|
|
361
|
+
process.stdout.write(renderRecall(result, values.headings === true));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (err instanceof McpTargetError || err instanceof RecallDirError) {
|
|
366
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
367
|
+
return 2;
|
|
368
|
+
}
|
|
369
|
+
process.stderr.write(`\n unexpected error: ${err.stack ?? String(err)}\n\n`);
|
|
370
|
+
return 2;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (command === "hook") {
|
|
374
|
+
// Exit 0 always: this runs as a Claude Code UserPromptSubmit hook, and a hook's stdout
|
|
375
|
+
// becomes context fed straight to the model, so a failure here must never surface as
|
|
376
|
+
// anything but silence on stdout. Everything that can go wrong (unreadable stdin, a bad
|
|
377
|
+
// target, no hit) is swallowed by renderHook() itself; this block only guards the read.
|
|
378
|
+
let raw = "";
|
|
379
|
+
try {
|
|
380
|
+
raw = fs.readFileSync(0, "utf8");
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
raw = "";
|
|
384
|
+
}
|
|
385
|
+
let maxTokens;
|
|
386
|
+
if (values["max-tokens"] !== undefined) {
|
|
387
|
+
const n = Number(values["max-tokens"]);
|
|
388
|
+
if (Number.isFinite(n) && n > 0)
|
|
389
|
+
maxTokens = Math.floor(n);
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const input = parseHookInput(raw);
|
|
393
|
+
const text = renderHook(input, { headings: values.headings === true, maxTokens, dir: values.dir });
|
|
394
|
+
if (text)
|
|
395
|
+
process.stdout.write(`${text}\n`);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
process.stderr.write(`\n hook: ${err.message ?? String(err)}\n`);
|
|
399
|
+
}
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
if (command === "bench") {
|
|
403
|
+
try {
|
|
404
|
+
const root = path.resolve(target);
|
|
405
|
+
const manifest = loadManifest(root);
|
|
406
|
+
const turns = values.turns === undefined ? 50 : Number(values.turns);
|
|
407
|
+
if (!Number.isFinite(turns)) {
|
|
408
|
+
throw new BenchError(`--turns must be a positive whole number (got "${values.turns}")`);
|
|
409
|
+
}
|
|
410
|
+
const b = bounds(manifest, turns);
|
|
411
|
+
if (values.workload === undefined) {
|
|
412
|
+
if (values.json === true) {
|
|
413
|
+
process.stdout.write(`${JSON.stringify({ bounds: b, tokenizer: manifest.tokenizer }, null, 2)}\n`);
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
process.stdout.write(renderBounds(b, manifest.tokenizer));
|
|
417
|
+
process.stdout.write(renderNoWorkload());
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
const tasks = readWorkload(values.workload);
|
|
421
|
+
// Section-level routing, the same path recall() takes. --whole-files models an agent
|
|
422
|
+
// that reads the entire file the OnDemandMemory list pointed at instead.
|
|
423
|
+
const m = model(manifest, tasks, values["whole-files"] === true ? {} : { root });
|
|
424
|
+
if (values.json === true) {
|
|
425
|
+
process.stdout.write(`${JSON.stringify({ bounds: b, model: m, tokenizer: manifest.tokenizer }, null, 2)}\n`);
|
|
426
|
+
return 0;
|
|
427
|
+
}
|
|
428
|
+
process.stdout.write(renderBounds(b, manifest.tokenizer));
|
|
429
|
+
process.stdout.write(renderModel(m, manifest.tokenizer));
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
if (err instanceof BenchError || err instanceof McpTargetError) {
|
|
434
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
435
|
+
return 2;
|
|
436
|
+
}
|
|
437
|
+
process.stderr.write(`\n unexpected error: ${err.stack ?? String(err)}\n\n`);
|
|
438
|
+
return 2;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (command !== "doctor") {
|
|
442
|
+
process.stderr.write(`\n error: unknown command "${command}"\n${USAGE}`);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
let config;
|
|
446
|
+
try {
|
|
447
|
+
config = {
|
|
448
|
+
budget: parseBudget(values.budget, 2000),
|
|
449
|
+
failOn: parseSeverity(values["fail-on"], "high"),
|
|
450
|
+
only: parseIds(values.only),
|
|
451
|
+
ignore: parseIds(values.ignore),
|
|
452
|
+
followExternalImports: values["follow-external-imports"] === true,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
457
|
+
return 2;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const result = doctor(target, config);
|
|
461
|
+
const resolved = { ...result, findings: result.findings };
|
|
462
|
+
const full = {
|
|
463
|
+
budget: config.budget ?? 2000,
|
|
464
|
+
failOn: config.failOn ?? "high",
|
|
465
|
+
only: config.only ?? [],
|
|
466
|
+
ignore: config.ignore ?? [],
|
|
467
|
+
maxListLine: 120,
|
|
468
|
+
followExternalImports: config.followExternalImports === true,
|
|
469
|
+
includeAutoMemory: true,
|
|
470
|
+
};
|
|
471
|
+
if (values.json) {
|
|
472
|
+
process.stdout.write(`${renderJson(resolved, full)}\n`);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
const color = !values.ci && !process.env["NO_COLOR"] && process.stdout.isTTY === true;
|
|
476
|
+
process.stdout.write(renderHuman(resolved, full, { color, ci: values.ci === true }));
|
|
477
|
+
}
|
|
478
|
+
return exitCodeFor(result.findings, full.failOn);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (err instanceof DiscoveryError) {
|
|
482
|
+
process.stderr.write(`\n error: ${err.message}\n\n`);
|
|
483
|
+
return 2;
|
|
484
|
+
}
|
|
485
|
+
process.stderr.write(`\n unexpected error: ${err.stack ?? String(err)}\n\n`);
|
|
486
|
+
return 2;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Only run when invoked as the binary, so tests can import `main` safely. */
|
|
490
|
+
function invokedDirectly() {
|
|
491
|
+
const entry = process.argv[1];
|
|
492
|
+
if (!entry)
|
|
493
|
+
return false;
|
|
494
|
+
try {
|
|
495
|
+
return path.resolve(fileURLToPath(import.meta.url)) === path.resolve(entry);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (invokedDirectly()) {
|
|
502
|
+
process.exitCode = main(process.argv.slice(2));
|
|
503
|
+
}
|