minnimemory 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +39 -0
  2. package/README.md +824 -0
  3. package/dist/bench.d.ts +98 -0
  4. package/dist/bench.js +142 -0
  5. package/dist/benchReport.d.ts +12 -0
  6. package/dist/benchReport.js +128 -0
  7. package/dist/bounds.d.ts +40 -0
  8. package/dist/bounds.js +44 -0
  9. package/dist/cli.d.ts +15 -0
  10. package/dist/cli.js +503 -0
  11. package/dist/compile.d.ts +187 -0
  12. package/dist/compile.js +516 -0
  13. package/dist/discover.d.ts +125 -0
  14. package/dist/discover.js +520 -0
  15. package/dist/doctor.d.ts +9 -0
  16. package/dist/doctor.js +67 -0
  17. package/dist/episodic.d.ts +47 -0
  18. package/dist/episodic.js +130 -0
  19. package/dist/hook.d.ts +45 -0
  20. package/dist/hook.js +104 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +18 -0
  23. package/dist/init.d.ts +125 -0
  24. package/dist/init.js +475 -0
  25. package/dist/instructions.d.ts +60 -0
  26. package/dist/instructions.js +270 -0
  27. package/dist/mcp.d.ts +109 -0
  28. package/dist/mcp.js +252 -0
  29. package/dist/mcpServer.d.ts +136 -0
  30. package/dist/mcpServer.js +997 -0
  31. package/dist/paths.d.ts +25 -0
  32. package/dist/paths.js +47 -0
  33. package/dist/recall.d.ts +113 -0
  34. package/dist/recall.js +256 -0
  35. package/dist/recallDir.d.ts +50 -0
  36. package/dist/recallDir.js +187 -0
  37. package/dist/reorganize.d.ts +62 -0
  38. package/dist/reorganize.js +216 -0
  39. package/dist/report.d.ts +16 -0
  40. package/dist/report.js +204 -0
  41. package/dist/router.d.ts +141 -0
  42. package/dist/router.js +314 -0
  43. package/dist/rules.d.ts +32 -0
  44. package/dist/rules.js +651 -0
  45. package/dist/scan.d.ts +110 -0
  46. package/dist/scan.js +173 -0
  47. package/dist/text.d.ts +158 -0
  48. package/dist/text.js +395 -0
  49. package/dist/tokenizer.d.ts +26 -0
  50. package/dist/tokenizer.js +69 -0
  51. package/dist/types.d.ts +156 -0
  52. package/dist/types.js +17 -0
  53. package/dist/version.d.ts +7 -0
  54. package/dist/version.js +7 -0
  55. package/dist/writeProtocol.d.ts +19 -0
  56. package/dist/writeProtocol.js +45 -0
  57. package/examples/CLAUDE.md +75 -0
  58. package/examples/README.md +7 -0
  59. package/package.json +52 -0
@@ -0,0 +1,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
+ }
@@ -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;