@seanhogg/builderforce-memory-mcp 2026.10.2 → 2026.10.22
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/README.md +22 -5
- package/dist/backend.d.ts +26 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/memory-store.d.ts +50 -2
- package/dist/backends/memory-store.d.ts.map +1 -1
- package/dist/backends/memory-store.js +88 -10
- package/dist/backends/memory-store.js.map +1 -1
- package/dist/bin/http.js +7 -1
- package/dist/bin/http.js.map +1 -1
- package/dist/bin/install.js +6 -3
- package/dist/bin/install.js.map +1 -1
- package/dist/bin/stdio.d.ts +9 -4
- package/dist/bin/stdio.d.ts.map +1 -1
- package/dist/bin/stdio.js +20 -5
- package/dist/bin/stdio.js.map +1 -1
- package/dist/compaction-tools.d.ts +15 -0
- package/dist/compaction-tools.d.ts.map +1 -0
- package/dist/compaction-tools.js +192 -0
- package/dist/compaction-tools.js.map +1 -0
- package/dist/compaction.d.ts +114 -8
- package/dist/compaction.d.ts.map +1 -1
- package/dist/compaction.js +161 -7
- package/dist/compaction.js.map +1 -1
- package/dist/experience/file-store.d.ts +2 -1
- package/dist/experience/file-store.d.ts.map +1 -1
- package/dist/experience/file-store.js +4 -0
- package/dist/experience/file-store.js.map +1 -1
- package/dist/experience/train.d.ts +13 -4
- package/dist/experience/train.d.ts.map +1 -1
- package/dist/experience/train.js +14 -5
- package/dist/experience/train.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/install/claude-hooks.d.ts +5 -0
- package/dist/install/claude-hooks.d.ts.map +1 -1
- package/dist/install/claude-hooks.js +69 -9
- package/dist/install/claude-hooks.js.map +1 -1
- package/dist/install/server-spec.d.ts +33 -7
- package/dist/install/server-spec.d.ts.map +1 -1
- package/dist/install/server-spec.js +59 -9
- package/dist/install/server-spec.js.map +1 -1
- package/dist/model/recall-probe.d.ts +29 -0
- package/dist/model/recall-probe.d.ts.map +1 -0
- package/dist/model/recall-probe.js +72 -0
- package/dist/model/recall-probe.js.map +1 -0
- package/dist/persistence/fact-archive.d.ts +47 -0
- package/dist/persistence/fact-archive.d.ts.map +1 -0
- package/dist/persistence/fact-archive.js +85 -0
- package/dist/persistence/fact-archive.js.map +1 -0
- package/dist/persistence/shared-json-file.d.ts +24 -1
- package/dist/persistence/shared-json-file.d.ts.map +1 -1
- package/dist/persistence/shared-json-file.js +46 -1
- package/dist/persistence/shared-json-file.js.map +1 -1
- package/dist/restore.d.ts +56 -0
- package/dist/restore.d.ts.map +1 -0
- package/dist/restore.js +118 -0
- package/dist/restore.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +11 -90
- package/dist/tools.js.map +1 -1
- package/package.json +6 -6
- package/server.json +2 -2
- package/src/backend.ts +29 -0
- package/src/backends/memory-store.ts +141 -11
- package/src/bin/http.ts +7 -1
- package/src/bin/install.ts +7 -3
- package/src/bin/stdio.ts +20 -5
- package/src/compaction-tools.ts +206 -0
- package/src/compaction.ts +228 -9
- package/src/experience/file-store.ts +5 -0
- package/src/experience/train.ts +26 -9
- package/src/index.ts +55 -10
- package/src/install/claude-hooks.ts +72 -9
- package/src/install/server-spec.ts +74 -8
- package/src/model/recall-probe.ts +81 -0
- package/src/persistence/fact-archive.ts +101 -0
- package/src/persistence/shared-json-file.ts +54 -1
- package/src/restore.ts +144 -0
- package/src/tools.ts +24 -93
package/src/bin/stdio.ts
CHANGED
|
@@ -6,15 +6,20 @@
|
|
|
6
6
|
* BUILDERFORCE_MEMORY_DB IndexedDB database name (default: MemoryStore default).
|
|
7
7
|
* BUILDERFORCE_MEMORY_READONLY '1' to disable remember/forget tools.
|
|
8
8
|
* BUILDERFORCE_MEMORY_FILE Absolute path to a JSON snapshot. Defaults to the
|
|
9
|
-
* shared per-machine store (
|
|
10
|
-
* memory.json
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* shared per-machine store — resolveMemoryFile():
|
|
10
|
+
* ~/.builderforce-memory/memory.json, or the legacy
|
|
11
|
+
* ~/.claude/builderforce-memory/memory.json when that
|
|
12
|
+
* is where the live memory.json is — so memory
|
|
13
|
+
* survives the respawn every MCP client does between
|
|
14
|
+
* sessions. Compacted originals are archived beside it
|
|
15
|
+
* (memory.archive.json); each overwrite keeps .bak.1-3.
|
|
13
16
|
* BUILDERFORCE_MEMORY_MODEL Absolute path to a `.evermind` package (an
|
|
14
17
|
* `evermind-lm`). When set, recall is ranked by SSM
|
|
15
18
|
* embedding cosine FUSED with the lexical ranking
|
|
16
19
|
* instead of lexically alone. Pure CPU — no GPU is
|
|
17
20
|
* involved; see src/embedding/evermind-embedder.ts.
|
|
21
|
+
* Also the model memory_compact's recall probe asks
|
|
22
|
+
* before stubbing a fact; unset = nothing is compacted.
|
|
18
23
|
* BUILDERFORCE_MEMORY_TOKENIZER Tokenizer JSON matching that checkpoint. Defaults
|
|
19
24
|
* to `<model>.tokenizer.json`.
|
|
20
25
|
* BUILDERFORCE_MEMORY_VECTORS Persistent vector-cache path. Defaults beside the
|
|
@@ -34,11 +39,16 @@
|
|
|
34
39
|
|
|
35
40
|
import { createLocalMemoryStoreBackend } from "../backends/memory-store.js";
|
|
36
41
|
import { createRecallEmbedder } from "../embedding/index.js";
|
|
42
|
+
import { readFile } from "node:fs/promises";
|
|
37
43
|
import { createExperienceHost } from "../experience/host.js";
|
|
38
44
|
import { resolveMemoryFile } from "../install/server-spec.js";
|
|
45
|
+
import { lazyRecallProbe } from "../model/recall-probe.js";
|
|
39
46
|
import { runStdio } from "../transports/stdio.js";
|
|
40
47
|
|
|
41
|
-
const
|
|
48
|
+
const log = (line: string) => void process.stderr.write(`${line}\n`);
|
|
49
|
+
// ONE resolver for installer, servers and hook: env, else whichever location holds
|
|
50
|
+
// the live memory.json (logged when there is a choice), else the canonical default.
|
|
51
|
+
const memoryFile = resolveMemoryFile(process.env, undefined, { log });
|
|
42
52
|
|
|
43
53
|
const backend = await createLocalMemoryStoreBackend({
|
|
44
54
|
dbName: process.env["BUILDERFORCE_MEMORY_DB"],
|
|
@@ -56,4 +66,9 @@ await runStdio(backend, {
|
|
|
56
66
|
gatewayApiKey: process.env["BUILDERFORCE_API_KEY"],
|
|
57
67
|
// Null unless BUILDERFORCE_MEMORY_EXPERIENCE=1 — Synapse's opt-in.
|
|
58
68
|
experience: await createExperienceHost({ memoryFile }),
|
|
69
|
+
// memory_compact proves absorption with the configured Evermind before stubbing;
|
|
70
|
+
// no model → nothing is compacted (and the tool says so).
|
|
71
|
+
recallProbe: lazyRecallProbe(),
|
|
72
|
+
// A local subprocess may read the user's own export file for memory_restore.
|
|
73
|
+
readRestoreFile: (path) => readFile(path, "utf8"),
|
|
59
74
|
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lossless compaction pair — `memory_compact` and `memory_restore` — split out
|
|
3
|
+
* of tools.ts the same way the gateway and experience families are.
|
|
4
|
+
*
|
|
5
|
+
* `memory_compact` stubs a fact only after archiving its original and proving the
|
|
6
|
+
* local Evermind absorbed it; `memory_restore` brings back the text of stubs that
|
|
7
|
+
* predate archiving (`contentLost`). The rules are compaction.ts / restore.ts; this
|
|
8
|
+
* module is only their I/O against a MemoryBackend.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import type { MemoryBackend, RecallHit, RememberInput } from "./backend.js";
|
|
13
|
+
import { DEFAULT_STUB_CHARS, planLosslessCompaction, type CompactionSkip } from "./compaction.js";
|
|
14
|
+
import { parseRestoreSource, planRestore, restoredArchive, type RestoreTarget } from "./restore.js";
|
|
15
|
+
import { fail, ok, type MemoryTool } from "./tool-core.js";
|
|
16
|
+
import type { MemoryToolsOptions } from "./tools.js";
|
|
17
|
+
|
|
18
|
+
const SKIP_REASONS: Record<CompactionSkip["reason"], string> = {
|
|
19
|
+
not_found: "not found",
|
|
20
|
+
already_compacted: "already compacted",
|
|
21
|
+
not_smaller: "already shorter than its stub",
|
|
22
|
+
no_probe: "no local Evermind model to prove it was absorbed (set BUILDERFORCE_MEMORY_MODEL)",
|
|
23
|
+
probe_failed: "the Evermind model could not answer a recall question about it — not absorbed yet",
|
|
24
|
+
no_archive: "this backend cannot archive the original, so stubbing would lose it",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** One-line compaction receipt: what shrank, what it saved, and what was left alone. */
|
|
28
|
+
function renderCompaction(compacted: number, bytesSaved: number, skipped: CompactionSkip[]): string {
|
|
29
|
+
const lines = [`Compacted ${compacted} memory(ies), ${bytesSaved} character(s) reclaimed.`];
|
|
30
|
+
for (const s of skipped) lines.push(`• skipped ${s.key} — ${SKIP_REASONS[s.reason]}`);
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `memory_restore` input: inline JSON always; a file path only where the host may read its disk. */
|
|
35
|
+
function restoreSchema(allowFile: boolean): z.ZodRawShape {
|
|
36
|
+
const shape: z.ZodRawShape = {
|
|
37
|
+
json: z.string().optional().describe("The export itself, as JSON text (array, or JSON Lines)."),
|
|
38
|
+
};
|
|
39
|
+
if (allowFile) shape["file"] = z.string().optional().describe("Path to a JSON export file to restore from.");
|
|
40
|
+
return shape;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Write a batch through the backend's batch path when it has one. */
|
|
44
|
+
async function writeAll(backend: MemoryBackend, writes: RememberInput[]): Promise<void> {
|
|
45
|
+
if (writes.length === 0) return;
|
|
46
|
+
if (backend.rememberMany) await backend.rememberMany(writes);
|
|
47
|
+
else for (const w of writes) await backend.remember!(w);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Registers `memory_compact` and `memory_restore`. Caller checks the backend is writable. */
|
|
51
|
+
export function appendCompactionTools(
|
|
52
|
+
tools: MemoryTool[],
|
|
53
|
+
backend: MemoryBackend,
|
|
54
|
+
opts: Pick<MemoryToolsOptions, "recallProbe" | "readRestoreFile">,
|
|
55
|
+
): void {
|
|
56
|
+
// memory_compact — the FIRST-CLASS compaction path. It writes through the live
|
|
57
|
+
// store (get → plan → archive → remember), so a durable backend's in-memory state
|
|
58
|
+
// and its on-disk snapshot agree afterwards. It is LOSSLESS: each original is
|
|
59
|
+
// archived before its body is stubbed, and only facts the local Evermind answers
|
|
60
|
+
// a recall probe about are stubbed at all (see planLosslessCompaction).
|
|
61
|
+
tools.push({
|
|
62
|
+
name: "memory_compact",
|
|
63
|
+
description:
|
|
64
|
+
"Shrink memories that have already been absorbed into the local Evermind model down to " +
|
|
65
|
+
"a one-line pointer stub, reclaiming the context they were eating. Each fact is first " +
|
|
66
|
+
"checked with a recall question put to the model; facts it cannot answer stay as they " +
|
|
67
|
+
"are. The full original is archived and memory_recall / memory_get still return it, so " +
|
|
68
|
+
"nothing is lost. Already-compacted keys are skipped, so repeating the call is safe.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
keys: z.array(z.string()).min(1).describe("Exact keys of the memories to compact."),
|
|
71
|
+
stub: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe(
|
|
75
|
+
"Explicit replacement body applied to every listed key. Omit to generate " +
|
|
76
|
+
"'[absorbed→Evermind vN] <first line>' from each entry's own first line.",
|
|
77
|
+
),
|
|
78
|
+
version: z
|
|
79
|
+
.number()
|
|
80
|
+
.int()
|
|
81
|
+
.min(0)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Version of the model that absorbed these facts; appears in the generated stub."),
|
|
84
|
+
maxChars: z
|
|
85
|
+
.number()
|
|
86
|
+
.int()
|
|
87
|
+
.min(20)
|
|
88
|
+
.max(500)
|
|
89
|
+
.optional()
|
|
90
|
+
.describe(`Max characters of the generated pointer line (default ${DEFAULT_STUB_CHARS}).`),
|
|
91
|
+
},
|
|
92
|
+
handler: async (args) => {
|
|
93
|
+
try {
|
|
94
|
+
const raw = args["keys"];
|
|
95
|
+
const keys = Array.isArray(raw) ? raw.map((k) => String(k)).filter(Boolean) : [];
|
|
96
|
+
if (keys.length === 0) return fail("keys is required.");
|
|
97
|
+
|
|
98
|
+
// ONE read pass: the hit carries both the body the plan reasons about
|
|
99
|
+
// and the tags/importance the rewrite must preserve. A stub resolved to
|
|
100
|
+
// its archive reports the STORED stub in `hit.stub` — that is what the
|
|
101
|
+
// plan must see, or an archived fact would be compacted again.
|
|
102
|
+
const hits = new Map<string, RecallHit>();
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const hit = await backend.get(key);
|
|
105
|
+
if (hit) hits.set(key, hit);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const plan = await planLosslessCompaction(
|
|
109
|
+
keys.map((key) => {
|
|
110
|
+
const hit = hits.get(key);
|
|
111
|
+
return { key, content: hit ? (hit.stub ?? hit.content) : undefined };
|
|
112
|
+
}),
|
|
113
|
+
{
|
|
114
|
+
stub: typeof args["stub"] === "string" ? (args["stub"] as string) : undefined,
|
|
115
|
+
version: args["version"] == null ? undefined : Number(args["version"]),
|
|
116
|
+
maxChars: args["maxChars"] == null ? undefined : Number(args["maxChars"]),
|
|
117
|
+
probe: opts.recallProbe ?? null,
|
|
118
|
+
canArchive: typeof backend.archive === "function",
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (plan.writes.length > 0) {
|
|
123
|
+
// Originals FIRST, durably — only then may the bodies become stubs.
|
|
124
|
+
await backend.archive!(plan.writes.map((w) => w.archive));
|
|
125
|
+
// Preserve tags/importance — compaction shortens a body, it does not
|
|
126
|
+
// reclassify the fact.
|
|
127
|
+
await writeAll(
|
|
128
|
+
backend,
|
|
129
|
+
plan.writes.map((w) => {
|
|
130
|
+
const prev = hits.get(w.key);
|
|
131
|
+
return { key: w.key, content: w.content, tags: prev?.tags, importance: prev?.importance };
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return ok(renderCompaction(plan.writes.length, plan.bytesSaved, plan.skipped));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return fail(`compact failed: ${String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// memory_restore — the way back for stubs whose original was never archived
|
|
144
|
+
// (`contentLost`). The text is matched to a stub by key, or by its first line
|
|
145
|
+
// against the stub's pointer, then archived beside the stub (the stub stays, so
|
|
146
|
+
// the token saving stays) — or written back as the body on a backend that
|
|
147
|
+
// cannot archive.
|
|
148
|
+
tools.push({
|
|
149
|
+
name: "memory_restore",
|
|
150
|
+
description:
|
|
151
|
+
"Restore the full text of memories marked '(content lost)' — compaction stubs whose " +
|
|
152
|
+
"original was never kept — from an export. Accepts [{key, content}] (a memory export) " +
|
|
153
|
+
"or the Evermind coordinator's [{id, text}] record dump; records are matched by key, " +
|
|
154
|
+
"else by their first line against the stub. Restored memories recall in full again.",
|
|
155
|
+
inputSchema: restoreSchema(!!opts.readRestoreFile),
|
|
156
|
+
handler: async (args) => {
|
|
157
|
+
try {
|
|
158
|
+
const file = typeof args["file"] === "string" ? (args["file"] as string) : "";
|
|
159
|
+
let text = typeof args["json"] === "string" ? (args["json"] as string) : "";
|
|
160
|
+
if (file && opts.readRestoreFile) text = await opts.readRestoreFile(file);
|
|
161
|
+
if (!text.trim()) return fail(opts.readRestoreFile ? "file or json is required." : "json is required.");
|
|
162
|
+
|
|
163
|
+
const records = parseRestoreSource(text);
|
|
164
|
+
if (records.length === 0) return fail("no restorable records (need {key, content} or {id, text} rows).");
|
|
165
|
+
|
|
166
|
+
// Targets: the lost stubs (all of them when the backend can list them,
|
|
167
|
+
// else just the keys the records name).
|
|
168
|
+
const keys = new Set<string>(backend.lostKeys ? await backend.lostKeys() : []);
|
|
169
|
+
for (const r of records) if (r.key) keys.add(r.key);
|
|
170
|
+
const targets: RestoreTarget[] = [];
|
|
171
|
+
const hits = new Map<string, RecallHit>();
|
|
172
|
+
for (const key of keys) {
|
|
173
|
+
const hit = await backend.get(key);
|
|
174
|
+
if (!hit?.contentLost) continue;
|
|
175
|
+
hits.set(key, hit);
|
|
176
|
+
targets.push({ key, stub: hit.content });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const plan = planRestore(records, targets);
|
|
180
|
+
if (plan.matches.length > 0) {
|
|
181
|
+
if (backend.archive) {
|
|
182
|
+
await backend.archive(plan.matches.map((m) => restoredArchive(m, hits.get(m.key)!.content)));
|
|
183
|
+
} else {
|
|
184
|
+
await writeAll(
|
|
185
|
+
backend,
|
|
186
|
+
plan.matches.map((m) => {
|
|
187
|
+
const prev = hits.get(m.key);
|
|
188
|
+
return { key: m.key, content: m.content, tags: prev?.tags, importance: prev?.importance };
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const lines = [
|
|
195
|
+
`Restored ${plan.matches.length} memory(ies) of ${targets.length} with lost content.`,
|
|
196
|
+
...plan.matches.map((m) => `• ${m.key} (matched by ${m.via})`),
|
|
197
|
+
];
|
|
198
|
+
if (plan.ambiguous.length > 0) lines.push(`Ambiguous (left alone): ${plan.ambiguous.join(", ")}`);
|
|
199
|
+
if (plan.unmatched > 0) lines.push(`${plan.unmatched} record(s) matched no lost memory.`);
|
|
200
|
+
return ok(lines.join("\n"));
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return fail(`restore failed: ${String(err)}`);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/compaction.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compaction — turning an ABSORBED memory into a terse pointer stub.
|
|
2
|
+
* Compaction — turning an ABSORBED memory into a terse pointer stub, LOSSLESSLY.
|
|
3
3
|
*
|
|
4
4
|
* Once a fact has been folded into an Evermind (or any downstream learner) the
|
|
5
5
|
* full body has done its job: keeping it verbatim in the store means every
|
|
@@ -7,10 +7,24 @@
|
|
|
7
7
|
* Compaction replaces the body with a one-line `[absorbed→Evermind vN] <first
|
|
8
8
|
* line>` pointer, so the fact stops filling context while it stays findable.
|
|
9
9
|
*
|
|
10
|
+
* Two rules make that safe:
|
|
11
|
+
* 1. The full original is ARCHIVED before the body is stubbed — in a sidecar
|
|
12
|
+
* `<memory>.archive.json` beside the snapshot, never inside it, so it survives
|
|
13
|
+
* the snapshot round-trip, an external rewrite of the snapshot, and the
|
|
14
|
+
* store's entry cap / eviction. Recall resolves a stub back to its archive.
|
|
15
|
+
* 2. A fact is stubbed only after a RECALL PROBE passes: a question generated
|
|
16
|
+
* from the fact is put to the local Evermind model, and an answer that does
|
|
17
|
+
* not carry the fact (or no model at all) leaves the fact uncompacted —
|
|
18
|
+
* "absorbed" is proven, not asserted.
|
|
19
|
+
*
|
|
20
|
+
* A stub with no archive is a fact whose text is gone (compacted before rule 1
|
|
21
|
+
* existed): {@link isContentLost} names it, and every reader labels it with
|
|
22
|
+
* {@link CONTENT_LOST_LABEL} until `memory_restore` brings the text back.
|
|
23
|
+
*
|
|
10
24
|
* The rules live here — not in the tool handler and not in a transport — because
|
|
11
25
|
* BOTH the in-process `memory_compact` tool and any external compactor (the
|
|
12
|
-
* BuilderForce VS Code extension
|
|
13
|
-
*
|
|
26
|
+
* BuilderForce VS Code extension) must produce byte-identical stubs and the same
|
|
27
|
+
* archive; otherwise re-running either one re-stubs the other's work.
|
|
14
28
|
*
|
|
15
29
|
* Dependency-free and published as its own subpath
|
|
16
30
|
* (`@seanhogg/builderforce-memory-mcp/compaction`), so an external compactor imports
|
|
@@ -48,25 +62,60 @@ export function memoryStub(content: string, version: number, maxChars = DEFAULT_
|
|
|
48
62
|
return `${STUB_PREFIX} v${version}] ${firstLine(content, maxChars)}`;
|
|
49
63
|
}
|
|
50
64
|
|
|
65
|
+
/** The pointer text inside a stub (after the `] `), without a trailing ellipsis. */
|
|
66
|
+
export function stubPointer(stub: string): string {
|
|
67
|
+
const s = stub.trim();
|
|
68
|
+
const close = s.indexOf("] ");
|
|
69
|
+
const pointer = isStub(s) && close >= 0 ? s.slice(close + 2) : s;
|
|
70
|
+
return pointer.replace(/…$/, "").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
/** One entry considered for compaction. */
|
|
52
74
|
export interface CompactionCandidate {
|
|
53
75
|
key: string;
|
|
54
76
|
content: string;
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The full original of a compacted fact — what the archive keeps so a stub is
|
|
81
|
+
* never the only copy. Written BEFORE the stub replaces the body.
|
|
82
|
+
*/
|
|
83
|
+
export interface ArchivedFact {
|
|
84
|
+
key: string;
|
|
85
|
+
/** The original, full body. */
|
|
86
|
+
content: string;
|
|
87
|
+
/** The stub that replaced it. */
|
|
88
|
+
stub: string;
|
|
89
|
+
/** Evermind version that absorbed it. */
|
|
90
|
+
version: number;
|
|
91
|
+
/** Unix ms the archive row was written. */
|
|
92
|
+
archivedAt: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A body rewrite the caller should apply — archive `archive` FIRST, then write `content`. */
|
|
58
96
|
export interface CompactionWrite {
|
|
59
97
|
key: string;
|
|
60
98
|
/** The stub that replaces the entry's body. */
|
|
61
99
|
content: string;
|
|
62
100
|
/** Characters recovered by this rewrite. */
|
|
63
101
|
bytesSaved: number;
|
|
102
|
+
/** The original body, to be archived before the stub is written. */
|
|
103
|
+
archive: ArchivedFact;
|
|
64
104
|
}
|
|
65
105
|
|
|
66
106
|
/** Why a candidate was left alone. */
|
|
67
107
|
export interface CompactionSkip {
|
|
68
108
|
key: string;
|
|
69
|
-
reason:
|
|
109
|
+
reason:
|
|
110
|
+
| "not_found"
|
|
111
|
+
| "already_compacted"
|
|
112
|
+
| "not_smaller"
|
|
113
|
+
/** No local model to probe with — the fact cannot be proven absorbed. */
|
|
114
|
+
| "no_probe"
|
|
115
|
+
/** The model could not answer the probe question generated from the fact. */
|
|
116
|
+
| "probe_failed"
|
|
117
|
+
/** The caller cannot archive the original, so stubbing would lose it. */
|
|
118
|
+
| "no_archive";
|
|
70
119
|
}
|
|
71
120
|
|
|
72
121
|
/** The decided plan: what to write, what to leave, and the total recovered. */
|
|
@@ -84,18 +133,23 @@ export interface CompactionOptions {
|
|
|
84
133
|
version?: number;
|
|
85
134
|
/** Max characters of a generated pointer line. Default {@link DEFAULT_STUB_CHARS}. */
|
|
86
135
|
maxChars?: number;
|
|
136
|
+
/** Clock for {@link ArchivedFact.archivedAt}. Default `Date.now()`. */
|
|
137
|
+
now?: number;
|
|
87
138
|
}
|
|
88
139
|
|
|
89
140
|
/**
|
|
90
|
-
* Decide the compaction for a set of candidates
|
|
91
|
-
*
|
|
92
|
-
*
|
|
141
|
+
* Decide the STRUCTURAL compaction for a set of candidates: what is already a
|
|
142
|
+
* stub, what would not shrink. Pure — no I/O — and NOT the whole contract: a
|
|
143
|
+
* caller must still archive each write's `archive` before applying it, and prove
|
|
144
|
+
* absorption — {@link planLosslessCompaction} runs this and then the probe.
|
|
145
|
+
* `undefined` content means the key was not found.
|
|
93
146
|
*/
|
|
94
147
|
export function planCompaction(
|
|
95
148
|
candidates: Array<CompactionCandidate | { key: string; content: undefined }>,
|
|
96
149
|
opts: CompactionOptions = {},
|
|
97
150
|
): CompactionPlan {
|
|
98
151
|
const version = opts.version ?? 0;
|
|
152
|
+
const now = opts.now ?? Date.now();
|
|
99
153
|
const maxChars = Math.max(20, opts.maxChars ?? DEFAULT_STUB_CHARS);
|
|
100
154
|
const writes: CompactionWrite[] = [];
|
|
101
155
|
const skipped: CompactionSkip[] = [];
|
|
@@ -118,9 +172,174 @@ export function planCompaction(
|
|
|
118
172
|
continue;
|
|
119
173
|
}
|
|
120
174
|
const saved = content.length - stub.length;
|
|
121
|
-
writes.push({
|
|
175
|
+
writes.push({
|
|
176
|
+
key: c.key,
|
|
177
|
+
content: stub,
|
|
178
|
+
bytesSaved: saved,
|
|
179
|
+
archive: { key: c.key, content, stub, version, archivedAt: now },
|
|
180
|
+
});
|
|
122
181
|
bytesSaved += saved;
|
|
123
182
|
}
|
|
124
183
|
|
|
125
184
|
return { writes, skipped, bytesSaved };
|
|
126
185
|
}
|
|
186
|
+
|
|
187
|
+
// ── Recall probe ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The local model compaction asks before it stubs a fact. `answer` returns the
|
|
191
|
+
* model's text for a question, or null when it cannot answer at all.
|
|
192
|
+
*/
|
|
193
|
+
export interface RecallProbe {
|
|
194
|
+
answer(question: string): Promise<string | null>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A question generated from one fact, and the token a correct answer must carry. */
|
|
198
|
+
export interface ProbeQuestion {
|
|
199
|
+
question: string;
|
|
200
|
+
expected: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const PROBE_BLANK = "____";
|
|
204
|
+
|
|
205
|
+
/** Tokens of a line that a cloze question can blank: alphanumeric, 4+ chars. */
|
|
206
|
+
function probeTokens(line: string): string[] {
|
|
207
|
+
return (line.match(/[A-Za-z0-9][A-Za-z0-9_.-]*[A-Za-z0-9]/g) ?? []).filter((t) => t.length >= 4);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** How identifying a token is: digits and capitals beat length (a name/version IS the fact). */
|
|
211
|
+
function salience(token: string): number {
|
|
212
|
+
return (/[0-9]/.test(token) ? 100 : 0) + (/[A-Z]/.test(token) ? 50 : 0) + token.length;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A cloze question over the fact's pointer line: its most identifying token is
|
|
217
|
+
* blanked and the model must supply it. Deterministic, so the same fact always
|
|
218
|
+
* gets the same question. Null when the line has nothing worth blanking.
|
|
219
|
+
*/
|
|
220
|
+
export function probeQuestionFor(key: string, content: string): ProbeQuestion | null {
|
|
221
|
+
const line = firstLine(content, 400);
|
|
222
|
+
const tokens = probeTokens(line);
|
|
223
|
+
if (tokens.length === 0) return null;
|
|
224
|
+
let expected = tokens[0]!;
|
|
225
|
+
for (const t of tokens) if (salience(t) > salience(expected)) expected = t;
|
|
226
|
+
const at = line.indexOf(expected);
|
|
227
|
+
const cloze = `${line.slice(0, at)}${PROBE_BLANK}${line.slice(at + expected.length)}`;
|
|
228
|
+
return {
|
|
229
|
+
question: `Stored memory "${key}": ${cloze}\nThe missing word (${PROBE_BLANK}) is:`,
|
|
230
|
+
expected,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True when the model's answer carries the blanked token (case-insensitive). */
|
|
235
|
+
export function probePasses(answer: string | null | undefined, expected: string): boolean {
|
|
236
|
+
if (typeof answer !== "string" || !expected) return false;
|
|
237
|
+
return answer.toLowerCase().includes(expected.toLowerCase());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Options for {@link planLosslessCompaction}. */
|
|
241
|
+
export interface LosslessCompactionOptions extends CompactionOptions {
|
|
242
|
+
/** The local model to prove absorption with. Null/absent → nothing is stubbed. */
|
|
243
|
+
probe?: RecallProbe | null;
|
|
244
|
+
/** False when the caller cannot archive originals → nothing is stubbed. Default true. */
|
|
245
|
+
canArchive?: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The full compaction contract: {@link planCompaction}'s structural rules, then a
|
|
250
|
+
* recall probe per surviving write. A write is kept only when the model answers
|
|
251
|
+
* the question generated from its fact; otherwise the fact stays uncompacted and
|
|
252
|
+
* the reason is recorded. The caller then archives every write's `archive` BEFORE
|
|
253
|
+
* applying its stub.
|
|
254
|
+
*/
|
|
255
|
+
export async function planLosslessCompaction(
|
|
256
|
+
candidates: Array<CompactionCandidate | { key: string; content: undefined }>,
|
|
257
|
+
opts: LosslessCompactionOptions = {},
|
|
258
|
+
): Promise<CompactionPlan> {
|
|
259
|
+
const structural = planCompaction(candidates, opts);
|
|
260
|
+
const writes: CompactionWrite[] = [];
|
|
261
|
+
const skipped: CompactionSkip[] = [...structural.skipped];
|
|
262
|
+
for (const w of structural.writes) {
|
|
263
|
+
if (opts.canArchive === false) {
|
|
264
|
+
skipped.push({ key: w.key, reason: "no_archive" });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!opts.probe) {
|
|
268
|
+
skipped.push({ key: w.key, reason: "no_probe" });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const q = probeQuestionFor(w.key, w.archive.content);
|
|
272
|
+
let answer: string | null = null;
|
|
273
|
+
if (q) {
|
|
274
|
+
try {
|
|
275
|
+
answer = await opts.probe.answer(q.question);
|
|
276
|
+
} catch {
|
|
277
|
+
answer = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!q || !probePasses(answer, q.expected)) {
|
|
281
|
+
skipped.push({ key: w.key, reason: "probe_failed" });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
writes.push(w);
|
|
285
|
+
}
|
|
286
|
+
return { writes, skipped, bytesSaved: writes.reduce((n, w) => n + w.bytesSaved, 0) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Archive + lost content ───────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Label every reader attaches to a stub whose original text no longer exists
|
|
293
|
+
* anywhere (compacted before archiving existed).
|
|
294
|
+
*/
|
|
295
|
+
export const CONTENT_LOST_LABEL = "(content lost — re-verify before relying on it)";
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Where the archive lives: beside the snapshot, under the same name — the same
|
|
299
|
+
* convention as the vector cache (`memory.json` → `memory.archive.json`).
|
|
300
|
+
*/
|
|
301
|
+
export function defaultArchiveFile(memoryFile: string): string {
|
|
302
|
+
return memoryFile.replace(/\.json$/i, "") + ".archive.json";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** True when `content` is a stub AND no archived original exists for it. */
|
|
306
|
+
export function isContentLost(content: string, archived: ArchivedFact | undefined): boolean {
|
|
307
|
+
return isStub(content) && !archived;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** On-disk archive: key → the latest archived original for that key. Never pruned. */
|
|
311
|
+
export interface ArchiveSnapshot {
|
|
312
|
+
version: 1;
|
|
313
|
+
facts: Record<string, ArchivedFact>;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Parse archive text; an absent/corrupt file is an empty archive. */
|
|
317
|
+
export function parseArchive(text: string | null | undefined): Record<string, ArchivedFact> {
|
|
318
|
+
if (!text) return {};
|
|
319
|
+
let parsed: Partial<ArchiveSnapshot> | null;
|
|
320
|
+
try {
|
|
321
|
+
parsed = JSON.parse(text) as Partial<ArchiveSnapshot> | null;
|
|
322
|
+
} catch {
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
const facts = parsed && typeof parsed === "object" ? parsed.facts : undefined;
|
|
326
|
+
if (!facts || typeof facts !== "object") return {};
|
|
327
|
+
// Null prototype: a memory key is arbitrary text, "__proto__" included.
|
|
328
|
+
const out: Record<string, ArchivedFact> = Object.create(null) as Record<string, ArchivedFact>;
|
|
329
|
+
for (const [key, f] of Object.entries(facts)) {
|
|
330
|
+
if (f && typeof f.content === "string") out[key] = { ...f, key };
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Merge `facts` into archive text, returning the next text. Rows are only ever
|
|
337
|
+
* added or replaced by a newer archive of the same key — never removed — so a
|
|
338
|
+
* forgotten or evicted stub keeps its original.
|
|
339
|
+
*/
|
|
340
|
+
export function mergeArchive(text: string | null | undefined, facts: ArchivedFact[]): string {
|
|
341
|
+
const all = parseArchive(text);
|
|
342
|
+
for (const f of facts) all[f.key] = f;
|
|
343
|
+
const snap: ArchiveSnapshot = { version: 1, facts: all };
|
|
344
|
+
return `${JSON.stringify(snap, null, 2)}\n`;
|
|
345
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
13
|
Adaptation,
|
|
14
|
+
CurationRecord,
|
|
14
15
|
Episode,
|
|
15
16
|
EpisodeSummary,
|
|
16
17
|
ExperienceSnapshot,
|
|
@@ -129,6 +130,10 @@ export class FileExperienceStore implements ExperienceStore {
|
|
|
129
130
|
this.fresh();
|
|
130
131
|
await this.inner.recordAdaptation(adaptation);
|
|
131
132
|
}
|
|
133
|
+
async recordCuration(curation: CurationRecord): Promise<void> {
|
|
134
|
+
this.fresh();
|
|
135
|
+
await this.inner.recordCuration(curation);
|
|
136
|
+
}
|
|
132
137
|
|
|
133
138
|
async snapshot(): Promise<ExperienceSnapshot> {
|
|
134
139
|
this.fresh();
|
package/src/experience/train.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type {
|
|
18
18
|
adaptPackage,
|
|
19
19
|
curatePendingExperience,
|
|
20
|
+
curationRecords,
|
|
20
21
|
experienceDocuments,
|
|
21
22
|
packDocuments,
|
|
22
23
|
CurationResult,
|
|
@@ -36,10 +37,16 @@ export interface TrainEngine {
|
|
|
36
37
|
/** The opt-in curation step and the CPU embedder it scores with (engine ≥ 2026.10.2). */
|
|
37
38
|
curatePendingExperience?: typeof curatePendingExperience;
|
|
38
39
|
EvermindTextEmbedder?: typeof EvermindTextEmbedder;
|
|
40
|
+
/** The `curated_out` records to persist for a curation (engine ≥ 2026.10.3). */
|
|
41
|
+
curationRecords?: typeof curationRecords;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
44
|
+
/**
|
|
45
|
+
* What curation left out of a pass, when it ran: `dropped` this call, and
|
|
46
|
+
* `alreadyDropped` — left out by an earlier call, verdict still standing, so skipped
|
|
47
|
+
* without being embedded again.
|
|
48
|
+
*/
|
|
49
|
+
export type CurationReport = { dropped: CurationResult["dropped"]; alreadyDropped?: CurationResult["dropped"] };
|
|
43
50
|
|
|
44
51
|
export type TrainOutcome =
|
|
45
52
|
| { status: "nothing_new"; pending: 0; version: string }
|
|
@@ -103,8 +110,10 @@ export interface TrainExperienceOptions {
|
|
|
103
110
|
* store's run outcomes, + near-duplicate removal against the batch AND the
|
|
104
111
|
* recently learned procedures — docs/ml-task-catalog.md §4.2), scoring with the
|
|
105
112
|
* model's own CPU embedder. Default false ⇒ every pending procedure is learned,
|
|
106
|
-
* exactly as before. A procedure curation drops is NOT recorded as learned
|
|
107
|
-
*
|
|
113
|
+
* exactly as before. A procedure curation drops is NOT recorded as learned; it is
|
|
114
|
+
* recorded as `curated_out` (with the id it duplicates, or its quality score), and
|
|
115
|
+
* later curations skip it while that verdict stands — a duplicate until what it
|
|
116
|
+
* repeats is no longer learned, a low-quality one until it has new runs.
|
|
108
117
|
*/
|
|
109
118
|
curate?: boolean;
|
|
110
119
|
}
|
|
@@ -126,15 +135,20 @@ export async function trainExperience(
|
|
|
126
135
|
|
|
127
136
|
let docs = pending;
|
|
128
137
|
let curation: CurationReport | undefined;
|
|
138
|
+
/** The LM curation embedded with — untrained, loaded from `model.pkg` — reused by the first pass. */
|
|
139
|
+
let curationLm: ConstructorParameters<typeof EvermindTextEmbedder>[0] | undefined;
|
|
129
140
|
if (opts.curate) {
|
|
130
141
|
if (!engine.curatePendingExperience || !engine.EvermindTextEmbedder) throw new Error(CURATION_UNSUPPORTED);
|
|
131
142
|
// The model being trained embeds for curation: its own notion of "the same procedure".
|
|
132
|
-
|
|
143
|
+
// Embedding never changes its weights, so the same LM is the first pass's copy.
|
|
144
|
+
curationLm = model.pkg.loadLM() as ConstructorParameters<typeof EvermindTextEmbedder>[0];
|
|
133
145
|
const codec = model.codec as ConstructorParameters<typeof EvermindTextEmbedder>[1];
|
|
134
|
-
const embedder = new engine.EvermindTextEmbedder(
|
|
146
|
+
const embedder = new engine.EvermindTextEmbedder(curationLm, codec);
|
|
135
147
|
const r = await engine.curatePendingExperience(pending, snap, { embedder, known });
|
|
136
148
|
docs = r.kept;
|
|
137
|
-
curation = { dropped: r.dropped };
|
|
149
|
+
curation = { dropped: r.dropped, ...(r.alreadyDropped?.length ? { alreadyDropped: r.alreadyDropped } : {}) };
|
|
150
|
+
// Persist the verdicts, so the next call skips these instead of re-embedding them.
|
|
151
|
+
if (engine.curationRecords) await store.recordCuration(engine.curationRecords(r, snap, Date.now()));
|
|
138
152
|
}
|
|
139
153
|
if (docs.length === 0) return { status: "curated_out", pending: pending.length, version, curation: curation ?? { dropped: [] } };
|
|
140
154
|
|
|
@@ -145,8 +159,11 @@ export async function trainExperience(
|
|
|
145
159
|
let lossSum = 0;
|
|
146
160
|
let passes = 0;
|
|
147
161
|
for (const pass of engine.packDocuments(docs, engine.EVERMIND_ADAPT_MAX_CHARS)) {
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
// Until a pass succeeds `pkg` is still `model.pkg`, so curation's untrained LM is its copy.
|
|
163
|
+
const lm = pkg === (model.pkg as unknown) ? curationLm : undefined;
|
|
164
|
+
const r = engine.adaptPackage(pkg, model.codec, pass.map((d) => d.text).join("\n\n"), next, lm ? { lm } : {});
|
|
165
|
+
if (!r) continue; // no trainable window: the LM was never touched
|
|
166
|
+
curationLm = undefined; // trained in place — spent
|
|
150
167
|
pkg = r.pkg;
|
|
151
168
|
lossSum += r.loss;
|
|
152
169
|
passes++;
|