@remnic/core 9.3.644 → 9.3.646

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 (143) hide show
  1. package/dist/access-cli.js +28 -27
  2. package/dist/access-cli.js.map +1 -1
  3. package/dist/access-http.js +16 -16
  4. package/dist/access-mcp.js +13 -13
  5. package/dist/access-schema.js +3 -3
  6. package/dist/access-service.js +11 -11
  7. package/dist/adapters/index.js +4 -4
  8. package/dist/adapters/registry.js +2 -2
  9. package/dist/briefing.js +2 -2
  10. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
  11. package/dist/causal-consolidation.js +3 -3
  12. package/dist/{chunk-2GCNTHRA.js → chunk-3JSWINVD.js} +98 -35
  13. package/dist/chunk-3JSWINVD.js.map +1 -0
  14. package/dist/{chunk-SZL2GGFY.js → chunk-4BISW7RX.js} +2 -2
  15. package/dist/{chunk-GYITUZFT.js → chunk-A4BTPHIN.js} +171 -134
  16. package/dist/chunk-A4BTPHIN.js.map +1 -0
  17. package/dist/{chunk-NV2MGIW7.js → chunk-ALUZN7BE.js} +2 -2
  18. package/dist/{chunk-RFSCIBCB.js → chunk-AMACWKM4.js} +2 -2
  19. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  20. package/dist/{chunk-HRXCPKN4.js → chunk-APWJRJFW.js} +2 -2
  21. package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
  22. package/dist/{chunk-RREI7CNM.js → chunk-D6WVJIS3.js} +9 -42
  23. package/dist/chunk-D6WVJIS3.js.map +1 -0
  24. package/dist/{chunk-EGBH5IPJ.js → chunk-DC66QVL2.js} +4 -4
  25. package/dist/chunk-EVWIEEKZ.js +315 -0
  26. package/dist/chunk-EVWIEEKZ.js.map +1 -0
  27. package/dist/{chunk-DM4US77W.js → chunk-FAYDM5WD.js} +6 -4
  28. package/dist/{chunk-DM4US77W.js.map → chunk-FAYDM5WD.js.map} +1 -1
  29. package/dist/{chunk-3UR4SBZF.js → chunk-H67ZTMTL.js} +20 -20
  30. package/dist/{chunk-3SF7QNKF.js → chunk-I3IWTRYB.js} +2 -2
  31. package/dist/{chunk-Y2XKJJEI.js → chunk-IMWFHBG2.js} +10 -33
  32. package/dist/chunk-IMWFHBG2.js.map +1 -0
  33. package/dist/{chunk-IHG27THY.js → chunk-JLOJ5RJ7.js} +68 -59
  34. package/dist/chunk-JLOJ5RJ7.js.map +1 -0
  35. package/dist/{chunk-AK4DSORQ.js → chunk-L7W5YW6Y.js} +7 -7
  36. package/dist/{chunk-4HYWWO3W.js → chunk-MCYT2RNT.js} +3 -3
  37. package/dist/{chunk-DGNQRNLL.js → chunk-NT5TINK5.js} +2 -2
  38. package/dist/{chunk-THWT7RJU.js → chunk-OR7R6M5Z.js} +2 -2
  39. package/dist/{chunk-4MNDVDBS.js → chunk-OTC2KOZ2.js} +2 -2
  40. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  41. package/dist/{chunk-TTOYT5RB.js → chunk-PQFUUXWK.js} +2 -2
  42. package/dist/{chunk-CDHRUNBX.js → chunk-QKE4LHNR.js} +2 -2
  43. package/dist/{chunk-S2SEPLLA.js → chunk-RAELB5NX.js} +5 -5
  44. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  45. package/dist/chunk-S4DDLTPX.js +140 -0
  46. package/dist/chunk-S4DDLTPX.js.map +1 -0
  47. package/dist/{chunk-KVUG7FF7.js → chunk-U55D5UD5.js} +7 -7
  48. package/dist/{chunk-4HYLRROV.js → chunk-UMKPSD35.js} +2 -2
  49. package/dist/{chunk-NLPH23IQ.js → chunk-UMTG2BN2.js} +2 -2
  50. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  51. package/dist/{chunk-D2B22JDF.js → chunk-WEPMT6SC.js} +7 -7
  52. package/dist/{chunk-ECMMM2VE.js → chunk-Z3PZRDLW.js} +2 -2
  53. package/dist/{chunk-GJSNDJHR.js → chunk-ZPPFKVSD.js} +4 -4
  54. package/dist/{chunk-TKNTAUA3.js → chunk-ZXWAQFDE.js} +2 -2
  55. package/dist/cli.js +37 -35
  56. package/dist/compounding/engine.js +2 -2
  57. package/dist/connectors/codex-materialize-runner.js +2 -2
  58. package/dist/connectors/index.js +2 -2
  59. package/dist/entity-retrieval.js +2 -2
  60. package/dist/{first-start-migration-FF7YFGRP.js → first-start-migration-PG5HBC3K.js} +4 -4
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.js +91 -78
  63. package/dist/index.js.map +1 -1
  64. package/dist/lcm/engine.js +3 -3
  65. package/dist/lcm/index.js +11 -11
  66. package/dist/maintenance/memory-governance.js +2 -2
  67. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +2 -2
  68. package/dist/maintenance/rebuild-memory-projection.js +3 -3
  69. package/dist/namespaces/migrate.js +7 -7
  70. package/dist/namespaces/search.js +4 -4
  71. package/dist/namespaces/storage.js +2 -2
  72. package/dist/operator-toolkit.js +9 -9
  73. package/dist/orchestrator.js +23 -22
  74. package/dist/resume-bundles.js +3 -2
  75. package/dist/schemas.d.ts +22 -22
  76. package/dist/search/factory.js +3 -3
  77. package/dist/search/index.js +7 -7
  78. package/dist/semantic-consolidation.js +3 -3
  79. package/dist/semantic-rule-promotion.js +2 -2
  80. package/dist/semantic-rule-verifier.js +2 -2
  81. package/dist/session-identity.d.ts +118 -0
  82. package/dist/session-identity.js +21 -0
  83. package/dist/session-identity.js.map +1 -0
  84. package/dist/session-transcript-migration.d.ts +65 -0
  85. package/dist/session-transcript-migration.js +16 -0
  86. package/dist/session-transcript-migration.js.map +1 -0
  87. package/dist/storage.js +1 -1
  88. package/dist/summarizer.js +2 -1
  89. package/dist/transcript.d.ts +12 -6
  90. package/dist/transcript.js +2 -1
  91. package/dist/transfer/backup.js +2 -2
  92. package/dist/transfer/capsule-export.js +2 -2
  93. package/dist/transfer/capsule-import.js +2 -2
  94. package/dist/transfer/import-sqlite.js +2 -2
  95. package/dist/transfer/types.d.ts +12 -12
  96. package/dist/verified-recall.js +2 -2
  97. package/package.json +1 -1
  98. package/src/cli.ts +79 -0
  99. package/src/index.ts +18 -0
  100. package/src/session-identity.test.ts +130 -0
  101. package/src/session-identity.ts +281 -0
  102. package/src/session-transcript-migration.test.ts +350 -0
  103. package/src/session-transcript-migration.ts +527 -0
  104. package/src/storage-fallback-category-dirs.test.ts +389 -0
  105. package/src/storage.ts +36 -4
  106. package/src/summarizer.ts +15 -35
  107. package/src/transcript-session-identity.test.ts +424 -0
  108. package/src/transcript.ts +100 -70
  109. package/src/utils/category-dir.ts +26 -0
  110. package/dist/chunk-2GCNTHRA.js.map +0 -1
  111. package/dist/chunk-GYITUZFT.js.map +0 -1
  112. package/dist/chunk-IHG27THY.js.map +0 -1
  113. package/dist/chunk-RREI7CNM.js.map +0 -1
  114. package/dist/chunk-Y2XKJJEI.js.map +0 -1
  115. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  116. /package/dist/{chunk-SZL2GGFY.js.map → chunk-4BISW7RX.js.map} +0 -0
  117. /package/dist/{chunk-NV2MGIW7.js.map → chunk-ALUZN7BE.js.map} +0 -0
  118. /package/dist/{chunk-RFSCIBCB.js.map → chunk-AMACWKM4.js.map} +0 -0
  119. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  120. /package/dist/{chunk-HRXCPKN4.js.map → chunk-APWJRJFW.js.map} +0 -0
  121. /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  122. /package/dist/{chunk-EGBH5IPJ.js.map → chunk-DC66QVL2.js.map} +0 -0
  123. /package/dist/{chunk-3UR4SBZF.js.map → chunk-H67ZTMTL.js.map} +0 -0
  124. /package/dist/{chunk-3SF7QNKF.js.map → chunk-I3IWTRYB.js.map} +0 -0
  125. /package/dist/{chunk-AK4DSORQ.js.map → chunk-L7W5YW6Y.js.map} +0 -0
  126. /package/dist/{chunk-4HYWWO3W.js.map → chunk-MCYT2RNT.js.map} +0 -0
  127. /package/dist/{chunk-DGNQRNLL.js.map → chunk-NT5TINK5.js.map} +0 -0
  128. /package/dist/{chunk-THWT7RJU.js.map → chunk-OR7R6M5Z.js.map} +0 -0
  129. /package/dist/{chunk-4MNDVDBS.js.map → chunk-OTC2KOZ2.js.map} +0 -0
  130. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  131. /package/dist/{chunk-TTOYT5RB.js.map → chunk-PQFUUXWK.js.map} +0 -0
  132. /package/dist/{chunk-CDHRUNBX.js.map → chunk-QKE4LHNR.js.map} +0 -0
  133. /package/dist/{chunk-S2SEPLLA.js.map → chunk-RAELB5NX.js.map} +0 -0
  134. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  135. /package/dist/{chunk-KVUG7FF7.js.map → chunk-U55D5UD5.js.map} +0 -0
  136. /package/dist/{chunk-4HYLRROV.js.map → chunk-UMKPSD35.js.map} +0 -0
  137. /package/dist/{chunk-NLPH23IQ.js.map → chunk-UMTG2BN2.js.map} +0 -0
  138. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  139. /package/dist/{chunk-D2B22JDF.js.map → chunk-WEPMT6SC.js.map} +0 -0
  140. /package/dist/{chunk-ECMMM2VE.js.map → chunk-Z3PZRDLW.js.map} +0 -0
  141. /package/dist/{chunk-GJSNDJHR.js.map → chunk-ZPPFKVSD.js.map} +0 -0
  142. /package/dist/{chunk-TKNTAUA3.js.map → chunk-ZXWAQFDE.js.map} +0 -0
  143. /package/dist/{first-start-migration-FF7YFGRP.js.map → first-start-migration-PG5HBC3K.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
 
package/src/summarizer.ts CHANGED
@@ -11,11 +11,9 @@ import type { TranscriptManager } from "./transcript.js";
11
11
  import { readSummarySnapshot, upsertSummarySnapshot, writeSummarySnapshot } from "./summary-snapshot.js";
12
12
  import {
13
13
  encodeStoragePathSegment,
14
- encodeStoragePathSegmentWithHash,
15
- isSafeLegacyPathSegment,
16
14
  resolveSafeStoragePath,
17
- storagePathHash,
18
15
  } from "./storage-paths.js";
16
+ import { sessionStoragePaths } from "./session-identity.js";
19
17
 
20
18
  // Schema for LLM summary output
21
19
  const HourlySummarySchema = z.object({
@@ -763,45 +761,25 @@ Respond with valid JSON matching this schema:
763
761
  startTime: Date,
764
762
  endTime: Date
765
763
  ): Promise<TranscriptEntry[]> {
766
- const parts = sessionKey.split(":");
767
- let channelType = "other";
768
- let channelId = "default";
769
-
770
- if (parts.length >= 3) {
771
- channelType = parts[2];
772
- if (channelType === "main") {
773
- channelId = "default";
774
- } else if (channelType === "discord" && parts.length >= 5 && parts[3] === "channel") {
775
- channelId = parts[4];
776
- } else if (channelType === "slack" && parts.length >= 5 && parts[3] === "channel") {
777
- channelId = parts[4];
778
- } else if (channelType === "cron" && parts.length >= 4) {
779
- channelId = parts[3];
780
- } else if (parts.length >= 4) {
781
- channelId = parts[3];
782
- }
783
- }
764
+ // Shared session-identity layer (issue #1496, rule #22). Arbitrary keys
765
+ // route to `session/<hash>`; legacy `agent:<id>:...` keep their paths. The
766
+ // shared helper also supplies the read-back-only candidate dirs (mirrors
767
+ // TranscriptManager) so legacy `other/default` AND old `parts.length >= 3`
768
+ // data stays discoverable here too.
769
+ const paths = sessionStoragePaths(sessionKey);
784
770
 
785
771
  try {
786
772
  const transcriptRoot = path.join(this.config.memoryDir, "transcripts");
787
- const encodedDir = path.join(
788
- encodeStoragePathSegment(channelType),
789
- encodeStoragePathSegment(channelId),
790
- );
791
- const alternateDir = path.join(
792
- encodeStoragePathSegmentWithHash(channelType),
793
- `${encodeStoragePathSegmentWithHash(channelId)}--session-${storagePathHash(sessionKey)}`,
794
- );
795
- const legacyDir =
796
- isSafeLegacyPathSegment(channelType) && isSafeLegacyPathSegment(channelId)
797
- ? path.join(channelType, channelId)
798
- : undefined;
799
773
  const candidateDirs = new Set(
800
- [encodedDir, alternateDir, legacyDir].filter(
774
+ [paths.dir, paths.alternateDir, paths.legacyDir, ...paths.readbackDirs].filter(
801
775
  (dir): dir is string => typeof dir === "string" && dir.length > 0,
802
776
  ),
803
777
  );
804
778
  const entries: TranscriptEntry[] = [];
779
+ // Dedup identical raw rows that a partially-applied migration may have
780
+ // left in both the primary dir and a read-back dir (issue #1496, cursor
781
+ // review). Exact raw JSONL line is the stable identity.
782
+ const seenRawLines = new Set<string>();
805
783
 
806
784
  // Read all daily transcript files in the directory
807
785
  for (const candidateDir of candidateDirs) {
@@ -828,8 +806,10 @@ Respond with valid JSON matching this schema:
828
806
  if (
829
807
  entry.sessionKey === sessionKey &&
830
808
  entryTime >= startTime.getTime() &&
831
- entryTime < endTime.getTime()
809
+ entryTime < endTime.getTime() &&
810
+ !seenRawLines.has(line)
832
811
  ) {
812
+ seenRawLines.add(line);
833
813
  entries.push(entry);
834
814
  }
835
815
  } catch {