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/paths.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Three call sites needed the same check and each grew its own version, which is how they
|
|
5
|
+
* drifted apart: reorganize.ts resolved through the nearest existing ancestor, init.ts compared
|
|
6
|
+
* a plain resolve against a realpath'd root (so a workspace behind a symlink refused every
|
|
7
|
+
* write), and mcpServer.ts skipped the realpath entirely for a path that did not exist yet.
|
|
8
|
+
* A containment check that differs per caller is a containment check you cannot reason about.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The real path of `abs`, following symlinks as far as the path exists and rejoining the
|
|
12
|
+
* not-yet-created remainder. A file that does not exist still gets its ancestors resolved, so a
|
|
13
|
+
* planned write into a symlinked directory is judged by where it would actually land.
|
|
14
|
+
*/
|
|
15
|
+
export declare function realPathOf(abs: string): string;
|
|
16
|
+
/** Is `abs` the root itself, or somewhere beneath it, once both are resolved through symlinks? */
|
|
17
|
+
export declare function containedIn(root: string, abs: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Reject a caller-supplied relative path before it is joined to a root: absolute paths and any
|
|
20
|
+
* `..` segment. Checked on the string, because a `..` that resolves back inside the root is
|
|
21
|
+
* still a caller reaching for somewhere it was not given.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isEscapingRelative(rel: string): boolean;
|
|
24
|
+
/** Does `rel` pass through a directory named `name`? A segment test, not a prefix test. */
|
|
25
|
+
export declare function hasSegment(rel: string, name: string): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Three call sites needed the same check and each grew its own version, which is how they
|
|
5
|
+
* drifted apart: reorganize.ts resolved through the nearest existing ancestor, init.ts compared
|
|
6
|
+
* a plain resolve against a realpath'd root (so a workspace behind a symlink refused every
|
|
7
|
+
* write), and mcpServer.ts skipped the realpath entirely for a path that did not exist yet.
|
|
8
|
+
* A containment check that differs per caller is a containment check you cannot reason about.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
/**
|
|
13
|
+
* The real path of `abs`, following symlinks as far as the path exists and rejoining the
|
|
14
|
+
* not-yet-created remainder. A file that does not exist still gets its ancestors resolved, so a
|
|
15
|
+
* planned write into a symlinked directory is judged by where it would actually land.
|
|
16
|
+
*/
|
|
17
|
+
export function realPathOf(abs) {
|
|
18
|
+
let existing = abs;
|
|
19
|
+
let remainder = "";
|
|
20
|
+
while (!fs.existsSync(existing)) {
|
|
21
|
+
remainder = path.join(path.basename(existing), remainder);
|
|
22
|
+
const parent = path.dirname(existing);
|
|
23
|
+
if (parent === existing)
|
|
24
|
+
return abs;
|
|
25
|
+
existing = parent;
|
|
26
|
+
}
|
|
27
|
+
const real = fs.realpathSync(existing);
|
|
28
|
+
return remainder ? path.join(real, remainder) : real;
|
|
29
|
+
}
|
|
30
|
+
/** Is `abs` the root itself, or somewhere beneath it, once both are resolved through symlinks? */
|
|
31
|
+
export function containedIn(root, abs) {
|
|
32
|
+
const realRoot = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
|
|
33
|
+
const real = realPathOf(abs);
|
|
34
|
+
return real === realRoot || real.startsWith(realRoot + path.sep);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reject a caller-supplied relative path before it is joined to a root: absolute paths and any
|
|
38
|
+
* `..` segment. Checked on the string, because a `..` that resolves back inside the root is
|
|
39
|
+
* still a caller reaching for somewhere it was not given.
|
|
40
|
+
*/
|
|
41
|
+
export function isEscapingRelative(rel) {
|
|
42
|
+
return path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..");
|
|
43
|
+
}
|
|
44
|
+
/** Does `rel` pass through a directory named `name`? A segment test, not a prefix test. */
|
|
45
|
+
export function hasSegment(rel, name) {
|
|
46
|
+
return rel.split(/[/\\]/).includes(name);
|
|
47
|
+
}
|
package/dist/recall.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recall engine: pure logic that loads a compiled workspace's manifest and serves its routed
|
|
3
|
+
* OnDemandMemory files at runtime. No SDK, no transport, no Zod - the MCP server that wires this
|
|
4
|
+
* onto actual tools lives in mcpServer.ts, kept separate on purpose so this half is testable from
|
|
5
|
+
* fixtures alone (renamed from mcp.ts 2026-09-16, C3, once mcpServer.ts had grown into the real
|
|
6
|
+
* server module and this file held none of the tool wiring its old name implied).
|
|
7
|
+
*
|
|
8
|
+
* This is v2 of routing (DESIGN.md section 6). v1, agent-driven, stays the default and keeps
|
|
9
|
+
* working with this server absent: AlwaysOnMemory already tells an agent which OnDemandMemory
|
|
10
|
+
* file to read via its own file tools. This server exists for the two cases the file route
|
|
11
|
+
* cannot cover: hosts with no persistent memory file, and repos with enough OnDemandMemory files
|
|
12
|
+
* that the list itself gets expensive to hold in every prefix.
|
|
13
|
+
*
|
|
14
|
+
* The read-only tools built on this: `recall` (the best memory sections for a task description,
|
|
15
|
+
* content inline, under a token cap), `modules` (the OnDemandMemory list, no content), `outline`
|
|
16
|
+
* (headings only, so an agent can decide before paying for an OnDemandMemory file's body).
|
|
17
|
+
* `scan` and `reorganize` live in scan.ts and reorganize.ts. `recallDir.ts` is the manifest-less
|
|
18
|
+
* counterpart, over a memory directory with no `.minnimemory/` compile step.
|
|
19
|
+
*
|
|
20
|
+
* `recall` ranks section-level units with router.ts (stemmed BM25, heading path and manifest
|
|
21
|
+
* triggers weighted) and returns units, never whole OnDemandMemory files: interface rule O4
|
|
22
|
+
* after the 2026-09-04 audit. `rankOnDemandFiles` below is the older trigger-overlap ranker over
|
|
23
|
+
* the manifest alone; it needs no file content, so `bench` still uses it to model the
|
|
24
|
+
* agent-driven route where an agent reads a whole file the OnDemandMemory list pointed at. Both
|
|
25
|
+
* are deterministic, offline, and call no model.
|
|
26
|
+
*/
|
|
27
|
+
import { type Manifest } from "./compile.js";
|
|
28
|
+
import { type Unit, type SearchIndex } from "./router.js";
|
|
29
|
+
export declare class McpTargetError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** Load and validate the manifest for a compiled workspace, or throw with a clear fix. Accepts a
|
|
32
|
+
* version 3 manifest (the `modules` key) and normalises it in memory to the version 4 shape
|
|
33
|
+
* (`onDemandFiles`), so a workspace compiled before the vocabulary sweep keeps working. */
|
|
34
|
+
export declare function loadManifest(root: string): Manifest;
|
|
35
|
+
/**
|
|
36
|
+
* Read one OnDemandMemory file by its manifest entry: the raw bytes on disk and the text the
|
|
37
|
+
* router indexes. They differ for an episodic JSON file, which is served as the markdown it
|
|
38
|
+
* stands for (O3): the manifest hash is over the bytes, so a drift check needs `raw` and the
|
|
39
|
+
* index needs `text`. One read serves both. The path is re-checked here, and the file must
|
|
40
|
+
* resolve (symlinks followed) to somewhere inside the compiled directory, so neither a `..`
|
|
41
|
+
* segment nor a symlinked file can read outside `.minnimemory/`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function loadOnDemandRaw(root: string, onDemandFile: string): {
|
|
44
|
+
raw: string;
|
|
45
|
+
text: string;
|
|
46
|
+
};
|
|
47
|
+
/** The text of one OnDemandMemory file as the router and the agent see it. Every existing
|
|
48
|
+
* caller wants only this; the raw bytes matter to the drift check in `getUnitIndex` alone. */
|
|
49
|
+
export declare function loadOnDemandContent(root: string, onDemandFile: string): string;
|
|
50
|
+
export { rankOnDemandFiles, type RankedOnDemandFile } from "./router.js";
|
|
51
|
+
/**
|
|
52
|
+
* Heading-only outline of an OnDemandMemory file's content, so an agent can decide before paying
|
|
53
|
+
* for the body.
|
|
54
|
+
* sections() returns a synthetic level-0 "(document)" entry for content with no real heading -
|
|
55
|
+
* that is not a heading an agent should see in an outline, so it is filtered here rather than
|
|
56
|
+
* leaking a fake entry through the tool.
|
|
57
|
+
*/
|
|
58
|
+
export declare function outlineOf(content: string): string[];
|
|
59
|
+
export interface RecallMatch {
|
|
60
|
+
onDemandFile: string;
|
|
61
|
+
file: string;
|
|
62
|
+
heading: string;
|
|
63
|
+
/** heading path inside the OnDemandMemory file, top down, ending with this unit */
|
|
64
|
+
path: string[];
|
|
65
|
+
kind: "section" | "entry";
|
|
66
|
+
/** 1-indexed, inclusive, within the OnDemandMemory file */
|
|
67
|
+
startLine: number;
|
|
68
|
+
endLine: number;
|
|
69
|
+
tokens: number;
|
|
70
|
+
score: number;
|
|
71
|
+
content: string;
|
|
72
|
+
}
|
|
73
|
+
export interface RecallResult {
|
|
74
|
+
query: string;
|
|
75
|
+
/** what one match is; always a section-level unit, stated so a caller never assumes a file */
|
|
76
|
+
unit: "section";
|
|
77
|
+
maxTokens: number;
|
|
78
|
+
matches: RecallMatch[];
|
|
79
|
+
/** OnDemandMemory files whose bytes no longer match the manifest hash: hand-edited since
|
|
80
|
+
* `init` wrote them (MM010). Found on the index rebuild, so it reflects the state of disk as
|
|
81
|
+
* of the last rebuild for this root. Empty on a clean workspace. */
|
|
82
|
+
drifted: string[];
|
|
83
|
+
}
|
|
84
|
+
export interface RecallOptions {
|
|
85
|
+
/** cap on returned tokens; the top hit is always returned even when it alone exceeds it */
|
|
86
|
+
maxTokens?: number;
|
|
87
|
+
/** restrict ranking to one OnDemandMemory file by name */
|
|
88
|
+
module?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The unit index and units for `manifest` at `root`, rebuilding only when the signature above has
|
|
92
|
+
* changed since the last call for this root. `recall`'s `module` filter is applied to the result
|
|
93
|
+
* of ranking against this cached, whole-workspace index rather than by building a second,
|
|
94
|
+
* file-scoped index per call - one cache entry serves every query, filtered or not.
|
|
95
|
+
* `drifted` names the OnDemandMemory files whose bytes no longer hash to their manifest entry
|
|
96
|
+
* (MM010), computed on the same rebuild and cached alongside the index: with `check` off the
|
|
97
|
+
* compiled surface, `recall` is the tool that has to say a file was hand-edited since init.
|
|
98
|
+
*/
|
|
99
|
+
export declare function getUnitIndex(root: string, manifest: Manifest): {
|
|
100
|
+
index: SearchIndex;
|
|
101
|
+
units: Unit[];
|
|
102
|
+
drifted: string[];
|
|
103
|
+
};
|
|
104
|
+
/** Test-only: forget every cached unit index, so a test that edits files on disk between calls
|
|
105
|
+
* does not need to also fake mtime/size to bust the cache. */
|
|
106
|
+
export declare function clearUnitIndexCache(): void;
|
|
107
|
+
/**
|
|
108
|
+
* The best section-level units for a query, content inline, best first, under a token cap.
|
|
109
|
+
* A hit inside a changelog returns that entry, not the log (rule 17 applied to the interface's
|
|
110
|
+
* own tool). Manifest triggers join each OnDemandMemory file's units at heading weight, so an
|
|
111
|
+
* edited trigger steers recall without touching the file's text.
|
|
112
|
+
*/
|
|
113
|
+
export declare function recall(root: string, manifest: Manifest, query: string, options?: RecallOptions): RecallResult;
|
package/dist/recall.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recall engine: pure logic that loads a compiled workspace's manifest and serves its routed
|
|
3
|
+
* OnDemandMemory files at runtime. No SDK, no transport, no Zod - the MCP server that wires this
|
|
4
|
+
* onto actual tools lives in mcpServer.ts, kept separate on purpose so this half is testable from
|
|
5
|
+
* fixtures alone (renamed from mcp.ts 2026-09-16, C3, once mcpServer.ts had grown into the real
|
|
6
|
+
* server module and this file held none of the tool wiring its old name implied).
|
|
7
|
+
*
|
|
8
|
+
* This is v2 of routing (DESIGN.md section 6). v1, agent-driven, stays the default and keeps
|
|
9
|
+
* working with this server absent: AlwaysOnMemory already tells an agent which OnDemandMemory
|
|
10
|
+
* file to read via its own file tools. This server exists for the two cases the file route
|
|
11
|
+
* cannot cover: hosts with no persistent memory file, and repos with enough OnDemandMemory files
|
|
12
|
+
* that the list itself gets expensive to hold in every prefix.
|
|
13
|
+
*
|
|
14
|
+
* The read-only tools built on this: `recall` (the best memory sections for a task description,
|
|
15
|
+
* content inline, under a token cap), `modules` (the OnDemandMemory list, no content), `outline`
|
|
16
|
+
* (headings only, so an agent can decide before paying for an OnDemandMemory file's body).
|
|
17
|
+
* `scan` and `reorganize` live in scan.ts and reorganize.ts. `recallDir.ts` is the manifest-less
|
|
18
|
+
* counterpart, over a memory directory with no `.minnimemory/` compile step.
|
|
19
|
+
*
|
|
20
|
+
* `recall` ranks section-level units with router.ts (stemmed BM25, heading path and manifest
|
|
21
|
+
* triggers weighted) and returns units, never whole OnDemandMemory files: interface rule O4
|
|
22
|
+
* after the 2026-09-04 audit. `rankOnDemandFiles` below is the older trigger-overlap ranker over
|
|
23
|
+
* the manifest alone; it needs no file content, so `bench` still uses it to model the
|
|
24
|
+
* agent-driven route where an agent reads a whole file the OnDemandMemory list pointed at. Both
|
|
25
|
+
* are deterministic, offline, and call no model.
|
|
26
|
+
*/
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { episodicJsonToMarkdown } from "./episodic.js";
|
|
31
|
+
import { sections } from "./text.js";
|
|
32
|
+
import { COMPILED_DIR_NAME, driftHash } from "./compile.js";
|
|
33
|
+
import { buildSearchIndex, RECALL_MAX_TOKENS, rankUnits, selectUnits, unitsOf } from "./router.js";
|
|
34
|
+
export class McpTargetError extends Error {
|
|
35
|
+
}
|
|
36
|
+
const COMPILED_DIR = COMPILED_DIR_NAME;
|
|
37
|
+
/**
|
|
38
|
+
* An OnDemandMemory file path the manifest is allowed to name: `OnDemandMemory/<name>.md`,
|
|
39
|
+
* nothing else. A cloned repo can ship any manifest it likes, and the server runs with the
|
|
40
|
+
* user's file permissions, so `file` is never trusted as a path (security audit 2026-09-02,
|
|
41
|
+
* finding 1).
|
|
42
|
+
*/
|
|
43
|
+
const ON_DEMAND_FILE = /^OnDemandMemory\/[A-Za-z0-9_][A-Za-z0-9_-]*\.(md|json)$/;
|
|
44
|
+
const OnDemandFileEntrySchema = z.object({
|
|
45
|
+
name: z.string().min(1),
|
|
46
|
+
file: z.string().regex(ON_DEMAND_FILE, "OnDemandMemory file must be OnDemandMemory/<name>.md or .json"),
|
|
47
|
+
tokens: z.number(),
|
|
48
|
+
hash: z.string(),
|
|
49
|
+
triggers: z.array(z.string()),
|
|
50
|
+
sourceLines: z.tuple([z.number(), z.number()]),
|
|
51
|
+
reason: z.string(),
|
|
52
|
+
kind: z.string().optional(),
|
|
53
|
+
generated: z.boolean().optional(),
|
|
54
|
+
});
|
|
55
|
+
const ManifestSchemaV4 = z.object({
|
|
56
|
+
version: z.literal(4),
|
|
57
|
+
tokenizer: z.string(),
|
|
58
|
+
generatedFrom: z.string(),
|
|
59
|
+
sourceHash: z.string(),
|
|
60
|
+
order: z.array(z.string()),
|
|
61
|
+
prefix: z.object({ before: z.number(), after: z.number() }),
|
|
62
|
+
always: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
63
|
+
stub: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
64
|
+
onDemandFiles: z.array(OnDemandFileEntrySchema),
|
|
65
|
+
});
|
|
66
|
+
/** Version 3 shape, kept only so an already-compiled workspace's manifest.json still loads;
|
|
67
|
+
* `init --update` rewrites it as version 4. The one difference is the `modules` key, normalised
|
|
68
|
+
* to `onDemandFiles` below once parsed. */
|
|
69
|
+
const ManifestSchemaV3 = z.object({
|
|
70
|
+
version: z.literal(3),
|
|
71
|
+
tokenizer: z.string(),
|
|
72
|
+
generatedFrom: z.string(),
|
|
73
|
+
sourceHash: z.string(),
|
|
74
|
+
order: z.array(z.string()),
|
|
75
|
+
prefix: z.object({ before: z.number(), after: z.number() }),
|
|
76
|
+
always: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
77
|
+
stub: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
78
|
+
modules: z.array(OnDemandFileEntrySchema),
|
|
79
|
+
});
|
|
80
|
+
/** Load and validate the manifest for a compiled workspace, or throw with a clear fix. Accepts a
|
|
81
|
+
* version 3 manifest (the `modules` key) and normalises it in memory to the version 4 shape
|
|
82
|
+
* (`onDemandFiles`), so a workspace compiled before the vocabulary sweep keeps working. */
|
|
83
|
+
export function loadManifest(root) {
|
|
84
|
+
const manifestPath = path.join(root, COMPILED_DIR, "manifest.json");
|
|
85
|
+
if (!fs.existsSync(manifestPath)) {
|
|
86
|
+
throw new McpTargetError(`no compiled workspace at ${root}\n` +
|
|
87
|
+
` expected ${COMPILED_DIR}/manifest.json - run "minnimemory init --write" first`);
|
|
88
|
+
}
|
|
89
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
throw new McpTargetError(`${manifestPath} is not valid JSON: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
const v4 = ManifestSchemaV4.safeParse(parsed);
|
|
98
|
+
if (v4.success)
|
|
99
|
+
return v4.data;
|
|
100
|
+
const v3 = ManifestSchemaV3.safeParse(parsed);
|
|
101
|
+
if (v3.success) {
|
|
102
|
+
const { modules, version: _version, ...rest } = v3.data;
|
|
103
|
+
return { ...rest, version: 4, onDemandFiles: modules };
|
|
104
|
+
}
|
|
105
|
+
const first = v4.error.issues[0];
|
|
106
|
+
const where = first ? `${first.path.join(".")}: ${first.message}` : "invalid shape";
|
|
107
|
+
throw new McpTargetError(`${manifestPath} is not a valid manifest (${where}) - rerun init`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Read one OnDemandMemory file by its manifest entry: the raw bytes on disk and the text the
|
|
111
|
+
* router indexes. They differ for an episodic JSON file, which is served as the markdown it
|
|
112
|
+
* stands for (O3): the manifest hash is over the bytes, so a drift check needs `raw` and the
|
|
113
|
+
* index needs `text`. One read serves both. The path is re-checked here, and the file must
|
|
114
|
+
* resolve (symlinks followed) to somewhere inside the compiled directory, so neither a `..`
|
|
115
|
+
* segment nor a symlinked file can read outside `.minnimemory/`.
|
|
116
|
+
*/
|
|
117
|
+
export function loadOnDemandRaw(root, onDemandFile) {
|
|
118
|
+
if (!ON_DEMAND_FILE.test(onDemandFile)) {
|
|
119
|
+
throw new McpTargetError(`refusing OnDemandMemory path outside OnDemandMemory/: ${onDemandFile}`);
|
|
120
|
+
}
|
|
121
|
+
const compiledDir = path.join(root, COMPILED_DIR);
|
|
122
|
+
const abs = path.join(compiledDir, onDemandFile);
|
|
123
|
+
if (!fs.existsSync(abs)) {
|
|
124
|
+
throw new McpTargetError(`OnDemandMemory file missing on disk: ${onDemandFile} (manifest is stale - rerun init)`);
|
|
125
|
+
}
|
|
126
|
+
const real = fs.realpathSync(abs);
|
|
127
|
+
const realDir = fs.realpathSync(compiledDir);
|
|
128
|
+
if (!real.startsWith(realDir + path.sep)) {
|
|
129
|
+
throw new McpTargetError(`refusing OnDemandMemory file that resolves outside ${COMPILED_DIR}/: ${onDemandFile}`);
|
|
130
|
+
}
|
|
131
|
+
const raw = fs.readFileSync(real, "utf8");
|
|
132
|
+
// O3: a JSON episodic file is served to the router and the agent as the markdown it stands for.
|
|
133
|
+
return { raw, text: onDemandFile.endsWith(".json") ? episodicJsonToMarkdown(raw) : raw };
|
|
134
|
+
}
|
|
135
|
+
/** The text of one OnDemandMemory file as the router and the agent see it. Every existing
|
|
136
|
+
* caller wants only this; the raw bytes matter to the drift check in `getUnitIndex` alone. */
|
|
137
|
+
export function loadOnDemandContent(root, onDemandFile) {
|
|
138
|
+
return loadOnDemandRaw(root, onDemandFile).text;
|
|
139
|
+
}
|
|
140
|
+
// RankedOnDemandFile and rankOnDemandFiles moved to router.js (2026-09-13 audit round 2, D7c): they belong
|
|
141
|
+
// beside the other ranking code, not on this recall-engine module. Re-exported here so
|
|
142
|
+
// bench.ts and every existing `from "./recall.js"` import keeps working unchanged.
|
|
143
|
+
export { rankOnDemandFiles } from "./router.js";
|
|
144
|
+
/**
|
|
145
|
+
* Heading-only outline of an OnDemandMemory file's content, so an agent can decide before paying
|
|
146
|
+
* for the body.
|
|
147
|
+
* sections() returns a synthetic level-0 "(document)" entry for content with no real heading -
|
|
148
|
+
* that is not a heading an agent should see in an outline, so it is filtered here rather than
|
|
149
|
+
* leaking a fake entry through the tool.
|
|
150
|
+
*/
|
|
151
|
+
export function outlineOf(content) {
|
|
152
|
+
const lines = content.split(/\r?\n/);
|
|
153
|
+
return sections(lines)
|
|
154
|
+
.filter((s) => s.level > 0)
|
|
155
|
+
.map((s) => `${"#".repeat(s.level)} ${s.heading}`);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* One cache entry per server root, holding the section-level unit index `recall` ranks against.
|
|
159
|
+
* Rebuilding it means re-reading and re-splitting every OnDemandMemory file and re-running
|
|
160
|
+
* BM25's document-frequency pass over the whole corpus - real work `recall` used to redo on every
|
|
161
|
+
* single call, even from the same still-unchanged workspace within one server session
|
|
162
|
+
* (2026-09-13 audit round 2, D5).
|
|
163
|
+
*/
|
|
164
|
+
const unitIndexCache = new Map();
|
|
165
|
+
/** One OnDemandMemory file's contribution to the cache signature: its manifest hash plus what is
|
|
166
|
+
* actually on disk right now, so an edit made outside `init`/`update` (a hand edit to an
|
|
167
|
+
* OnDemandMemory file) still invalidates the cache even though the manifest itself did not
|
|
168
|
+
* change. */
|
|
169
|
+
function onDemandFileSignature(root, onDemandFile, manifestHash) {
|
|
170
|
+
try {
|
|
171
|
+
const st = fs.statSync(path.join(root, COMPILED_DIR, onDemandFile));
|
|
172
|
+
return `${onDemandFile}:${manifestHash}:${st.mtimeMs}:${st.size}`;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return `${onDemandFile}:${manifestHash}:(missing)`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function manifestSignature(root, manifest) {
|
|
179
|
+
const perFile = manifest.onDemandFiles.map((m) => onDemandFileSignature(root, m.file, m.hash)).join("|");
|
|
180
|
+
return `${manifest.sourceHash}:${manifest.always.hash}:${perFile}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The unit index and units for `manifest` at `root`, rebuilding only when the signature above has
|
|
184
|
+
* changed since the last call for this root. `recall`'s `module` filter is applied to the result
|
|
185
|
+
* of ranking against this cached, whole-workspace index rather than by building a second,
|
|
186
|
+
* file-scoped index per call - one cache entry serves every query, filtered or not.
|
|
187
|
+
* `drifted` names the OnDemandMemory files whose bytes no longer hash to their manifest entry
|
|
188
|
+
* (MM010), computed on the same rebuild and cached alongside the index: with `check` off the
|
|
189
|
+
* compiled surface, `recall` is the tool that has to say a file was hand-edited since init.
|
|
190
|
+
*/
|
|
191
|
+
export function getUnitIndex(root, manifest) {
|
|
192
|
+
const signature = manifestSignature(root, manifest);
|
|
193
|
+
const cached = unitIndexCache.get(root);
|
|
194
|
+
if (cached && cached.signature === signature) {
|
|
195
|
+
return { index: cached.index, units: cached.units, drifted: cached.drifted };
|
|
196
|
+
}
|
|
197
|
+
// A rebuild is the only place drift can be checked cheaply: the content is already being
|
|
198
|
+
// read and parsed here, and a cache hit means nothing on disk moved, so there is no new
|
|
199
|
+
// drift to find. The first call of any session rebuilds, which is what catches drift a
|
|
200
|
+
// previous session left behind. Hash the raw bytes, not the indexed text: an episodic
|
|
201
|
+
// JSON file is indexed as the markdown it stands for, but the manifest hashed the bytes.
|
|
202
|
+
// driftHash, not a raw hash, so a checkout that rewrote line endings is not drift.
|
|
203
|
+
const drifted = [];
|
|
204
|
+
const units = manifest.onDemandFiles.flatMap((m) => {
|
|
205
|
+
const { raw, text } = loadOnDemandRaw(root, m.file);
|
|
206
|
+
if (driftHash(raw) !== m.hash)
|
|
207
|
+
drifted.push(m.file);
|
|
208
|
+
return unitsOf({ name: m.name, file: m.file }, text);
|
|
209
|
+
});
|
|
210
|
+
const index = buildSearchIndex(units, new Map(manifest.onDemandFiles.map((m) => [m.name, m.triggers])));
|
|
211
|
+
unitIndexCache.set(root, { signature, index, units, drifted });
|
|
212
|
+
return { index, units, drifted };
|
|
213
|
+
}
|
|
214
|
+
/** Test-only: forget every cached unit index, so a test that edits files on disk between calls
|
|
215
|
+
* does not need to also fake mtime/size to bust the cache. */
|
|
216
|
+
export function clearUnitIndexCache() {
|
|
217
|
+
unitIndexCache.clear();
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The best section-level units for a query, content inline, best first, under a token cap.
|
|
221
|
+
* A hit inside a changelog returns that entry, not the log (rule 17 applied to the interface's
|
|
222
|
+
* own tool). Manifest triggers join each OnDemandMemory file's units at heading weight, so an
|
|
223
|
+
* edited trigger steers recall without touching the file's text.
|
|
224
|
+
*/
|
|
225
|
+
export function recall(root, manifest, query, options = {}) {
|
|
226
|
+
if (options.module !== undefined && !manifest.onDemandFiles.some((m) => m.name === options.module)) {
|
|
227
|
+
const known = manifest.onDemandFiles.map((m) => m.name).join(", ") || "(none)";
|
|
228
|
+
throw new McpTargetError(`unknown OnDemandMemory file "${options.module}". known OnDemandMemory files: ${known}`);
|
|
229
|
+
}
|
|
230
|
+
const { index, drifted } = getUnitIndex(root, manifest);
|
|
231
|
+
const maxTokens = options.maxTokens ?? RECALL_MAX_TOKENS;
|
|
232
|
+
let ranked = rankUnits(index, query);
|
|
233
|
+
if (options.module !== undefined) {
|
|
234
|
+
const wanted = options.module;
|
|
235
|
+
ranked = ranked.filter((r) => r.unit.onDemandFile === wanted);
|
|
236
|
+
}
|
|
237
|
+
const picked = selectUnits(ranked, maxTokens);
|
|
238
|
+
return {
|
|
239
|
+
query,
|
|
240
|
+
unit: "section",
|
|
241
|
+
maxTokens,
|
|
242
|
+
matches: picked.map(({ unit, score }) => ({
|
|
243
|
+
onDemandFile: unit.onDemandFile,
|
|
244
|
+
file: unit.file,
|
|
245
|
+
heading: unit.heading,
|
|
246
|
+
path: unit.path,
|
|
247
|
+
kind: unit.kind,
|
|
248
|
+
startLine: unit.startLine,
|
|
249
|
+
endLine: unit.endLine,
|
|
250
|
+
tokens: unit.tokens,
|
|
251
|
+
score,
|
|
252
|
+
content: unit.content,
|
|
253
|
+
})),
|
|
254
|
+
drifted,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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 type { RecallResult } from "./recall.js";
|
|
20
|
+
import { type SearchIndex } from "./router.js";
|
|
21
|
+
export declare class RecallDirError extends Error {
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The section-level unit index for a memory directory, rebuilt only when a topic file's or the
|
|
25
|
+
* index file's mtime/size has changed since the last call for this directory.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getDirIndex(dir: string): SearchIndex;
|
|
28
|
+
/** Test-only: forget every cached directory index, so a test that edits files on disk between
|
|
29
|
+
* calls does not also need to fake mtime/size to bust the cache. */
|
|
30
|
+
export declare function clearDirIndexCache(): void;
|
|
31
|
+
/** Every topic file's name plus, when an index file is present, the hook line it parsed to
|
|
32
|
+
* (`[]` when none was found). The listing recall() with no query, or a no-match miss, renders. */
|
|
33
|
+
export interface DirFileEntry {
|
|
34
|
+
name: string;
|
|
35
|
+
file: string;
|
|
36
|
+
triggers: string[];
|
|
37
|
+
}
|
|
38
|
+
export declare function listDirFiles(dir: string): DirFileEntry[];
|
|
39
|
+
export interface RecallDirOptions {
|
|
40
|
+
/** cap on returned tokens; the top hit is always returned even when it alone exceeds it */
|
|
41
|
+
maxTokens?: number;
|
|
42
|
+
/** restrict ranking to one topic file by name (extension stripped) */
|
|
43
|
+
file?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The best section-level units for a query over a memory directory with no manifest, same shape
|
|
47
|
+
* as recall.ts's recall(). `drifted` is always empty here: drift (MM010) is a manifest concept, and
|
|
48
|
+
* a manifest-less directory has none to drift from.
|
|
49
|
+
*/
|
|
50
|
+
export declare function recallDir(dir: string, query: string, options?: RecallDirOptions): RecallResult;
|