@remnic/core 9.3.643 → 9.3.645
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/dist/access-cli.js +12 -12
- package/dist/access-http.js +6 -6
- package/dist/access-mcp.js +5 -5
- package/dist/access-service.js +4 -4
- package/dist/briefing.js +2 -2
- package/dist/causal-consolidation.js +3 -3
- package/dist/{chunk-SZL2GGFY.js → chunk-4BISW7RX.js} +2 -2
- package/dist/{chunk-2GCNTHRA.js → chunk-6YLJZ2EU.js} +12 -12
- package/dist/{chunk-GYITUZFT.js → chunk-A4BTPHIN.js} +171 -134
- package/dist/chunk-A4BTPHIN.js.map +1 -0
- package/dist/{chunk-NV2MGIW7.js → chunk-ALUZN7BE.js} +2 -2
- package/dist/{chunk-RFSCIBCB.js → chunk-AMACWKM4.js} +2 -2
- package/dist/{chunk-S2SEPLLA.js → chunk-BTRL6GG7.js} +3 -3
- package/dist/{chunk-RREI7CNM.js → chunk-D6WVJIS3.js} +9 -42
- package/dist/chunk-D6WVJIS3.js.map +1 -0
- package/dist/{chunk-DM4US77W.js → chunk-IZXOO6NP.js} +5 -3
- package/dist/{chunk-DM4US77W.js.map → chunk-IZXOO6NP.js.map} +1 -1
- package/dist/{chunk-3UR4SBZF.js → chunk-MBT3ASYV.js} +9 -9
- package/dist/{chunk-4HYWWO3W.js → chunk-MCYT2RNT.js} +3 -3
- package/dist/{chunk-THWT7RJU.js → chunk-OR7R6M5Z.js} +2 -2
- package/dist/{chunk-4MNDVDBS.js → chunk-OTC2KOZ2.js} +2 -2
- package/dist/{chunk-TTOYT5RB.js → chunk-PQFUUXWK.js} +2 -2
- package/dist/{chunk-EGBH5IPJ.js → chunk-T22TZJWJ.js} +4 -4
- package/dist/{chunk-4HYLRROV.js → chunk-UMKPSD35.js} +2 -2
- package/dist/{chunk-NLPH23IQ.js → chunk-UMTG2BN2.js} +2 -2
- package/dist/{chunk-AK4DSORQ.js → chunk-W5ISYSIL.js} +4 -4
- package/dist/{chunk-GJSNDJHR.js → chunk-YOXCJP3A.js} +3 -3
- package/dist/{chunk-ECMMM2VE.js → chunk-Z3PZRDLW.js} +2 -2
- package/dist/{chunk-TKNTAUA3.js → chunk-ZXWAQFDE.js} +2 -2
- package/dist/cli.js +15 -15
- package/dist/compounding/engine.js +2 -2
- package/dist/connectors/codex-materialize-runner.js +2 -2
- package/dist/connectors/index.js +2 -2
- package/dist/entity-retrieval.js +2 -2
- package/dist/index.js +23 -24
- package/dist/index.js.map +1 -1
- package/dist/maintenance/memory-governance.js +2 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +2 -2
- package/dist/maintenance/rebuild-memory-projection.js +3 -3
- package/dist/namespaces/migrate.js +3 -3
- package/dist/namespaces/storage.js +2 -2
- package/dist/operator-toolkit.js +5 -5
- package/dist/orchestrator.js +10 -10
- package/dist/schemas.d.ts +22 -22
- package/dist/semantic-consolidation.js +3 -3
- package/dist/semantic-rule-promotion.js +2 -2
- package/dist/semantic-rule-verifier.js +2 -2
- package/dist/storage.js +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/verified-recall.js +2 -2
- package/package.json +1 -1
- package/src/storage-fallback-category-dirs.test.ts +389 -0
- package/src/storage.ts +36 -4
- package/src/utils/category-dir.ts +26 -0
- package/dist/chunk-GYITUZFT.js.map +0 -1
- package/dist/chunk-RREI7CNM.js.map +0 -1
- /package/dist/{chunk-SZL2GGFY.js.map → chunk-4BISW7RX.js.map} +0 -0
- /package/dist/{chunk-2GCNTHRA.js.map → chunk-6YLJZ2EU.js.map} +0 -0
- /package/dist/{chunk-NV2MGIW7.js.map → chunk-ALUZN7BE.js.map} +0 -0
- /package/dist/{chunk-RFSCIBCB.js.map → chunk-AMACWKM4.js.map} +0 -0
- /package/dist/{chunk-S2SEPLLA.js.map → chunk-BTRL6GG7.js.map} +0 -0
- /package/dist/{chunk-3UR4SBZF.js.map → chunk-MBT3ASYV.js.map} +0 -0
- /package/dist/{chunk-4HYWWO3W.js.map → chunk-MCYT2RNT.js.map} +0 -0
- /package/dist/{chunk-THWT7RJU.js.map → chunk-OR7R6M5Z.js.map} +0 -0
- /package/dist/{chunk-4MNDVDBS.js.map → chunk-OTC2KOZ2.js.map} +0 -0
- /package/dist/{chunk-TTOYT5RB.js.map → chunk-PQFUUXWK.js.map} +0 -0
- /package/dist/{chunk-EGBH5IPJ.js.map → chunk-T22TZJWJ.js.map} +0 -0
- /package/dist/{chunk-4HYLRROV.js.map → chunk-UMKPSD35.js.map} +0 -0
- /package/dist/{chunk-NLPH23IQ.js.map → chunk-UMTG2BN2.js.map} +0 -0
- /package/dist/{chunk-AK4DSORQ.js.map → chunk-W5ISYSIL.js.map} +0 -0
- /package/dist/{chunk-GJSNDJHR.js.map → chunk-YOXCJP3A.js.map} +0 -0
- /package/dist/{chunk-ECMMM2VE.js.map → chunk-Z3PZRDLW.js.map} +0 -0
- /package/dist/{chunk-TKNTAUA3.js.map → chunk-ZXWAQFDE.js.map} +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #1497 — Recall: scan all memory category directories when the QMD
|
|
3
|
+
* filesystem fallback reads from disk.
|
|
4
|
+
*
|
|
5
|
+
* The filesystem fallback recall path (orchestrator `recent_scan`) calls
|
|
6
|
+
* `StorageManager.readAllMemories()` per namespace, which scans the disk via
|
|
7
|
+
* `collectActiveMemoryPaths()`. The old implementation only scanned four
|
|
8
|
+
* directories (facts, procedures, reasoning-traces, corrections), so on-disk
|
|
9
|
+
* memories in any other recall category directory (preferences, decisions,
|
|
10
|
+
* moments, commitments, principles, rules, skills, relationships) were silently
|
|
11
|
+
* missed when QMD was unavailable.
|
|
12
|
+
*
|
|
13
|
+
* These tests seed `.md` memory files directly into the on-disk category
|
|
14
|
+
* directories (the way an external seed, a migration, or a future routing
|
|
15
|
+
* change would leave them) and assert the disk-scan recall returns them.
|
|
16
|
+
*
|
|
17
|
+
* They FAIL on the old four-directory behavior and PASS once
|
|
18
|
+
* `collectActiveMemoryPaths()` iterates the shared `RECALL_FALLBACK_DIRS`.
|
|
19
|
+
*
|
|
20
|
+
* PR #1503 review (chatgpt-codex-connector, cursor): `questions/` is NOT a
|
|
21
|
+
* recall memory dir. It holds operational question-QUEUE items written by
|
|
22
|
+
* `writeQuestion()` and surfaced only through the dedicated, disabled-by-default
|
|
23
|
+
* `injectQuestions` pipeline stage. The QMD primary recall corpus excludes them,
|
|
24
|
+
* so the fallback must exclude them too (corpus parity; CLAUDE.md rule #39).
|
|
25
|
+
* `RECALL_FALLBACK_DIRS` = `ALL_CATEGORY_DIRS` minus `RECALL_NON_MEMORY_DIRS`
|
|
26
|
+
* (currently just `questions`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import assert from "node:assert/strict";
|
|
30
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import test from "node:test";
|
|
34
|
+
|
|
35
|
+
import { StorageManager } from "./storage.js";
|
|
36
|
+
import {
|
|
37
|
+
CATEGORY_DIR_MAP,
|
|
38
|
+
RECALL_FALLBACK_DIRS,
|
|
39
|
+
RECALL_NON_MEMORY_DIRS,
|
|
40
|
+
} from "./utils/category-dir.js";
|
|
41
|
+
|
|
42
|
+
/** Build a minimal-but-valid memory markdown file body. */
|
|
43
|
+
function memoryFile(id: string, category: string, content: string): string {
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
return [
|
|
46
|
+
"---",
|
|
47
|
+
`id: ${id}`,
|
|
48
|
+
`category: ${category}`,
|
|
49
|
+
`created: ${now}`,
|
|
50
|
+
`updated: ${now}`,
|
|
51
|
+
"source: test",
|
|
52
|
+
"confidence: 0.9",
|
|
53
|
+
'tags: ["synthetic"]',
|
|
54
|
+
"---",
|
|
55
|
+
"",
|
|
56
|
+
content,
|
|
57
|
+
"",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function seedMemory(
|
|
62
|
+
baseDir: string,
|
|
63
|
+
categoryDir: string,
|
|
64
|
+
id: string,
|
|
65
|
+
category: string,
|
|
66
|
+
content: string,
|
|
67
|
+
options: { nested?: boolean } = {},
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const dir = options.nested
|
|
70
|
+
? path.join(baseDir, categoryDir, "2026-06-29")
|
|
71
|
+
: path.join(baseDir, categoryDir);
|
|
72
|
+
await mkdir(dir, { recursive: true });
|
|
73
|
+
await writeFile(path.join(dir, `${id}.md`), memoryFile(id, category, content), "utf-8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function makeStorage(prefix = "engram-1497-"): Promise<{
|
|
77
|
+
storage: StorageManager;
|
|
78
|
+
baseDir: string;
|
|
79
|
+
cleanup: () => Promise<void>;
|
|
80
|
+
}> {
|
|
81
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
|
82
|
+
const storage = new StorageManager(baseDir);
|
|
83
|
+
await storage.ensureDirectories();
|
|
84
|
+
StorageManager.clearAllStaticCaches();
|
|
85
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
86
|
+
return {
|
|
87
|
+
storage,
|
|
88
|
+
baseDir,
|
|
89
|
+
cleanup: async () => {
|
|
90
|
+
StorageManager.clearAllStaticCaches();
|
|
91
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Singular-category-key -> {dir} for every RECALL category dir the fallback
|
|
98
|
+
* scan must cover. This is `ALL_CATEGORY_DIRS` minus `RECALL_NON_MEMORY_DIRS`
|
|
99
|
+
* (the `questions/` queue dir is intentionally absent — see the file header).
|
|
100
|
+
*/
|
|
101
|
+
const CATEGORY_CASES: ReadonlyArray<{ key: string; dir: string }> = [
|
|
102
|
+
{ key: "fact", dir: "facts" },
|
|
103
|
+
...Object.entries(CATEGORY_DIR_MAP).map(([key, dir]) => ({ key, dir })),
|
|
104
|
+
].filter(({ dir }) => !RECALL_NON_MEMORY_DIRS.has(dir));
|
|
105
|
+
|
|
106
|
+
test("collectActiveMemoryPaths source-of-truth: every RECALL_FALLBACK_DIRS entry is covered", () => {
|
|
107
|
+
// Guards against the dir list drifting away from the shared source of truth.
|
|
108
|
+
const expected = new Set(RECALL_FALLBACK_DIRS);
|
|
109
|
+
const covered = new Set(CATEGORY_CASES.map((c) => c.dir));
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
[...covered].sort(),
|
|
112
|
+
[...expected].sort(),
|
|
113
|
+
"test cases must cover exactly RECALL_FALLBACK_DIRS",
|
|
114
|
+
);
|
|
115
|
+
// questions/ must NOT be a recall fallback dir.
|
|
116
|
+
assert.ok(
|
|
117
|
+
!expected.has("questions"),
|
|
118
|
+
"questions/ is a queue dir and must be excluded from recall fallback",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fallback disk recall returns a memory from EVERY category directory", async () => {
|
|
123
|
+
const { storage, baseDir, cleanup } = await makeStorage();
|
|
124
|
+
try {
|
|
125
|
+
for (const { key, dir } of CATEGORY_CASES) {
|
|
126
|
+
await seedMemory(
|
|
127
|
+
baseDir,
|
|
128
|
+
dir,
|
|
129
|
+
`mem-${key}`,
|
|
130
|
+
key,
|
|
131
|
+
`Synthetic ${key} memory content for issue 1497.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const memories = await storage.readAllMemories();
|
|
136
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
137
|
+
|
|
138
|
+
for (const { key, dir } of CATEGORY_CASES) {
|
|
139
|
+
assert.ok(
|
|
140
|
+
foundIds.has(`mem-${key}`),
|
|
141
|
+
`expected fallback recall to find memory in "${dir}/" (category "${key}"), ` +
|
|
142
|
+
`got ids: ${[...foundIds].join(", ")}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
// Exactly one per category directory — no over-counting / double scan.
|
|
146
|
+
assert.equal(memories.length, CATEGORY_CASES.length);
|
|
147
|
+
} finally {
|
|
148
|
+
await cleanup();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("fallback disk recall scans nested date subdirectories under category dirs", async () => {
|
|
153
|
+
const { storage, baseDir, cleanup } = await makeStorage();
|
|
154
|
+
try {
|
|
155
|
+
// Place files in nested YYYY-MM-DD subdirs for a representative spread of
|
|
156
|
+
// categories, including ones outside the legacy four-dir scan.
|
|
157
|
+
await seedMemory(baseDir, "preferences", "nested-pref", "preference", "Nested pref.", {
|
|
158
|
+
nested: true,
|
|
159
|
+
});
|
|
160
|
+
await seedMemory(baseDir, "decisions", "nested-dec", "decision", "Nested decision.", {
|
|
161
|
+
nested: true,
|
|
162
|
+
});
|
|
163
|
+
await seedMemory(baseDir, "facts", "nested-fact", "fact", "Nested fact.", {
|
|
164
|
+
nested: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const memories = await storage.readAllMemories();
|
|
168
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
169
|
+
|
|
170
|
+
assert.ok(foundIds.has("nested-pref"), "nested preference not found");
|
|
171
|
+
assert.ok(foundIds.has("nested-dec"), "nested decision not found");
|
|
172
|
+
assert.ok(foundIds.has("nested-fact"), "nested fact not found");
|
|
173
|
+
} finally {
|
|
174
|
+
await cleanup();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("missing category directories are ignored without throwing", async () => {
|
|
179
|
+
// Construct a storage WITHOUT calling ensureDirectories(): none of the
|
|
180
|
+
// category dirs exist on disk. The scan must not throw and must return [].
|
|
181
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-missing-"));
|
|
182
|
+
const storage = new StorageManager(baseDir);
|
|
183
|
+
StorageManager.clearAllStaticCaches();
|
|
184
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
185
|
+
try {
|
|
186
|
+
const memories = await storage.readAllMemories();
|
|
187
|
+
assert.deepEqual(memories, []);
|
|
188
|
+
|
|
189
|
+
// Now create exactly one non-legacy dir and confirm partial presence works.
|
|
190
|
+
await seedMemory(baseDir, "rules", "only-rule", "rule", "Only a rule exists.");
|
|
191
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
192
|
+
const after = await storage.readAllMemories();
|
|
193
|
+
assert.equal(after.length, 1);
|
|
194
|
+
assert.equal(after[0]?.frontmatter.id, "only-rule");
|
|
195
|
+
} finally {
|
|
196
|
+
StorageManager.clearAllStaticCaches();
|
|
197
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("non-memory markdown in category dirs is ignored safely (no crash)", async () => {
|
|
202
|
+
const { storage, baseDir, cleanup } = await makeStorage();
|
|
203
|
+
try {
|
|
204
|
+
// A valid memory plus several garbage markdown files in category dirs.
|
|
205
|
+
await seedMemory(baseDir, "skills", "good-skill", "skill", "A valid skill memory.");
|
|
206
|
+
|
|
207
|
+
await mkdir(path.join(baseDir, "principles"), { recursive: true });
|
|
208
|
+
// No frontmatter at all.
|
|
209
|
+
await writeFile(
|
|
210
|
+
path.join(baseDir, "principles", "README.md"),
|
|
211
|
+
"# Just a heading\n\nNo frontmatter here.\n",
|
|
212
|
+
"utf-8",
|
|
213
|
+
);
|
|
214
|
+
// Broken / truncated frontmatter.
|
|
215
|
+
await writeFile(
|
|
216
|
+
path.join(baseDir, "principles", "broken.md"),
|
|
217
|
+
"---\nid: broken\n(no closing delimiter)\n",
|
|
218
|
+
"utf-8",
|
|
219
|
+
);
|
|
220
|
+
// Empty file.
|
|
221
|
+
await writeFile(path.join(baseDir, "principles", "empty.md"), "", "utf-8");
|
|
222
|
+
|
|
223
|
+
const memories = await storage.readAllMemories();
|
|
224
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
225
|
+
assert.ok(foundIds.has("good-skill"), "valid skill memory should be returned");
|
|
226
|
+
assert.ok(!foundIds.has("broken"), "broken markdown must not be parsed as a memory");
|
|
227
|
+
assert.equal(memories.length, 1, "only the one valid memory should be returned");
|
|
228
|
+
} finally {
|
|
229
|
+
await cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("filesystem fallback is namespace-aware across more than one namespace", async () => {
|
|
234
|
+
// Each namespace is a distinct StorageManager baseDir (mirrors how the
|
|
235
|
+
// orchestrator's readAllMemoriesForNamespaces routes per namespace). The fix
|
|
236
|
+
// lives in collectActiveMemoryPaths(), so it must work for ALL baseDirs.
|
|
237
|
+
const nsA = await makeStorage("engram-1497-nsA-");
|
|
238
|
+
const nsB = await makeStorage("engram-1497-nsB-");
|
|
239
|
+
try {
|
|
240
|
+
// Seed non-legacy category dirs in each namespace.
|
|
241
|
+
await seedMemory(nsA.baseDir, "relationships", "rel-a", "relationship", "NS A relationship.");
|
|
242
|
+
await seedMemory(nsA.baseDir, "commitments", "com-a", "commitment", "NS A commitment.");
|
|
243
|
+
await seedMemory(nsB.baseDir, "moments", "mom-b", "moment", "NS B moment.");
|
|
244
|
+
|
|
245
|
+
const a = new Set((await nsA.storage.readAllMemories()).map((m) => m.frontmatter.id));
|
|
246
|
+
const b = new Set((await nsB.storage.readAllMemories()).map((m) => m.frontmatter.id));
|
|
247
|
+
|
|
248
|
+
assert.ok(a.has("rel-a") && a.has("com-a"), "namespace A must surface its memories");
|
|
249
|
+
assert.ok(b.has("mom-b"), "namespace B must surface its memories");
|
|
250
|
+
// No cross-namespace bleed.
|
|
251
|
+
assert.ok(!a.has("mom-b"), "namespace A must not read namespace B");
|
|
252
|
+
assert.ok(!b.has("rel-a") && !b.has("com-a"), "namespace B must not read namespace A");
|
|
253
|
+
} finally {
|
|
254
|
+
await nsA.cleanup();
|
|
255
|
+
await nsB.cleanup();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("non-category content dirs are deliberately EXCLUDED from fallback recall", async () => {
|
|
260
|
+
// Explicit decision (issue #1497): entities/, state/, artifacts/, identity/,
|
|
261
|
+
// config/ and profile.md are NOT standard frontmatter-backed memory files and
|
|
262
|
+
// are intentionally excluded from the category-dir scan. Files placed there
|
|
263
|
+
// must NOT appear in readAllMemories() — they have dedicated read paths.
|
|
264
|
+
const { storage, baseDir, cleanup } = await makeStorage();
|
|
265
|
+
try {
|
|
266
|
+
// A real memory so the corpus is non-empty.
|
|
267
|
+
await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine fact.");
|
|
268
|
+
|
|
269
|
+
// Drop markdown that *looks* like a memory into the excluded dirs.
|
|
270
|
+
for (const excluded of ["entities", "state", "artifacts", "identity", "config"]) {
|
|
271
|
+
await mkdir(path.join(baseDir, excluded), { recursive: true });
|
|
272
|
+
await writeFile(
|
|
273
|
+
path.join(baseDir, excluded, "looks-like-memory.md"),
|
|
274
|
+
memoryFile(`excluded-${excluded}`, "fact", `Should not be recalled (${excluded}).`),
|
|
275
|
+
"utf-8",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
// profile.md at the root is also excluded.
|
|
279
|
+
await writeFile(
|
|
280
|
+
path.join(baseDir, "profile.md"),
|
|
281
|
+
memoryFile("excluded-profile", "fact", "Profile should not be recalled."),
|
|
282
|
+
"utf-8",
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const memories = await storage.readAllMemories();
|
|
286
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
287
|
+
|
|
288
|
+
assert.ok(foundIds.has("real-fact"), "the genuine fact must still be recalled");
|
|
289
|
+
for (const excluded of ["entities", "state", "artifacts", "identity", "config"]) {
|
|
290
|
+
assert.ok(
|
|
291
|
+
!foundIds.has(`excluded-${excluded}`),
|
|
292
|
+
`${excluded}/ must be excluded from fallback recall`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
assert.ok(!foundIds.has("excluded-profile"), "profile.md must be excluded from fallback recall");
|
|
296
|
+
assert.equal(memories.length, 1, "only the one in-category memory should be returned");
|
|
297
|
+
} finally {
|
|
298
|
+
await cleanup();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("question-queue items written by writeQuestion() do NOT leak into fallback recall", async () => {
|
|
303
|
+
// PR #1503 review (chatgpt-codex-connector P2 + cursor): scanning questions/
|
|
304
|
+
// via the category-dir list pulled writeQuestion() queue items
|
|
305
|
+
// (priority/resolved frontmatter) into recall. They are NOT recall memories —
|
|
306
|
+
// they are surfaced only via readQuestions() + the disabled-by-default
|
|
307
|
+
// injectQuestions pipeline. The QMD primary corpus excludes them; the
|
|
308
|
+
// filesystem fallback must too (CLAUDE.md rule #39 corpus parity).
|
|
309
|
+
//
|
|
310
|
+
// This test uses the REAL writeQuestion() path (not a synthetic memory file)
|
|
311
|
+
// so it exercises exactly the frontmatter shape the reviewers flagged. It
|
|
312
|
+
// FAILS on the buggy ALL_CATEGORY_DIRS scan and PASSES after the
|
|
313
|
+
// RECALL_FALLBACK_DIRS fix.
|
|
314
|
+
const { storage, baseDir, cleanup } = await makeStorage();
|
|
315
|
+
try {
|
|
316
|
+
// A genuine recall memory so the corpus is non-empty.
|
|
317
|
+
await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine fact.");
|
|
318
|
+
|
|
319
|
+
// Real operational question-queue items (frontmatter { id, created,
|
|
320
|
+
// priority, resolved }, body `<question>\n\n**Context:** ...`).
|
|
321
|
+
const qId1 = await storage.writeQuestion("What is the user's timezone?", "ctx1", 0.9);
|
|
322
|
+
const qId2 = await storage.writeQuestion("Does the user prefer dark mode?", "ctx2", 0.5);
|
|
323
|
+
|
|
324
|
+
// Queue items remain readable via the dedicated queue surface.
|
|
325
|
+
const queue = await storage.readQuestions();
|
|
326
|
+
const queueIds = new Set(queue.map((q) => q.id));
|
|
327
|
+
assert.ok(queueIds.has(qId1) && queueIds.has(qId2), "readQuestions() must still return the queue");
|
|
328
|
+
|
|
329
|
+
// ...but they must NOT appear in the recall corpus.
|
|
330
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
331
|
+
const memories = await storage.readAllMemories();
|
|
332
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
333
|
+
assert.ok(foundIds.has("real-fact"), "the genuine fact must still be recalled");
|
|
334
|
+
assert.ok(!foundIds.has(qId1), "question-queue item #1 must NOT leak into recall");
|
|
335
|
+
assert.ok(!foundIds.has(qId2), "question-queue item #2 must NOT leak into recall");
|
|
336
|
+
assert.equal(
|
|
337
|
+
memories.length,
|
|
338
|
+
1,
|
|
339
|
+
"only the genuine fact should be recalled (no question-queue leakage)",
|
|
340
|
+
);
|
|
341
|
+
} finally {
|
|
342
|
+
await cleanup();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* QMD-DISABLED config path.
|
|
348
|
+
*
|
|
349
|
+
* A full end-to-end orchestrator recall with QMD disabled requires a large
|
|
350
|
+
* fixture (gateway API stubs, config, search-collection wiring) that the core
|
|
351
|
+
* Node test runner does not bootstrap here. The orchestrator's QMD-unavailable
|
|
352
|
+
* recall fallback (`recent_scan`) routes through
|
|
353
|
+
* `readAllMemoriesForNamespaces(ns)` -> `StorageManager.readAllMemories()` ->
|
|
354
|
+
* `collectActiveMemoryPaths()` (see orchestrator.ts readAllMemoriesForNamespaces
|
|
355
|
+
* and the recent-memory-read step). We therefore exercise that exact collector
|
|
356
|
+
* directly with a config object that explicitly disables QMD, proving the disk
|
|
357
|
+
* scan a QMD-disabled deployment relies on returns every category.
|
|
358
|
+
*/
|
|
359
|
+
test("QMD-disabled deployment: disk-scan collector returns all categories", async () => {
|
|
360
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-qmd-off-"));
|
|
361
|
+
// entitySchemas is the only other constructor arg; QMD is never constructed by
|
|
362
|
+
// StorageManager — the fallback recall path is pure filesystem. This models a
|
|
363
|
+
// deployment configured with `qmdEnabled: false` (no QMD process at all).
|
|
364
|
+
const qmdDisabledConfig = { qmdEnabled: false } as const;
|
|
365
|
+
assert.equal(qmdDisabledConfig.qmdEnabled, false);
|
|
366
|
+
|
|
367
|
+
const storage = new StorageManager(baseDir);
|
|
368
|
+
await storage.ensureDirectories();
|
|
369
|
+
StorageManager.clearAllStaticCaches();
|
|
370
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
371
|
+
try {
|
|
372
|
+
for (const { key, dir } of CATEGORY_CASES) {
|
|
373
|
+
await seedMemory(baseDir, dir, `qmdoff-${key}`, key, `QMD-off ${key} memory.`);
|
|
374
|
+
}
|
|
375
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
376
|
+
|
|
377
|
+
const memories = await storage.readAllMemories();
|
|
378
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
379
|
+
for (const { key, dir } of CATEGORY_CASES) {
|
|
380
|
+
assert.ok(
|
|
381
|
+
foundIds.has(`qmdoff-${key}`),
|
|
382
|
+
`QMD-disabled fallback must read "${dir}/" (category "${key}")`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
StorageManager.clearAllStaticCaches();
|
|
387
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|
package/src/storage.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { log } from "./logger.js";
|
|
6
6
|
import { isErrnoCode } from "./utils/errno.js";
|
|
7
|
+
import { RECALL_FALLBACK_DIRS } from "./utils/category-dir.js";
|
|
7
8
|
import { getCachedEntities, invalidateCachedEntities, setCachedEntities } from "./memory-cache.js";
|
|
8
9
|
import { rotateMarkdownFileToArchive } from "./hygiene.js";
|
|
9
10
|
import { sanitizeMemoryContent } from "./sanitize.js";
|
|
@@ -4011,10 +4012,41 @@ export class StorageManager {
|
|
|
4011
4012
|
}
|
|
4012
4013
|
};
|
|
4013
4014
|
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4015
|
+
// Scan EVERY supported memory category directory, not just the legacy four
|
|
4016
|
+
// (facts/procedures/reasoning-traces/corrections). Issue #1497: the QMD
|
|
4017
|
+
// filesystem-fallback recall path (orchestrator `recent_scan` ->
|
|
4018
|
+
// readAllMemoriesForNamespaces -> readAllMemories -> here) must read every
|
|
4019
|
+
// recall category dir so on-disk memories in preferences/decisions/moments/
|
|
4020
|
+
// commitments/principles/rules/skills/relationships are not missed when QMD
|
|
4021
|
+
// is disabled, missing, or unhealthy. RECALL_FALLBACK_DIRS is the single
|
|
4022
|
+
// source of truth derived from ALL_CATEGORY_DIRS (shared with
|
|
4023
|
+
// ensureDirectories() and the write routing in utils/category-dir.ts).
|
|
4024
|
+
//
|
|
4025
|
+
// These paths resolve identically to the legacy this.factsDir /
|
|
4026
|
+
// this.correctionsDir / this.proceduresDir / this.reasoningTracesDir
|
|
4027
|
+
// getters (all `path.join(this.baseDir, <dir>)`), so the scan stays
|
|
4028
|
+
// namespace-aware: this.baseDir is per-namespace, set by the storage router.
|
|
4029
|
+
//
|
|
4030
|
+
// Deliberately EXCLUDED (issue #1497 + PR #1503 review): the non-category
|
|
4031
|
+
// content dirs that ensureDirectories() also creates — entities/, state/,
|
|
4032
|
+
// artifacts/, identity/, config/ — plus the root profile.md, AND the
|
|
4033
|
+
// questions/ queue dir. questions/ holds operational question-QUEUE items
|
|
4034
|
+
// written by writeQuestion() (frontmatter `{ id, created, priority,
|
|
4035
|
+
// resolved }`), read only via readQuestions() and surfaced through the
|
|
4036
|
+
// dedicated, disabled-by-default `injectQuestions` recall-pipeline stage —
|
|
4037
|
+
// never as standard recall memories. The QMD primary recall corpus does not
|
|
4038
|
+
// include them, so the fallback must not either (corpus parity; CLAUDE.md
|
|
4039
|
+
// rule #39). Were questions/ scanned here, parseFrontmatter() would accept
|
|
4040
|
+
// those files (they have a `---` frontmatter block) and leak queue items
|
|
4041
|
+
// into recall. None of these excluded dirs are in RECALL_FALLBACK_DIRS; the
|
|
4042
|
+
// exclusion is asserted by tests in storage-fallback-category-dirs.test.ts.
|
|
4043
|
+
//
|
|
4044
|
+
// collectPaths() already ignores missing dirs (try/catch) and the parser
|
|
4045
|
+
// returns null for non-memory markdown, so unrelated files never crash the
|
|
4046
|
+
// scan.
|
|
4047
|
+
for (const dir of RECALL_FALLBACK_DIRS) {
|
|
4048
|
+
await collectPaths(path.join(this.baseDir, dir));
|
|
4049
|
+
}
|
|
4018
4050
|
return filePaths;
|
|
4019
4051
|
}
|
|
4020
4052
|
|
|
@@ -28,6 +28,32 @@ export const ALL_CATEGORY_DIRS: string[] = [
|
|
|
28
28
|
...Object.values(CATEGORY_DIR_MAP),
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Category directories whose files are NOT part of the recall memory corpus.
|
|
33
|
+
*
|
|
34
|
+
* `questions/` holds operational question-QUEUE items written by
|
|
35
|
+
* `StorageManager.writeQuestion()` (frontmatter `{ id, created, priority,
|
|
36
|
+
* resolved }`, body `<question>\n\n**Context:** ...`). They are read only via
|
|
37
|
+
* `readQuestions()` and surfaced through the dedicated, disabled-by-default
|
|
38
|
+
* `injectQuestions` recall-pipeline stage (`{"id":"questions","enabled":false}`)
|
|
39
|
+
* — never as standard frontmatter-backed recall memories. The QMD primary
|
|
40
|
+
* recall path treats them the same way (they are not part of the `memories`
|
|
41
|
+
* corpus), so the filesystem fallback must not pull them into recall either.
|
|
42
|
+
*/
|
|
43
|
+
export const RECALL_NON_MEMORY_DIRS: ReadonlySet<string> = new Set(["questions"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Category directories the filesystem-fallback recall scan should read.
|
|
47
|
+
*
|
|
48
|
+
* This is `ALL_CATEGORY_DIRS` minus the non-memory queue dirs in
|
|
49
|
+
* `RECALL_NON_MEMORY_DIRS`. It is the single source of truth for the corpus the
|
|
50
|
+
* QMD-unavailable fallback (`StorageManager.collectActiveMemoryPaths()`) treats
|
|
51
|
+
* as recall memories — kept in parity with the QMD primary recall corpus.
|
|
52
|
+
*/
|
|
53
|
+
export const RECALL_FALLBACK_DIRS: string[] = ALL_CATEGORY_DIRS.filter(
|
|
54
|
+
(dir) => !RECALL_NON_MEMORY_DIRS.has(dir),
|
|
55
|
+
);
|
|
56
|
+
|
|
31
57
|
/** All category keys (singular form) — used when iterating categories and calling getCategoryDir. */
|
|
32
58
|
export const ALL_CATEGORY_KEYS: string[] = [
|
|
33
59
|
"fact",
|