@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.
Files changed (73) hide show
  1. package/dist/access-cli.js +12 -12
  2. package/dist/access-http.js +6 -6
  3. package/dist/access-mcp.js +5 -5
  4. package/dist/access-service.js +4 -4
  5. package/dist/briefing.js +2 -2
  6. package/dist/causal-consolidation.js +3 -3
  7. package/dist/{chunk-SZL2GGFY.js → chunk-4BISW7RX.js} +2 -2
  8. package/dist/{chunk-2GCNTHRA.js → chunk-6YLJZ2EU.js} +12 -12
  9. package/dist/{chunk-GYITUZFT.js → chunk-A4BTPHIN.js} +171 -134
  10. package/dist/chunk-A4BTPHIN.js.map +1 -0
  11. package/dist/{chunk-NV2MGIW7.js → chunk-ALUZN7BE.js} +2 -2
  12. package/dist/{chunk-RFSCIBCB.js → chunk-AMACWKM4.js} +2 -2
  13. package/dist/{chunk-S2SEPLLA.js → chunk-BTRL6GG7.js} +3 -3
  14. package/dist/{chunk-RREI7CNM.js → chunk-D6WVJIS3.js} +9 -42
  15. package/dist/chunk-D6WVJIS3.js.map +1 -0
  16. package/dist/{chunk-DM4US77W.js → chunk-IZXOO6NP.js} +5 -3
  17. package/dist/{chunk-DM4US77W.js.map → chunk-IZXOO6NP.js.map} +1 -1
  18. package/dist/{chunk-3UR4SBZF.js → chunk-MBT3ASYV.js} +9 -9
  19. package/dist/{chunk-4HYWWO3W.js → chunk-MCYT2RNT.js} +3 -3
  20. package/dist/{chunk-THWT7RJU.js → chunk-OR7R6M5Z.js} +2 -2
  21. package/dist/{chunk-4MNDVDBS.js → chunk-OTC2KOZ2.js} +2 -2
  22. package/dist/{chunk-TTOYT5RB.js → chunk-PQFUUXWK.js} +2 -2
  23. package/dist/{chunk-EGBH5IPJ.js → chunk-T22TZJWJ.js} +4 -4
  24. package/dist/{chunk-4HYLRROV.js → chunk-UMKPSD35.js} +2 -2
  25. package/dist/{chunk-NLPH23IQ.js → chunk-UMTG2BN2.js} +2 -2
  26. package/dist/{chunk-AK4DSORQ.js → chunk-W5ISYSIL.js} +4 -4
  27. package/dist/{chunk-GJSNDJHR.js → chunk-YOXCJP3A.js} +3 -3
  28. package/dist/{chunk-ECMMM2VE.js → chunk-Z3PZRDLW.js} +2 -2
  29. package/dist/{chunk-TKNTAUA3.js → chunk-ZXWAQFDE.js} +2 -2
  30. package/dist/cli.js +15 -15
  31. package/dist/compounding/engine.js +2 -2
  32. package/dist/connectors/codex-materialize-runner.js +2 -2
  33. package/dist/connectors/index.js +2 -2
  34. package/dist/entity-retrieval.js +2 -2
  35. package/dist/index.js +23 -24
  36. package/dist/index.js.map +1 -1
  37. package/dist/maintenance/memory-governance.js +2 -2
  38. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +2 -2
  39. package/dist/maintenance/rebuild-memory-projection.js +3 -3
  40. package/dist/namespaces/migrate.js +3 -3
  41. package/dist/namespaces/storage.js +2 -2
  42. package/dist/operator-toolkit.js +5 -5
  43. package/dist/orchestrator.js +10 -10
  44. package/dist/schemas.d.ts +22 -22
  45. package/dist/semantic-consolidation.js +3 -3
  46. package/dist/semantic-rule-promotion.js +2 -2
  47. package/dist/semantic-rule-verifier.js +2 -2
  48. package/dist/storage.js +1 -1
  49. package/dist/transfer/types.d.ts +12 -12
  50. package/dist/verified-recall.js +2 -2
  51. package/package.json +1 -1
  52. package/src/storage-fallback-category-dirs.test.ts +389 -0
  53. package/src/storage.ts +36 -4
  54. package/src/utils/category-dir.ts +26 -0
  55. package/dist/chunk-GYITUZFT.js.map +0 -1
  56. package/dist/chunk-RREI7CNM.js.map +0 -1
  57. /package/dist/{chunk-SZL2GGFY.js.map → chunk-4BISW7RX.js.map} +0 -0
  58. /package/dist/{chunk-2GCNTHRA.js.map → chunk-6YLJZ2EU.js.map} +0 -0
  59. /package/dist/{chunk-NV2MGIW7.js.map → chunk-ALUZN7BE.js.map} +0 -0
  60. /package/dist/{chunk-RFSCIBCB.js.map → chunk-AMACWKM4.js.map} +0 -0
  61. /package/dist/{chunk-S2SEPLLA.js.map → chunk-BTRL6GG7.js.map} +0 -0
  62. /package/dist/{chunk-3UR4SBZF.js.map → chunk-MBT3ASYV.js.map} +0 -0
  63. /package/dist/{chunk-4HYWWO3W.js.map → chunk-MCYT2RNT.js.map} +0 -0
  64. /package/dist/{chunk-THWT7RJU.js.map → chunk-OR7R6M5Z.js.map} +0 -0
  65. /package/dist/{chunk-4MNDVDBS.js.map → chunk-OTC2KOZ2.js.map} +0 -0
  66. /package/dist/{chunk-TTOYT5RB.js.map → chunk-PQFUUXWK.js.map} +0 -0
  67. /package/dist/{chunk-EGBH5IPJ.js.map → chunk-T22TZJWJ.js.map} +0 -0
  68. /package/dist/{chunk-4HYLRROV.js.map → chunk-UMKPSD35.js.map} +0 -0
  69. /package/dist/{chunk-NLPH23IQ.js.map → chunk-UMTG2BN2.js.map} +0 -0
  70. /package/dist/{chunk-AK4DSORQ.js.map → chunk-W5ISYSIL.js.map} +0 -0
  71. /package/dist/{chunk-GJSNDJHR.js.map → chunk-YOXCJP3A.js.map} +0 -0
  72. /package/dist/{chunk-ECMMM2VE.js.map → chunk-Z3PZRDLW.js.map} +0 -0
  73. /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
- await collectPaths(this.factsDir);
4015
- await collectPaths(this.proceduresDir);
4016
- await collectPaths(this.reasoningTracesDir);
4017
- await collectPaths(this.correctionsDir);
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",