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,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token-reduction instruction set.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the MinniMemory v1.0 research file. These are the behavioural half of the
|
|
5
|
+
* product: restructuring a memory file into AlwaysOnMemory plus OnDemandMemory only pays off
|
|
6
|
+
* if the agent is actually told to route rather than read everything, so candidates 15-25 in
|
|
7
|
+
* particular are what make the emitted structure do anything at all.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT, and it must stay in the generated output: this set is reasoned and audited but
|
|
10
|
+
* NOT measured. The source research states plainly that no live measurement pass has run, and
|
|
11
|
+
* that candidates 5 and 15-25 cannot be measured by the current single-turn harness at all.
|
|
12
|
+
* Nothing here may be presented to a user as a proven saving.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The default profile resolves the redundancies the source research documents rather than
|
|
16
|
+
* emitting all 25 and letting them contradict each other:
|
|
17
|
+
*
|
|
18
|
+
* - #1 subsumes #6, #7, #10 and #12, so those four are off by default.
|
|
19
|
+
* - #3 and #11 directly conflict, so both are off and MERGED-3-11 replaces them.
|
|
20
|
+
* - #13 is off by default because it trades a clarifying question for an assumption, which is
|
|
21
|
+
* a judgement call a tool should not make for someone silently.
|
|
22
|
+
*/
|
|
23
|
+
export const INSTRUCTIONS = [
|
|
24
|
+
// -------------------------------------------------------------- output side
|
|
25
|
+
{
|
|
26
|
+
id: "01",
|
|
27
|
+
title: "Answer-length discipline",
|
|
28
|
+
text: "Give the shortest complete answer. Do not restate the question, add a preamble such as \"Here is\" or \"Sure\", or offer further help unless asked.",
|
|
29
|
+
category: "output",
|
|
30
|
+
defaultOn: true,
|
|
31
|
+
note: "subsumes 06, 07, 10 and 12",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "02",
|
|
35
|
+
title: "No visible re-verification",
|
|
36
|
+
text: "Do not restate your reasoning, double-check, or re-explain your answer in the visible response. If you need to verify something, do it silently before answering.",
|
|
37
|
+
category: "output",
|
|
38
|
+
defaultOn: true,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "03",
|
|
42
|
+
title: "Format-appropriate compactness",
|
|
43
|
+
text: "For list-like or factual answers, use the most compact valid Markdown (tables, short bullets) instead of full prose paragraphs.",
|
|
44
|
+
category: "output",
|
|
45
|
+
defaultOn: false,
|
|
46
|
+
note: "conflicts with 11; replaced by MERGED-3-11",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "04",
|
|
50
|
+
title: "Reference, do not repeat",
|
|
51
|
+
text: "Do not repeat back information the user already provided or that appears earlier in this conversation. Reference it briefly instead of restating it in full.",
|
|
52
|
+
category: "output",
|
|
53
|
+
defaultOn: true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "05",
|
|
57
|
+
title: "Cache-stable prompt",
|
|
58
|
+
text: "Keep the always-loaded text identical across calls. Per-request values, such as dates, names, or session data, go in the request, never in the always-loaded block.",
|
|
59
|
+
category: "context",
|
|
60
|
+
defaultOn: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "06",
|
|
64
|
+
title: "Scope discipline",
|
|
65
|
+
text: "Deliver exactly what was asked, at the scope implied. Do not add unrequested extras, alternatives, or follow-up suggestions.",
|
|
66
|
+
category: "output",
|
|
67
|
+
defaultOn: false,
|
|
68
|
+
note: "covered by 01",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "07",
|
|
72
|
+
title: "No closing offers",
|
|
73
|
+
text: "Do not end with an offer to do more, such as \"Let me know if\" or \"Want me to also\", unless it is the direct answer to what was asked.",
|
|
74
|
+
category: "output",
|
|
75
|
+
defaultOn: false,
|
|
76
|
+
note: "covered by 01",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "08",
|
|
80
|
+
title: "Silent corrections",
|
|
81
|
+
text: "If you notice and fix your own earlier mistake, correct it plainly in one line and move on. Do not narrate the mistake, apologize, or explain what went wrong.",
|
|
82
|
+
category: "output",
|
|
83
|
+
defaultOn: true,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "09",
|
|
87
|
+
title: "No restated instructions or inputs",
|
|
88
|
+
text: "Do not repeat the task description, the rules you were given, or the input data back before answering. Go straight to the answer.",
|
|
89
|
+
category: "output",
|
|
90
|
+
defaultOn: true,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "10",
|
|
94
|
+
title: "One answer, not a menu",
|
|
95
|
+
text: "Give one recommended answer or approach, not a list of alternatives, unless the user explicitly asked to compare options.",
|
|
96
|
+
category: "output",
|
|
97
|
+
defaultOn: false,
|
|
98
|
+
note: "covered by 01",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "11",
|
|
102
|
+
title: "Minimal formatting overhead",
|
|
103
|
+
text: "Use plain sentences for short answers. Reserve headers, bold, and bullet lists for content that is genuinely structured or long enough to need them.",
|
|
104
|
+
category: "output",
|
|
105
|
+
defaultOn: false,
|
|
106
|
+
note: "conflicts with 03; replaced by MERGED-3-11",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "12",
|
|
110
|
+
title: "No trailing summary",
|
|
111
|
+
text: "Do not add a closing recap or \"In summary\" paragraph restating what was already said, unless the response is long enough that a reader would actually need one.",
|
|
112
|
+
category: "output",
|
|
113
|
+
defaultOn: false,
|
|
114
|
+
note: "covered by 01",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "13",
|
|
118
|
+
title: "Assume, do not ask, on reversible defaults",
|
|
119
|
+
text: "For small ambiguous choices with a reasonable default, make the choice and state it in passing rather than asking a clarifying question first.",
|
|
120
|
+
category: "output",
|
|
121
|
+
defaultOn: false,
|
|
122
|
+
note: "off by default: trading a clarifying question for an assumption is a judgement call, and some workflows want the question",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "14",
|
|
126
|
+
title: "No restating in code comments",
|
|
127
|
+
text: "In code output, do not add comments describing what the next line does. Comment only a genuinely non-obvious constraint.",
|
|
128
|
+
category: "output",
|
|
129
|
+
defaultOn: true,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "MERGED-3-11",
|
|
133
|
+
title: "Structure only when it earns its place",
|
|
134
|
+
text: "Use compact Markdown (tables, short bullets) only for content that is genuinely list-like or long enough to need structure. Otherwise use plain sentences.",
|
|
135
|
+
category: "output",
|
|
136
|
+
defaultOn: true,
|
|
137
|
+
note: "the documented resolution of the 03 and 11 conflict",
|
|
138
|
+
},
|
|
139
|
+
// ------------------------------------------------------------- context side
|
|
140
|
+
{
|
|
141
|
+
id: "15",
|
|
142
|
+
title: "Answer from AlwaysOnMemory before loading an OnDemandMemory file",
|
|
143
|
+
text: "Answer from AlwaysOnMemory when it is sufficient. Load an OnDemandMemory file only when the task needs what AlwaysOnMemory does not hold, and load only that file.",
|
|
144
|
+
category: "context",
|
|
145
|
+
defaultOn: true,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "16",
|
|
149
|
+
title: "The OnDemandMemory list routes, it does not answer",
|
|
150
|
+
text: "The OnDemandMemory list is a map, not an answer. Use it to pick a file, then answer from that file, never from the list.",
|
|
151
|
+
category: "context",
|
|
152
|
+
defaultOn: true,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "17",
|
|
156
|
+
title: "Targeted reads, not whole documents",
|
|
157
|
+
text: "When you need part of a file, read only that part. Search or read the relevant section rather than ingesting the whole document. Read the rest only if the section proves insufficient.",
|
|
158
|
+
category: "context",
|
|
159
|
+
defaultOn: true,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "18",
|
|
163
|
+
title: "Do not echo what you read",
|
|
164
|
+
text: "Never reprint file contents, tool results, or retrieved passages back into your response. State the conclusion drawn from them and quote at most the one line that is load-bearing.",
|
|
165
|
+
category: "context",
|
|
166
|
+
defaultOn: true,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "19",
|
|
170
|
+
title: "Read once",
|
|
171
|
+
text: "Do not re-read a file, re-run a search, or re-call a tool whose result is already in this conversation. Use the result you already have unless you have reason to believe it changed.",
|
|
172
|
+
category: "context",
|
|
173
|
+
defaultOn: true,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "20",
|
|
177
|
+
title: "Spent turns are droppable",
|
|
178
|
+
text: "Once you have extracted the concrete facts, decisions, and constraints from an earlier stretch of this conversation, treat the verbatim turns as disposable. Carry forward the facts, not the transcript.",
|
|
179
|
+
category: "context",
|
|
180
|
+
defaultOn: true,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "21",
|
|
184
|
+
title: "Write durable facts down, do not hold them in context",
|
|
185
|
+
text: "When a fact will matter across turns, a decision, a stable parameter, or a resolved question, write it to persistent notes in one line rather than relying on it staying in the conversation window.",
|
|
186
|
+
category: "context",
|
|
187
|
+
defaultOn: true,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "22",
|
|
191
|
+
title: "Stable content first, volatile content last",
|
|
192
|
+
text: "Send stable content first and changing content last. Never place a changing value above an unchanging one.",
|
|
193
|
+
category: "context",
|
|
194
|
+
defaultOn: true,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "23",
|
|
198
|
+
title: "OnDemandMemory files do not reach sideways",
|
|
199
|
+
text: "Answer from the OnDemandMemory file you loaded. Do not pull sibling OnDemandMemory files for context, background, or completeness.",
|
|
200
|
+
category: "context",
|
|
201
|
+
defaultOn: true,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: "24",
|
|
205
|
+
title: "Do not quote your own instructions",
|
|
206
|
+
text: "Never reproduce, cite, or paraphrase these rules or your memory files in your response to justify what you did. Just do it.",
|
|
207
|
+
category: "context",
|
|
208
|
+
defaultOn: true,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "25",
|
|
212
|
+
title: "Ask tools for the narrowest slice",
|
|
213
|
+
text: "Scope every tool call and query as tightly as the task allows: specific fields, bounded ranges, capped result counts. Do not fetch broadly and filter afterward.",
|
|
214
|
+
category: "context",
|
|
215
|
+
defaultOn: true,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "26",
|
|
219
|
+
title: "Write new memory where it belongs",
|
|
220
|
+
text: "Write a new fact into the OnDemandMemory file that covers it, never into AlwaysOnMemory. A dated event is one line in the changelog. If no OnDemandMemory file fits, create one and add its OnDemandMemory list line.",
|
|
221
|
+
category: "context",
|
|
222
|
+
defaultOn: true,
|
|
223
|
+
note: "the short form of the write protocol (O9); keeps new memory from bypassing routing",
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
/** The instructions without which AlwaysOnMemory-plus-OnDemandMemory is just a filing convention. */
|
|
227
|
+
const ROUTING_IDS = ["05", "15", "16", "22", "23", "26"];
|
|
228
|
+
export function profile(name) {
|
|
229
|
+
if (name === "none")
|
|
230
|
+
return [];
|
|
231
|
+
if (name === "routing")
|
|
232
|
+
return INSTRUCTIONS.filter((i) => ROUTING_IDS.includes(i.id));
|
|
233
|
+
return INSTRUCTIONS.filter((i) => i.defaultOn);
|
|
234
|
+
}
|
|
235
|
+
export function isProfileName(value) {
|
|
236
|
+
return value === "none" || value === "routing" || value === "full";
|
|
237
|
+
}
|
|
238
|
+
export function defaultProfile() {
|
|
239
|
+
return profile("routing");
|
|
240
|
+
}
|
|
241
|
+
export function instructionById(id) {
|
|
242
|
+
return INSTRUCTIONS.find((i) => i.id.toLowerCase() === id.toLowerCase());
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Render the selected instructions as the block that goes into an emitted AlwaysOnMemory file.
|
|
246
|
+
* Context rules come first because they govern what gets loaded at all.
|
|
247
|
+
*/
|
|
248
|
+
export function renderInstructions(selected) {
|
|
249
|
+
// An empty profile emits nothing at all, not an empty heading.
|
|
250
|
+
if (selected.length === 0)
|
|
251
|
+
return "";
|
|
252
|
+
const context = selected.filter((i) => i.category === "context");
|
|
253
|
+
const output = selected.filter((i) => i.category === "output");
|
|
254
|
+
// No caveat sentence here: whether the rules are measured is recorded in the manifest
|
|
255
|
+
// (instructionStatus) and the README, not charged to the agent on every turn (P3).
|
|
256
|
+
const lines = ["## Token discipline", ""];
|
|
257
|
+
if (context.length) {
|
|
258
|
+
lines.push("### What to load and keep", "");
|
|
259
|
+
for (const i of context)
|
|
260
|
+
lines.push(`- ${i.text}`);
|
|
261
|
+
lines.push("");
|
|
262
|
+
}
|
|
263
|
+
if (output.length) {
|
|
264
|
+
lines.push("### How to answer", "");
|
|
265
|
+
for (const i of output)
|
|
266
|
+
lines.push(`- ${i.text}`);
|
|
267
|
+
lines.push("");
|
|
268
|
+
}
|
|
269
|
+
return lines.join("\n").trimEnd();
|
|
270
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server: read-only tools that serve a compiled workspace's routed OnDemandMemory files
|
|
3
|
+
* at runtime.
|
|
4
|
+
*
|
|
5
|
+
* This is v2 of routing (DESIGN.md section 6). v1, agent-driven, stays the default and keeps
|
|
6
|
+
* working with this server absent: AlwaysOnMemory already tells an agent which OnDemandMemory
|
|
7
|
+
* file to read via its own file tools. This server exists for the two cases the file route
|
|
8
|
+
* cannot cover: hosts with no persistent memory file, and repos with enough OnDemandMemory files
|
|
9
|
+
* that the list itself gets expensive to hold in every prefix.
|
|
10
|
+
*
|
|
11
|
+
* The read-only tools: `recall` (the best memory sections for a task description, content
|
|
12
|
+
* inline, under a token cap), `modules` (the OnDemandMemory list, no content), `outline`
|
|
13
|
+
* (headings only, so an agent can decide before paying for an OnDemandMemory file's body).
|
|
14
|
+
* `scan` and `reorganize` live in scan.ts and reorganize.ts.
|
|
15
|
+
*
|
|
16
|
+
* `recall` ranks section-level units with router.ts (stemmed BM25, heading path and manifest
|
|
17
|
+
* triggers weighted) and returns units, never whole OnDemandMemory files: interface rule O4
|
|
18
|
+
* after the 2026-09-04 audit. `rankOnDemandFiles` below is the older trigger-overlap ranker over
|
|
19
|
+
* the manifest alone; it needs no file content, so `bench` still uses it to model the
|
|
20
|
+
* agent-driven route where an agent reads a whole file the OnDemandMemory list pointed at. Both
|
|
21
|
+
* are deterministic, offline, and call no model.
|
|
22
|
+
*/
|
|
23
|
+
import { type Manifest } from "./compile.js";
|
|
24
|
+
import { type Unit, type SearchIndex } from "./router.js";
|
|
25
|
+
export declare class McpTargetError extends Error {
|
|
26
|
+
}
|
|
27
|
+
/** Load and validate the manifest for a compiled workspace, or throw with a clear fix. Accepts a
|
|
28
|
+
* version 3 manifest (the `modules` key) and normalises it in memory to the version 4 shape
|
|
29
|
+
* (`onDemandFiles`), so a workspace compiled before the vocabulary sweep keeps working. */
|
|
30
|
+
export declare function loadManifest(root: string): Manifest;
|
|
31
|
+
/**
|
|
32
|
+
* Read one OnDemandMemory file by its manifest entry: the raw bytes on disk and the text the
|
|
33
|
+
* router indexes. They differ for an episodic JSON file, which is served as the markdown it
|
|
34
|
+
* stands for (O3): the manifest hash is over the bytes, so a drift check needs `raw` and the
|
|
35
|
+
* index needs `text`. One read serves both. The path is re-checked here, and the file must
|
|
36
|
+
* resolve (symlinks followed) to somewhere inside the compiled directory, so neither a `..`
|
|
37
|
+
* segment nor a symlinked file can read outside `.minnimemory/`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function loadOnDemandRaw(root: string, onDemandFile: string): {
|
|
40
|
+
raw: string;
|
|
41
|
+
text: string;
|
|
42
|
+
};
|
|
43
|
+
/** The text of one OnDemandMemory file as the router and the agent see it. Every existing
|
|
44
|
+
* caller wants only this; the raw bytes matter to the drift check in `getUnitIndex` alone. */
|
|
45
|
+
export declare function loadOnDemandContent(root: string, onDemandFile: string): string;
|
|
46
|
+
export { rankOnDemandFiles, type RankedOnDemandFile } from "./router.js";
|
|
47
|
+
/**
|
|
48
|
+
* Heading-only outline of an OnDemandMemory file's content, so an agent can decide before paying
|
|
49
|
+
* for the body.
|
|
50
|
+
* sections() returns a synthetic level-0 "(document)" entry for content with no real heading -
|
|
51
|
+
* that is not a heading an agent should see in an outline, so it is filtered here rather than
|
|
52
|
+
* leaking a fake entry through the tool.
|
|
53
|
+
*/
|
|
54
|
+
export declare function outlineOf(content: string): string[];
|
|
55
|
+
export interface RecallMatch {
|
|
56
|
+
onDemandFile: string;
|
|
57
|
+
file: string;
|
|
58
|
+
heading: string;
|
|
59
|
+
/** heading path inside the OnDemandMemory file, top down, ending with this unit */
|
|
60
|
+
path: string[];
|
|
61
|
+
kind: "section" | "entry";
|
|
62
|
+
/** 1-indexed, inclusive, within the OnDemandMemory file */
|
|
63
|
+
startLine: number;
|
|
64
|
+
endLine: number;
|
|
65
|
+
tokens: number;
|
|
66
|
+
score: number;
|
|
67
|
+
content: string;
|
|
68
|
+
}
|
|
69
|
+
export interface RecallResult {
|
|
70
|
+
query: string;
|
|
71
|
+
/** what one match is; always a section-level unit, stated so a caller never assumes a file */
|
|
72
|
+
unit: "section";
|
|
73
|
+
maxTokens: number;
|
|
74
|
+
matches: RecallMatch[];
|
|
75
|
+
/** OnDemandMemory files whose bytes no longer match the manifest hash: hand-edited since
|
|
76
|
+
* `init` wrote them (MM010). Found on the index rebuild, so it reflects the state of disk as
|
|
77
|
+
* of the last rebuild for this root. Empty on a clean workspace. */
|
|
78
|
+
drifted: string[];
|
|
79
|
+
}
|
|
80
|
+
export interface RecallOptions {
|
|
81
|
+
/** cap on returned tokens; the top hit is always returned even when it alone exceeds it */
|
|
82
|
+
maxTokens?: number;
|
|
83
|
+
/** restrict ranking to one OnDemandMemory file by name */
|
|
84
|
+
module?: string;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The unit index and units for `manifest` at `root`, rebuilding only when the signature above has
|
|
88
|
+
* changed since the last call for this root. `recall`'s `module` filter is applied to the result
|
|
89
|
+
* of ranking against this cached, whole-workspace index rather than by building a second,
|
|
90
|
+
* file-scoped index per call - one cache entry serves every query, filtered or not.
|
|
91
|
+
* `drifted` names the OnDemandMemory files whose bytes no longer hash to their manifest entry
|
|
92
|
+
* (MM010), computed on the same rebuild and cached alongside the index: with `check` off the
|
|
93
|
+
* compiled surface, `recall` is the tool that has to say a file was hand-edited since init.
|
|
94
|
+
*/
|
|
95
|
+
export declare function getUnitIndex(root: string, manifest: Manifest): {
|
|
96
|
+
index: SearchIndex;
|
|
97
|
+
units: Unit[];
|
|
98
|
+
drifted: string[];
|
|
99
|
+
};
|
|
100
|
+
/** Test-only: forget every cached unit index, so a test that edits files on disk between calls
|
|
101
|
+
* does not need to also fake mtime/size to bust the cache. */
|
|
102
|
+
export declare function clearUnitIndexCache(): void;
|
|
103
|
+
/**
|
|
104
|
+
* The best section-level units for a query, content inline, best first, under a token cap.
|
|
105
|
+
* A hit inside a changelog returns that entry, not the log (rule 17 applied to the interface's
|
|
106
|
+
* own tool). Manifest triggers join each OnDemandMemory file's units at heading weight, so an
|
|
107
|
+
* edited trigger steers recall without touching the file's text.
|
|
108
|
+
*/
|
|
109
|
+
export declare function recall(root: string, manifest: Manifest, query: string, options?: RecallOptions): RecallResult;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP server: read-only tools that serve a compiled workspace's routed OnDemandMemory files
|
|
3
|
+
* at runtime.
|
|
4
|
+
*
|
|
5
|
+
* This is v2 of routing (DESIGN.md section 6). v1, agent-driven, stays the default and keeps
|
|
6
|
+
* working with this server absent: AlwaysOnMemory already tells an agent which OnDemandMemory
|
|
7
|
+
* file to read via its own file tools. This server exists for the two cases the file route
|
|
8
|
+
* cannot cover: hosts with no persistent memory file, and repos with enough OnDemandMemory files
|
|
9
|
+
* that the list itself gets expensive to hold in every prefix.
|
|
10
|
+
*
|
|
11
|
+
* The read-only tools: `recall` (the best memory sections for a task description, content
|
|
12
|
+
* inline, under a token cap), `modules` (the OnDemandMemory list, no content), `outline`
|
|
13
|
+
* (headings only, so an agent can decide before paying for an OnDemandMemory file's body).
|
|
14
|
+
* `scan` and `reorganize` live in scan.ts and reorganize.ts.
|
|
15
|
+
*
|
|
16
|
+
* `recall` ranks section-level units with router.ts (stemmed BM25, heading path and manifest
|
|
17
|
+
* triggers weighted) and returns units, never whole OnDemandMemory files: interface rule O4
|
|
18
|
+
* after the 2026-09-04 audit. `rankOnDemandFiles` below is the older trigger-overlap ranker over
|
|
19
|
+
* the manifest alone; it needs no file content, so `bench` still uses it to model the
|
|
20
|
+
* agent-driven route where an agent reads a whole file the OnDemandMemory list pointed at. Both
|
|
21
|
+
* are deterministic, offline, and call no model.
|
|
22
|
+
*/
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { episodicJsonToMarkdown } from "./episodic.js";
|
|
27
|
+
import { sections } from "./text.js";
|
|
28
|
+
import { COMPILED_DIR_NAME, driftHash } from "./compile.js";
|
|
29
|
+
import { buildSearchIndex, RECALL_MAX_TOKENS, rankUnits, selectUnits, unitsOf } from "./router.js";
|
|
30
|
+
export class McpTargetError extends Error {
|
|
31
|
+
}
|
|
32
|
+
const COMPILED_DIR = COMPILED_DIR_NAME;
|
|
33
|
+
/**
|
|
34
|
+
* An OnDemandMemory file path the manifest is allowed to name: `OnDemandMemory/<name>.md`,
|
|
35
|
+
* nothing else. A cloned repo can ship any manifest it likes, and the server runs with the
|
|
36
|
+
* user's file permissions, so `file` is never trusted as a path (security audit 2026-09-02,
|
|
37
|
+
* finding 1).
|
|
38
|
+
*/
|
|
39
|
+
const ON_DEMAND_FILE = /^OnDemandMemory\/[A-Za-z0-9_][A-Za-z0-9_-]*\.(md|json)$/;
|
|
40
|
+
const OnDemandFileEntrySchema = z.object({
|
|
41
|
+
name: z.string().min(1),
|
|
42
|
+
file: z.string().regex(ON_DEMAND_FILE, "OnDemandMemory file must be OnDemandMemory/<name>.md or .json"),
|
|
43
|
+
tokens: z.number(),
|
|
44
|
+
hash: z.string(),
|
|
45
|
+
triggers: z.array(z.string()),
|
|
46
|
+
sourceLines: z.tuple([z.number(), z.number()]),
|
|
47
|
+
reason: z.string(),
|
|
48
|
+
kind: z.string().optional(),
|
|
49
|
+
generated: z.boolean().optional(),
|
|
50
|
+
});
|
|
51
|
+
const ManifestSchemaV4 = z.object({
|
|
52
|
+
version: z.literal(4),
|
|
53
|
+
tokenizer: z.string(),
|
|
54
|
+
generatedFrom: z.string(),
|
|
55
|
+
sourceHash: z.string(),
|
|
56
|
+
order: z.array(z.string()),
|
|
57
|
+
prefix: z.object({ before: z.number(), after: z.number() }),
|
|
58
|
+
always: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
59
|
+
stub: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
60
|
+
onDemandFiles: z.array(OnDemandFileEntrySchema),
|
|
61
|
+
});
|
|
62
|
+
/** Version 3 shape, kept only so an already-compiled workspace's manifest.json still loads;
|
|
63
|
+
* `init --update` rewrites it as version 4. The one difference is the `modules` key, normalised
|
|
64
|
+
* to `onDemandFiles` below once parsed. */
|
|
65
|
+
const ManifestSchemaV3 = z.object({
|
|
66
|
+
version: z.literal(3),
|
|
67
|
+
tokenizer: z.string(),
|
|
68
|
+
generatedFrom: z.string(),
|
|
69
|
+
sourceHash: z.string(),
|
|
70
|
+
order: z.array(z.string()),
|
|
71
|
+
prefix: z.object({ before: z.number(), after: z.number() }),
|
|
72
|
+
always: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
73
|
+
stub: z.object({ file: z.string(), tokens: z.number(), hash: z.string() }),
|
|
74
|
+
modules: z.array(OnDemandFileEntrySchema),
|
|
75
|
+
});
|
|
76
|
+
/** Load and validate the manifest for a compiled workspace, or throw with a clear fix. Accepts a
|
|
77
|
+
* version 3 manifest (the `modules` key) and normalises it in memory to the version 4 shape
|
|
78
|
+
* (`onDemandFiles`), so a workspace compiled before the vocabulary sweep keeps working. */
|
|
79
|
+
export function loadManifest(root) {
|
|
80
|
+
const manifestPath = path.join(root, COMPILED_DIR, "manifest.json");
|
|
81
|
+
if (!fs.existsSync(manifestPath)) {
|
|
82
|
+
throw new McpTargetError(`no compiled workspace at ${root}\n` +
|
|
83
|
+
` expected ${COMPILED_DIR}/manifest.json - run "minnimemory init --write" first`);
|
|
84
|
+
}
|
|
85
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(raw);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
throw new McpTargetError(`${manifestPath} is not valid JSON: ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
const v4 = ManifestSchemaV4.safeParse(parsed);
|
|
94
|
+
if (v4.success)
|
|
95
|
+
return v4.data;
|
|
96
|
+
const v3 = ManifestSchemaV3.safeParse(parsed);
|
|
97
|
+
if (v3.success) {
|
|
98
|
+
const { modules, version: _version, ...rest } = v3.data;
|
|
99
|
+
return { ...rest, version: 4, onDemandFiles: modules };
|
|
100
|
+
}
|
|
101
|
+
const first = v4.error.issues[0];
|
|
102
|
+
const where = first ? `${first.path.join(".")}: ${first.message}` : "invalid shape";
|
|
103
|
+
throw new McpTargetError(`${manifestPath} is not a valid manifest (${where}) - rerun init`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read one OnDemandMemory file by its manifest entry: the raw bytes on disk and the text the
|
|
107
|
+
* router indexes. They differ for an episodic JSON file, which is served as the markdown it
|
|
108
|
+
* stands for (O3): the manifest hash is over the bytes, so a drift check needs `raw` and the
|
|
109
|
+
* index needs `text`. One read serves both. The path is re-checked here, and the file must
|
|
110
|
+
* resolve (symlinks followed) to somewhere inside the compiled directory, so neither a `..`
|
|
111
|
+
* segment nor a symlinked file can read outside `.minnimemory/`.
|
|
112
|
+
*/
|
|
113
|
+
export function loadOnDemandRaw(root, onDemandFile) {
|
|
114
|
+
if (!ON_DEMAND_FILE.test(onDemandFile)) {
|
|
115
|
+
throw new McpTargetError(`refusing OnDemandMemory path outside OnDemandMemory/: ${onDemandFile}`);
|
|
116
|
+
}
|
|
117
|
+
const compiledDir = path.join(root, COMPILED_DIR);
|
|
118
|
+
const abs = path.join(compiledDir, onDemandFile);
|
|
119
|
+
if (!fs.existsSync(abs)) {
|
|
120
|
+
throw new McpTargetError(`OnDemandMemory file missing on disk: ${onDemandFile} (manifest is stale - rerun init)`);
|
|
121
|
+
}
|
|
122
|
+
const real = fs.realpathSync(abs);
|
|
123
|
+
const realDir = fs.realpathSync(compiledDir);
|
|
124
|
+
if (!real.startsWith(realDir + path.sep)) {
|
|
125
|
+
throw new McpTargetError(`refusing OnDemandMemory file that resolves outside ${COMPILED_DIR}/: ${onDemandFile}`);
|
|
126
|
+
}
|
|
127
|
+
const raw = fs.readFileSync(real, "utf8");
|
|
128
|
+
// O3: a JSON episodic file is served to the router and the agent as the markdown it stands for.
|
|
129
|
+
return { raw, text: onDemandFile.endsWith(".json") ? episodicJsonToMarkdown(raw) : raw };
|
|
130
|
+
}
|
|
131
|
+
/** The text of one OnDemandMemory file as the router and the agent see it. Every existing
|
|
132
|
+
* caller wants only this; the raw bytes matter to the drift check in `getUnitIndex` alone. */
|
|
133
|
+
export function loadOnDemandContent(root, onDemandFile) {
|
|
134
|
+
return loadOnDemandRaw(root, onDemandFile).text;
|
|
135
|
+
}
|
|
136
|
+
// RankedOnDemandFile and rankOnDemandFiles moved to router.js (2026-09-13 audit round 2, D7c): they belong
|
|
137
|
+
// beside the other ranking code, not on the mcp-server-facing module. Re-exported here so
|
|
138
|
+
// bench.ts and every existing `from "./mcp.js"` import keeps working unchanged.
|
|
139
|
+
export { rankOnDemandFiles } from "./router.js";
|
|
140
|
+
/**
|
|
141
|
+
* Heading-only outline of an OnDemandMemory file's content, so an agent can decide before paying
|
|
142
|
+
* for the body.
|
|
143
|
+
* sections() returns a synthetic level-0 "(document)" entry for content with no real heading -
|
|
144
|
+
* that is not a heading an agent should see in an outline, so it is filtered here rather than
|
|
145
|
+
* leaking a fake entry through the tool.
|
|
146
|
+
*/
|
|
147
|
+
export function outlineOf(content) {
|
|
148
|
+
const lines = content.split(/\r?\n/);
|
|
149
|
+
return sections(lines)
|
|
150
|
+
.filter((s) => s.level > 0)
|
|
151
|
+
.map((s) => `${"#".repeat(s.level)} ${s.heading}`);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One cache entry per server root, holding the section-level unit index `recall` ranks against.
|
|
155
|
+
* Rebuilding it means re-reading and re-splitting every OnDemandMemory file and re-running
|
|
156
|
+
* BM25's document-frequency pass over the whole corpus - real work `recall` used to redo on every
|
|
157
|
+
* single call, even from the same still-unchanged workspace within one server session
|
|
158
|
+
* (2026-09-13 audit round 2, D5).
|
|
159
|
+
*/
|
|
160
|
+
const unitIndexCache = new Map();
|
|
161
|
+
/** One OnDemandMemory file's contribution to the cache signature: its manifest hash plus what is
|
|
162
|
+
* actually on disk right now, so an edit made outside `init`/`update` (a hand edit to an
|
|
163
|
+
* OnDemandMemory file) still invalidates the cache even though the manifest itself did not
|
|
164
|
+
* change. */
|
|
165
|
+
function onDemandFileSignature(root, onDemandFile, manifestHash) {
|
|
166
|
+
try {
|
|
167
|
+
const st = fs.statSync(path.join(root, COMPILED_DIR, onDemandFile));
|
|
168
|
+
return `${onDemandFile}:${manifestHash}:${st.mtimeMs}:${st.size}`;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return `${onDemandFile}:${manifestHash}:(missing)`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function manifestSignature(root, manifest) {
|
|
175
|
+
const perFile = manifest.onDemandFiles.map((m) => onDemandFileSignature(root, m.file, m.hash)).join("|");
|
|
176
|
+
return `${manifest.sourceHash}:${manifest.always.hash}:${perFile}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The unit index and units for `manifest` at `root`, rebuilding only when the signature above has
|
|
180
|
+
* changed since the last call for this root. `recall`'s `module` filter is applied to the result
|
|
181
|
+
* of ranking against this cached, whole-workspace index rather than by building a second,
|
|
182
|
+
* file-scoped index per call - one cache entry serves every query, filtered or not.
|
|
183
|
+
* `drifted` names the OnDemandMemory files whose bytes no longer hash to their manifest entry
|
|
184
|
+
* (MM010), computed on the same rebuild and cached alongside the index: with `check` off the
|
|
185
|
+
* compiled surface, `recall` is the tool that has to say a file was hand-edited since init.
|
|
186
|
+
*/
|
|
187
|
+
export function getUnitIndex(root, manifest) {
|
|
188
|
+
const signature = manifestSignature(root, manifest);
|
|
189
|
+
const cached = unitIndexCache.get(root);
|
|
190
|
+
if (cached && cached.signature === signature) {
|
|
191
|
+
return { index: cached.index, units: cached.units, drifted: cached.drifted };
|
|
192
|
+
}
|
|
193
|
+
// A rebuild is the only place drift can be checked cheaply: the content is already being
|
|
194
|
+
// read and parsed here, and a cache hit means nothing on disk moved, so there is no new
|
|
195
|
+
// drift to find. The first call of any session rebuilds, which is what catches drift a
|
|
196
|
+
// previous session left behind. Hash the raw bytes, not the indexed text: an episodic
|
|
197
|
+
// JSON file is indexed as the markdown it stands for, but the manifest hashed the bytes.
|
|
198
|
+
// driftHash, not a raw hash, so a checkout that rewrote line endings is not drift.
|
|
199
|
+
const drifted = [];
|
|
200
|
+
const units = manifest.onDemandFiles.flatMap((m) => {
|
|
201
|
+
const { raw, text } = loadOnDemandRaw(root, m.file);
|
|
202
|
+
if (driftHash(raw) !== m.hash)
|
|
203
|
+
drifted.push(m.file);
|
|
204
|
+
return unitsOf({ name: m.name, file: m.file }, text);
|
|
205
|
+
});
|
|
206
|
+
const index = buildSearchIndex(units, new Map(manifest.onDemandFiles.map((m) => [m.name, m.triggers])));
|
|
207
|
+
unitIndexCache.set(root, { signature, index, units, drifted });
|
|
208
|
+
return { index, units, drifted };
|
|
209
|
+
}
|
|
210
|
+
/** Test-only: forget every cached unit index, so a test that edits files on disk between calls
|
|
211
|
+
* does not need to also fake mtime/size to bust the cache. */
|
|
212
|
+
export function clearUnitIndexCache() {
|
|
213
|
+
unitIndexCache.clear();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The best section-level units for a query, content inline, best first, under a token cap.
|
|
217
|
+
* A hit inside a changelog returns that entry, not the log (rule 17 applied to the interface's
|
|
218
|
+
* own tool). Manifest triggers join each OnDemandMemory file's units at heading weight, so an
|
|
219
|
+
* edited trigger steers recall without touching the file's text.
|
|
220
|
+
*/
|
|
221
|
+
export function recall(root, manifest, query, options = {}) {
|
|
222
|
+
if (options.module !== undefined && !manifest.onDemandFiles.some((m) => m.name === options.module)) {
|
|
223
|
+
const known = manifest.onDemandFiles.map((m) => m.name).join(", ") || "(none)";
|
|
224
|
+
throw new McpTargetError(`unknown OnDemandMemory file "${options.module}". known OnDemandMemory files: ${known}`);
|
|
225
|
+
}
|
|
226
|
+
const { index, drifted } = getUnitIndex(root, manifest);
|
|
227
|
+
const maxTokens = options.maxTokens ?? RECALL_MAX_TOKENS;
|
|
228
|
+
let ranked = rankUnits(index, query);
|
|
229
|
+
if (options.module !== undefined) {
|
|
230
|
+
const wanted = options.module;
|
|
231
|
+
ranked = ranked.filter((r) => r.unit.onDemandFile === wanted);
|
|
232
|
+
}
|
|
233
|
+
const picked = selectUnits(ranked, maxTokens);
|
|
234
|
+
return {
|
|
235
|
+
query,
|
|
236
|
+
unit: "section",
|
|
237
|
+
maxTokens,
|
|
238
|
+
matches: picked.map(({ unit, score }) => ({
|
|
239
|
+
onDemandFile: unit.onDemandFile,
|
|
240
|
+
file: unit.file,
|
|
241
|
+
heading: unit.heading,
|
|
242
|
+
path: unit.path,
|
|
243
|
+
kind: unit.kind,
|
|
244
|
+
startLine: unit.startLine,
|
|
245
|
+
endLine: unit.endLine,
|
|
246
|
+
tokens: unit.tokens,
|
|
247
|
+
score,
|
|
248
|
+
content: unit.content,
|
|
249
|
+
})),
|
|
250
|
+
drifted,
|
|
251
|
+
};
|
|
252
|
+
}
|