@remnic/core 9.3.705 → 9.3.707

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 (261) hide show
  1. package/dist/access-boundary.d.ts +5 -5
  2. package/dist/access-boundary.js +10 -10
  3. package/dist/access-cli.js +26 -25
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +5 -5
  6. package/dist/access-http.js +14 -14
  7. package/dist/access-mcp.d.ts +5 -5
  8. package/dist/access-mcp.js +13 -13
  9. package/dist/access-operations-batch.js +11 -11
  10. package/dist/access-operations.d.ts +5 -5
  11. package/dist/access-operations.js +12 -12
  12. package/dist/{access-service-C8hc8N2i.d.ts → access-service-j1c1gptF.d.ts} +3 -3
  13. package/dist/access-service.d.ts +5 -5
  14. package/dist/access-service.js +9 -9
  15. package/dist/access-surface-catalog.d.ts +5 -5
  16. package/dist/action-confidence.d.ts +1 -1
  17. package/dist/active-memory-bridge.d.ts +1 -1
  18. package/dist/active-recall.d.ts +1 -1
  19. package/dist/behavior-learner.d.ts +1 -1
  20. package/dist/behavior-signals.d.ts +1 -1
  21. package/dist/bootstrap.d.ts +4 -4
  22. package/dist/briefing.d.ts +1 -1
  23. package/dist/briefing.js +5 -5
  24. package/dist/buffer-surprise-report.d.ts +1 -1
  25. package/dist/buffer.d.ts +1 -1
  26. package/dist/calibration.d.ts +1 -1
  27. package/dist/capabilities.d.ts +1 -1
  28. package/dist/{catalog-CAeU0e1C.d.ts → catalog-CKxilpzS.d.ts} +1 -1
  29. package/dist/causal-behavior.d.ts +1 -1
  30. package/dist/causal-consolidation.d.ts +1 -1
  31. package/dist/causal-consolidation.js +6 -6
  32. package/dist/{chunk-JZCKDTAS.js → chunk-24FGNOQS.js} +3 -3
  33. package/dist/{chunk-C2EHWNP2.js → chunk-2VQYHHWB.js} +9 -3
  34. package/dist/chunk-2VQYHHWB.js.map +1 -0
  35. package/dist/{chunk-47LPHBIP.js → chunk-3PTOZJOI.js} +2 -2
  36. package/dist/{chunk-2SYPOX45.js → chunk-4DZATVK5.js} +3 -3
  37. package/dist/{chunk-S5APSA3U.js → chunk-4E2QCH46.js} +2 -2
  38. package/dist/{chunk-KKK7YTYN.js → chunk-5GSUBCZM.js} +6 -2
  39. package/dist/chunk-5GSUBCZM.js.map +1 -0
  40. package/dist/{chunk-5DTWZ66X.js → chunk-66TSLESZ.js} +2 -2
  41. package/dist/{chunk-IREVVFTM.js → chunk-6CIAGZ6X.js} +9 -1
  42. package/dist/chunk-6CIAGZ6X.js.map +1 -0
  43. package/dist/{chunk-Y4PMATX2.js → chunk-6HPJMR5I.js} +2 -2
  44. package/dist/{chunk-M5MWSG4S.js → chunk-BGAHTI4C.js} +5 -5
  45. package/dist/{chunk-OM6ETLLH.js → chunk-C63MK3WL.js} +2 -2
  46. package/dist/{chunk-GT4VBKM4.js → chunk-EJDAXR7O.js} +5 -5
  47. package/dist/{chunk-ULVM34NP.js → chunk-ESMY4RJ4.js} +2 -2
  48. package/dist/{chunk-MNH2OODZ.js → chunk-GKI6LX5L.js} +2 -2
  49. package/dist/{chunk-KZMMAVR2.js → chunk-HIV5E57C.js} +2 -2
  50. package/dist/{chunk-SFOAQQDJ.js → chunk-IONFO7UK.js} +3 -3
  51. package/dist/{chunk-BMKGG7GC.js → chunk-IVCQW4C4.js} +2 -2
  52. package/dist/chunk-M7R5R6OJ.js +355 -0
  53. package/dist/chunk-M7R5R6OJ.js.map +1 -0
  54. package/dist/{chunk-IX3UQT4H.js → chunk-MLLTU5FX.js} +1 -1
  55. package/dist/chunk-MLLTU5FX.js.map +1 -0
  56. package/dist/{chunk-NFGJBEPT.js → chunk-MRX6ZXHZ.js} +2 -2
  57. package/dist/{chunk-GI3MTYDU.js → chunk-N75N5SNX.js} +3 -3
  58. package/dist/{chunk-BKBSE3BU.js → chunk-NM6TSEGQ.js} +2 -2
  59. package/dist/{chunk-S3WYRPSA.js → chunk-PDJQVAGM.js} +68 -31
  60. package/dist/chunk-PDJQVAGM.js.map +1 -0
  61. package/dist/{chunk-SY6WG6DB.js → chunk-RH2OSRQY.js} +2 -2
  62. package/dist/{chunk-YTMDF6S7.js → chunk-SPETAWFE.js} +2 -2
  63. package/dist/{chunk-JHAYR2JY.js → chunk-STYMKPFT.js} +7 -7
  64. package/dist/{chunk-OMNTQPZO.js → chunk-TKZ3AL5V.js} +2 -2
  65. package/dist/{chunk-IFR23TC5.js → chunk-VYZNWH4J.js} +37 -6
  66. package/dist/chunk-VYZNWH4J.js.map +1 -0
  67. package/dist/{chunk-KFU2SKDD.js → chunk-W2S3Z5MT.js} +2 -2
  68. package/dist/{chunk-6GJ2WQRK.js → chunk-W2WQ4LE7.js} +2 -2
  69. package/dist/{chunk-7ARKLLCV.js → chunk-WDH3KUZU.js} +2 -2
  70. package/dist/{chunk-7L54YZUI.js → chunk-WQADZ3ZY.js} +17 -17
  71. package/dist/{chunk-HEHPVBWA.js → chunk-WSWYIKXX.js} +4 -4
  72. package/dist/{chunk-WN4GHSDH.js → chunk-XKMDDM7P.js} +2 -2
  73. package/dist/{chunk-LY4FJLPT.js → chunk-Y6YHAGQP.js} +2 -2
  74. package/dist/{cli-ChE0XlJY.d.ts → cli-CbT-pyM4.d.ts} +3 -3
  75. package/dist/cli.d.ts +6 -6
  76. package/dist/cli.js +26 -26
  77. package/dist/compounding/engine.d.ts +1 -1
  78. package/dist/compounding/engine.js +5 -5
  79. package/dist/compounding/preference-consolidator.d.ts +1 -1
  80. package/dist/compression-optimizer.d.ts +1 -1
  81. package/dist/config.d.ts +1 -1
  82. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  83. package/dist/connectors/codex-materialize-runner.js +5 -5
  84. package/dist/connectors/codex-materialize.d.ts +1 -1
  85. package/dist/connectors/index.d.ts +1 -1
  86. package/dist/connectors/index.js +5 -5
  87. package/dist/consolidation-provenance-check.d.ts +1 -1
  88. package/dist/consolidation-undo.d.ts +1 -1
  89. package/dist/contradiction/index.d.ts +1 -1
  90. package/dist/conversation-index/backend.d.ts +1 -1
  91. package/dist/conversation-index/chunker.d.ts +1 -1
  92. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  93. package/dist/conversation-index/indexer.d.ts +1 -1
  94. package/dist/conversation-index/search.d.ts +1 -1
  95. package/dist/day-summary.d.ts +1 -1
  96. package/dist/delinearize.d.ts +1 -1
  97. package/dist/direct-answer-wiring.d.ts +1 -1
  98. package/dist/direct-answer.d.ts +1 -1
  99. package/dist/embedding-fallback.d.ts +1 -1
  100. package/dist/enrichment/index.d.ts +1 -1
  101. package/dist/entity-retrieval.d.ts +1 -1
  102. package/dist/entity-retrieval.js +5 -5
  103. package/dist/entity-schema.d.ts +1 -1
  104. package/dist/event-time.d.ts +35 -1
  105. package/dist/event-time.js +6 -320
  106. package/dist/event-time.js.map +1 -1
  107. package/dist/explicit-capture.d.ts +4 -4
  108. package/dist/extraction-faithfulness.d.ts +1 -1
  109. package/dist/extraction-judge-telemetry.d.ts +1 -1
  110. package/dist/extraction-judge-training.d.ts +1 -1
  111. package/dist/extraction-judge.d.ts +1 -1
  112. package/dist/extraction.d.ts +10 -1
  113. package/dist/extraction.js +2 -2
  114. package/dist/fallback-llm.d.ts +1 -1
  115. package/dist/{forget-6SOIPUMQ.js → forget-U66KPDPY.js} +3 -3
  116. package/dist/identity-continuity.d.ts +1 -1
  117. package/dist/importance.d.ts +1 -1
  118. package/dist/index.d.ts +9 -9
  119. package/dist/index.js +35 -34
  120. package/dist/index.js.map +1 -1
  121. package/dist/intent.d.ts +1 -1
  122. package/dist/lcm/engine.d.ts +1 -1
  123. package/dist/lcm/index.d.ts +1 -1
  124. package/dist/lcm/tools.d.ts +1 -1
  125. package/dist/lifecycle.d.ts +1 -1
  126. package/dist/live-connectors-runner.d.ts +1 -1
  127. package/dist/local-llm.d.ts +1 -1
  128. package/dist/maintenance/memory-governance.d.ts +1 -1
  129. package/dist/maintenance/memory-governance.js +5 -5
  130. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +5 -5
  131. package/dist/maintenance/rebuild-memory-projection.js +6 -6
  132. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  133. package/dist/memory-action-policy.d.ts +1 -1
  134. package/dist/memory-cache.d.ts +1 -1
  135. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  136. package/dist/memory-projection-store.d.ts +1 -1
  137. package/dist/memory-provenance.d.ts +1 -1
  138. package/dist/memory-worth-outcomes.d.ts +1 -1
  139. package/dist/models-json.d.ts +1 -1
  140. package/dist/namespaces/migrate.d.ts +2 -2
  141. package/dist/namespaces/migrate.js +6 -6
  142. package/dist/namespaces/principal.d.ts +1 -1
  143. package/dist/namespaces/search.d.ts +1 -1
  144. package/dist/namespaces/storage.d.ts +2 -2
  145. package/dist/namespaces/storage.js +5 -5
  146. package/dist/native-knowledge.d.ts +1 -1
  147. package/dist/operator-toolkit.d.ts +1 -1
  148. package/dist/operator-toolkit.js +10 -10
  149. package/dist/orchestration/maintenance.d.ts +2 -2
  150. package/dist/orchestration/maintenance.js +7 -7
  151. package/dist/{orchestrator-m_PnhyHj.d.ts → orchestrator-D4ovYV3x.d.ts} +3 -3
  152. package/dist/orchestrator.d.ts +4 -4
  153. package/dist/orchestrator.js +20 -19
  154. package/dist/patterns-cli.d.ts +1 -1
  155. package/dist/policy-runtime.d.ts +1 -1
  156. package/dist/provenance.d.ts +1 -1
  157. package/dist/qmd-recall-cache.d.ts +1 -1
  158. package/dist/qmd.d.ts +1 -1
  159. package/dist/recall-disclosure-escalation.d.ts +1 -1
  160. package/dist/recall-explain-renderer.d.ts +1 -1
  161. package/dist/recall-explain-renderer.js +3 -3
  162. package/dist/recall-planner-llm.d.ts +1 -1
  163. package/dist/recall-state.d.ts +1 -1
  164. package/dist/recall-tag-filter.d.ts +1 -1
  165. package/dist/recall-xray-cli.d.ts +1 -1
  166. package/dist/recall-xray-cli.js +4 -4
  167. package/dist/recall-xray-renderer.d.ts +1 -1
  168. package/dist/recall-xray-renderer.js +3 -3
  169. package/dist/recall-xray.d.ts +1 -1
  170. package/dist/recall-xray.js +2 -2
  171. package/dist/resolve-auth-token.d.ts +1 -1
  172. package/dist/retrieval-agents.d.ts +1 -1
  173. package/dist/retrieval-tiers.d.ts +1 -1
  174. package/dist/routing/engine.d.ts +1 -1
  175. package/dist/routing/store.d.ts +1 -1
  176. package/dist/schemas.d.ts +60 -22
  177. package/dist/schemas.js +1 -1
  178. package/dist/search/embed-helper.d.ts +1 -1
  179. package/dist/search/factory.d.ts +1 -1
  180. package/dist/search/index.d.ts +1 -1
  181. package/dist/search/lancedb-backend.d.ts +1 -1
  182. package/dist/search/meilisearch-backend.d.ts +1 -1
  183. package/dist/search/noop-backend.d.ts +1 -1
  184. package/dist/search/orama-backend.d.ts +1 -1
  185. package/dist/search/port.d.ts +1 -1
  186. package/dist/search/remote-backend.d.ts +1 -1
  187. package/dist/{semantic-consolidation-DVBbq6Wr.d.ts → semantic-consolidation-DyMUCsfN.d.ts} +1 -1
  188. package/dist/semantic-consolidation.d.ts +2 -2
  189. package/dist/semantic-consolidation.js +6 -6
  190. package/dist/semantic-rule-promotion.js +5 -5
  191. package/dist/semantic-rule-verifier.d.ts +1 -1
  192. package/dist/semantic-rule-verifier.js +5 -5
  193. package/dist/session-observer-bands.d.ts +1 -1
  194. package/dist/session-observer-state.d.ts +1 -1
  195. package/dist/shared-context/manager.d.ts +1 -1
  196. package/dist/signal.d.ts +1 -1
  197. package/dist/storage.d.ts +5 -1
  198. package/dist/storage.js +4 -4
  199. package/dist/summarizer.d.ts +1 -1
  200. package/dist/summary-snapshot.d.ts +1 -1
  201. package/dist/temporal-supersession.d.ts +1 -1
  202. package/dist/temporal-supersession.js +2 -2
  203. package/dist/temporal-validity.d.ts +2 -2
  204. package/dist/temporal-validity.js +1 -1
  205. package/dist/threading.d.ts +1 -1
  206. package/dist/tier-migration.d.ts +1 -1
  207. package/dist/tier-routing.d.ts +1 -1
  208. package/dist/topics.d.ts +1 -1
  209. package/dist/transcript.d.ts +1 -1
  210. package/dist/transfer/types.d.ts +12 -12
  211. package/dist/{types-CJrvHJjx.d.ts → types-Couvz-L3.d.ts} +12 -2
  212. package/dist/types.d.ts +1 -1
  213. package/dist/types.js +1 -1
  214. package/dist/utility-runtime.d.ts +1 -1
  215. package/dist/verified-recall.js +5 -5
  216. package/package.json +2 -2
  217. package/src/event-time.ts +73 -0
  218. package/src/extraction-eventtime-wiring.test.ts +269 -0
  219. package/src/extraction.ts +50 -3
  220. package/src/issue-1492-coding-mode-consistency.test.ts +315 -0
  221. package/src/orchestrator.ts +71 -17
  222. package/src/schemas.ts +12 -0
  223. package/src/storage.ts +13 -2
  224. package/src/temporal-validity.test.ts +33 -0
  225. package/src/temporal-validity.ts +24 -1
  226. package/src/types.ts +12 -2
  227. package/dist/chunk-C2EHWNP2.js.map +0 -1
  228. package/dist/chunk-IFR23TC5.js.map +0 -1
  229. package/dist/chunk-IREVVFTM.js.map +0 -1
  230. package/dist/chunk-IX3UQT4H.js.map +0 -1
  231. package/dist/chunk-KKK7YTYN.js.map +0 -1
  232. package/dist/chunk-S3WYRPSA.js.map +0 -1
  233. /package/dist/{chunk-JZCKDTAS.js.map → chunk-24FGNOQS.js.map} +0 -0
  234. /package/dist/{chunk-47LPHBIP.js.map → chunk-3PTOZJOI.js.map} +0 -0
  235. /package/dist/{chunk-2SYPOX45.js.map → chunk-4DZATVK5.js.map} +0 -0
  236. /package/dist/{chunk-S5APSA3U.js.map → chunk-4E2QCH46.js.map} +0 -0
  237. /package/dist/{chunk-5DTWZ66X.js.map → chunk-66TSLESZ.js.map} +0 -0
  238. /package/dist/{chunk-Y4PMATX2.js.map → chunk-6HPJMR5I.js.map} +0 -0
  239. /package/dist/{chunk-M5MWSG4S.js.map → chunk-BGAHTI4C.js.map} +0 -0
  240. /package/dist/{chunk-OM6ETLLH.js.map → chunk-C63MK3WL.js.map} +0 -0
  241. /package/dist/{chunk-GT4VBKM4.js.map → chunk-EJDAXR7O.js.map} +0 -0
  242. /package/dist/{chunk-ULVM34NP.js.map → chunk-ESMY4RJ4.js.map} +0 -0
  243. /package/dist/{chunk-MNH2OODZ.js.map → chunk-GKI6LX5L.js.map} +0 -0
  244. /package/dist/{chunk-KZMMAVR2.js.map → chunk-HIV5E57C.js.map} +0 -0
  245. /package/dist/{chunk-SFOAQQDJ.js.map → chunk-IONFO7UK.js.map} +0 -0
  246. /package/dist/{chunk-BMKGG7GC.js.map → chunk-IVCQW4C4.js.map} +0 -0
  247. /package/dist/{chunk-NFGJBEPT.js.map → chunk-MRX6ZXHZ.js.map} +0 -0
  248. /package/dist/{chunk-GI3MTYDU.js.map → chunk-N75N5SNX.js.map} +0 -0
  249. /package/dist/{chunk-BKBSE3BU.js.map → chunk-NM6TSEGQ.js.map} +0 -0
  250. /package/dist/{chunk-SY6WG6DB.js.map → chunk-RH2OSRQY.js.map} +0 -0
  251. /package/dist/{chunk-YTMDF6S7.js.map → chunk-SPETAWFE.js.map} +0 -0
  252. /package/dist/{chunk-JHAYR2JY.js.map → chunk-STYMKPFT.js.map} +0 -0
  253. /package/dist/{chunk-OMNTQPZO.js.map → chunk-TKZ3AL5V.js.map} +0 -0
  254. /package/dist/{chunk-KFU2SKDD.js.map → chunk-W2S3Z5MT.js.map} +0 -0
  255. /package/dist/{chunk-6GJ2WQRK.js.map → chunk-W2WQ4LE7.js.map} +0 -0
  256. /package/dist/{chunk-7ARKLLCV.js.map → chunk-WDH3KUZU.js.map} +0 -0
  257. /package/dist/{chunk-7L54YZUI.js.map → chunk-WQADZ3ZY.js.map} +0 -0
  258. /package/dist/{chunk-HEHPVBWA.js.map → chunk-WSWYIKXX.js.map} +0 -0
  259. /package/dist/{chunk-WN4GHSDH.js.map → chunk-XKMDDM7P.js.map} +0 -0
  260. /package/dist/{chunk-LY4FJLPT.js.map → chunk-Y6YHAGQP.js.map} +0 -0
  261. /package/dist/{forget-6SOIPUMQ.js.map → forget-U66KPDPY.js.map} +0 -0
@@ -0,0 +1,269 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp, rm } from "node:fs/promises";
6
+
7
+ import { resolveFactEventTime } from "./event-time.js";
8
+ import { ExtractionEngine } from "./extraction.js";
9
+ import { ExtractedFactSchema } from "./schemas.js";
10
+ import { StorageManager } from "./storage.js";
11
+
12
+ /**
13
+ * Issue #1578 PR 2 — extraction event-time wiring.
14
+ *
15
+ * Proves three contracts:
16
+ * 1. resolveFactEventTime anchors to the SOURCE TURN timestamp, never
17
+ * wall-clock — replay/import of an old transcript yields old-era event
18
+ * times (the core bi-temporal invariant from the #1578 design).
19
+ * 2. Extraction normalization passes the per-fact `eventTime` expression
20
+ * through from raw LLM JSON (camelCase and snake_case payloads) so the
21
+ * persist path can resolve it at write time.
22
+ * 3. Storage round-trips `invalid_at` so the bi-temporal interval survives
23
+ * write → read (complementing PR 1's observedAt/eventTimeSource test).
24
+ */
25
+
26
+ // ── 1. Replay anchoring ──────────────────────────────────────────────────
27
+
28
+ test("resolveFactEventTime: replay of a 2025 transcript anchors 'last March' to 2025-03, not today", () => {
29
+ // The anchor is the source turn timestamp from an old (2025) transcript.
30
+ const anchor2025 = "2025-04-10T12:00:00.000Z";
31
+ const result = resolveFactEventTime("last March", anchor2025);
32
+ assert.equal(result.eventTimeSource, "extracted");
33
+ assert.ok(result.validFrom, "validFrom must be set for a resolvable expression");
34
+ // "last March" relative to April 2025 → March 2025.
35
+ assert.ok(result.validFrom!.startsWith("2025-03"), `expected 2025-03, got ${result.validFrom}`);
36
+ // Critical bi-temporal invariant: NOT anchored to today's wall-clock.
37
+ assert.ok(!result.validFrom!.startsWith("2026"), "must not anchor to wall-clock");
38
+ assert.equal(result.observedAt, anchor2025);
39
+ });
40
+
41
+ test("resolveFactEventTime: same expression, different anchors → different validFrom", () => {
42
+ // The identical phrase resolves differently depending on the anchor —
43
+ // proving the resolver is anchor-driven, not Date.now()-driven.
44
+ const old = resolveFactEventTime("yesterday", "2025-01-15T10:00:00.000Z");
45
+ const recent = resolveFactEventTime("yesterday", "2026-07-05T10:00:00.000Z");
46
+ assert.ok(old.validFrom!.startsWith("2025-01-14"), `got ${old.validFrom}`);
47
+ assert.ok(recent.validFrom!.startsWith("2026-07-04"), `got ${recent.validFrom}`);
48
+ assert.notEqual(old.validFrom, recent.validFrom);
49
+ });
50
+
51
+ test("resolveFactEventTime: absent expression → 'assumed' with validFrom = anchor", () => {
52
+ const anchor = "2025-06-01T00:00:00.000Z";
53
+ const result = resolveFactEventTime(undefined, anchor);
54
+ assert.equal(result.eventTimeSource, "assumed");
55
+ assert.equal(result.validFrom, anchor);
56
+ assert.equal(result.observedAt, anchor);
57
+ assert.equal(result.validUntil, undefined);
58
+ });
59
+
60
+ test("resolveFactEventTime: 'since 2024' → validFrom only (open-ended start)", () => {
61
+ const result = resolveFactEventTime("since 2024", "2025-06-01T00:00:00.000Z");
62
+ assert.equal(result.eventTimeSource, "extracted");
63
+ assert.ok(result.validFrom!.startsWith("2024-01-01"));
64
+ assert.equal(result.validUntil, undefined);
65
+ });
66
+
67
+ test("resolveFactEventTime: unresolvable garbage → 'assumed' fallback", () => {
68
+ const anchor = "2025-06-01T00:00:00.000Z";
69
+ const result = resolveFactEventTime("sometime maybe???", anchor);
70
+ assert.equal(result.eventTimeSource, "assumed");
71
+ assert.equal(result.validFrom, anchor);
72
+ });
73
+
74
+ // ── 2. Extraction normalization passes eventTime through ─────────────────
75
+
76
+ function makeEngine(): ExtractionEngine {
77
+ // Minimal construction: the facts normalizer is a pure data transform
78
+ // (no LLM / I/O). Stub the optional deps so the constructor doesn't
79
+ // spin up real clients.
80
+ return new ExtractionEngine(
81
+ { memoryDir: "/tmp" } as any,
82
+ undefined, // profiler → safe default
83
+ {} as any, // localLlm stub
84
+ undefined, // gatewayConfig
85
+ {} as any, // modelRegistry stub
86
+ );
87
+ }
88
+
89
+ test("extraction normalization: camelCase 'eventTime' passes through to ExtractedFact", () => {
90
+ const engine = makeEngine();
91
+ const result = (engine as any).normalizeExtractionResultPayload({
92
+ facts: [
93
+ { content: "We moved offices in March.", category: "fact", eventTime: "last March" },
94
+ ],
95
+ entities: [],
96
+ });
97
+ assert.equal(result.facts.length, 1);
98
+ assert.equal(result.facts[0].eventTime, "last March");
99
+ });
100
+
101
+ test("extraction normalization: snake_case 'event_time' also captured", () => {
102
+ const engine = makeEngine();
103
+ const result = (engine as any).normalizeExtractionResultPayload({
104
+ facts: [
105
+ { content: "Switched to PostgreSQL.", category: "fact", event_time: "2025-01-15" },
106
+ ],
107
+ entities: [],
108
+ });
109
+ assert.equal(result.facts[0].eventTime, "2025-01-15");
110
+ });
111
+
112
+ test("extraction normalization: absent eventTime → undefined (assumed deferred to write time)", () => {
113
+ const engine = makeEngine();
114
+ const result = (engine as any).normalizeExtractionResultPayload({
115
+ facts: [{ content: "The sky is blue.", category: "fact" }],
116
+ entities: [],
117
+ });
118
+ assert.equal(result.facts[0].eventTime, undefined);
119
+ });
120
+
121
+ // ── 2b. Schema preserves snake_case event_time (gateway fix, chatgpt-codex) ─
122
+
123
+ test("ExtractedFactSchema: snake_case event_time survives schema parse (gateway strip-unknown fix)", () => {
124
+ // Zod .object() strips unknown keys by default. Before the event_time alias
125
+ // was added, the gateway fallback path (parseWithSchemaDetailed → schema.parse)
126
+ // silently dropped event_time, losing the temporal bound (#1578).
127
+ const parsed = ExtractedFactSchema.parse({
128
+ content: "Switched to PostgreSQL.",
129
+ category: "fact",
130
+ confidence: 0.9,
131
+ tags: [],
132
+ event_time: "2025-01-15",
133
+ });
134
+ assert.equal(parsed.event_time, "2025-01-15");
135
+ });
136
+
137
+ test("ExtractedFactSchema: camelCase eventTime also survives schema parse (no regression)", () => {
138
+ const parsed = ExtractedFactSchema.parse({
139
+ content: "We moved offices in March.",
140
+ category: "fact",
141
+ confidence: 0.8,
142
+ tags: [],
143
+ eventTime: "2025-03",
144
+ });
145
+ assert.equal(parsed.eventTime, "2025-03");
146
+ });
147
+
148
+ // ── 3. Storage round-trips invalid_at ────────────────────────────────────
149
+
150
+ test("storage: invalid_at round-trips through writeMemoryFrontmatter → readAllMemories", async () => {
151
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-et-invalidAt-"));
152
+ try {
153
+ const storage = new StorageManager(dir);
154
+ await storage.ensureDirectories();
155
+
156
+ const id = await storage.writeMemory("fact", "Old stack was Node 18.", {
157
+ validAt: "2025-01-01T00:00:00.000Z",
158
+ observedAt: "2025-06-01T00:00:00.000Z",
159
+ eventTimeSource: "extracted",
160
+ });
161
+
162
+ const before = await storage.readAllMemories();
163
+ const mem = before.find((m) => m.frontmatter.id === id);
164
+ assert.ok(mem);
165
+
166
+ const ok = await storage.writeMemoryFrontmatter(mem!, {
167
+ invalid_at: "2025-06-15T00:00:00.000Z",
168
+ });
169
+ assert.equal(ok, true);
170
+
171
+ const after = await storage.readAllMemories();
172
+ const patched = after.find((m) => m.frontmatter.id === id);
173
+ assert.ok(patched, "patched fact must be readable");
174
+ assert.equal(patched!.frontmatter.invalid_at, "2025-06-15T00:00:00.000Z");
175
+ // Bi-temporal provenance preserved through the frontmatter patch.
176
+ assert.equal(patched!.frontmatter.observedAt, "2025-06-01T00:00:00.000Z");
177
+ assert.equal(patched!.frontmatter.eventTimeSource, "extracted");
178
+ assert.equal(patched!.frontmatter.valid_at, "2025-01-01T00:00:00.000Z");
179
+ } finally {
180
+ await rm(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test("storage: writeMemory accepts invalidAt directly (end-bound at extraction write time)", async () => {
185
+ // Proves Fix C: writeMemory now accepts invalidAt so "until X" expressions
186
+ // persist invalid_at at write time without a separate updateMemoryFrontmatter call.
187
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-et-writeMem-invalid-"));
188
+ try {
189
+ const storage = new StorageManager(dir);
190
+ await storage.ensureDirectories();
191
+ const id = await storage.writeMemory("fact", "We used MongoDB until June 2025.", {
192
+ validAt: "2024-01-01T00:00:00.000Z",
193
+ invalidAt: "2025-06-01T00:00:00.000Z",
194
+ observedAt: "2025-06-20T00:00:00.000Z",
195
+ eventTimeSource: "extracted",
196
+ });
197
+ const mems = await storage.readAllMemories();
198
+ const mem = mems.find((m) => m.frontmatter.id === id);
199
+ assert.ok(mem);
200
+ assert.equal(mem!.frontmatter.valid_at, "2024-01-01T00:00:00.000Z");
201
+ assert.equal(mem!.frontmatter.invalid_at, "2025-06-01T00:00:00.000Z");
202
+ assert.equal(mem!.frontmatter.observedAt, "2025-06-20T00:00:00.000Z");
203
+ assert.equal(mem!.frontmatter.eventTimeSource, "extracted");
204
+ } finally {
205
+ await rm(dir, { recursive: true, force: true });
206
+ }
207
+ });
208
+
209
+ test("resolveFactEventTime: 'through 2026' → validUntil only (open-ended end bound)", () => {
210
+ const result = resolveFactEventTime("through 2026", "2025-06-20T00:00:00.000Z");
211
+ assert.equal(result.eventTimeSource, "extracted");
212
+ assert.equal(result.validFrom, undefined);
213
+ assert.ok(result.validUntil, "validUntil must be set for an end-bound expression");
214
+ // "through 2026" → exclusive end = start of 2027.
215
+ assert.equal(result.validUntil, "2027-01-01T00:00:00.000Z");
216
+ });
217
+ test("storage: writeChunk propagates invalidAt/observedAt/eventTimeSource from the parent fact (#1578 PR2)", async () => {
218
+ // Proves the cursor-bugbot fix: an independently-surfaced chunk expires at the
219
+ // same invalid_at window as its parent so recall's isValidityExpiredNow check
220
+ // fires on chunks too, not just the chunked parent write.
221
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-et-chunk-invalid-"));
222
+ try {
223
+ const storage = new StorageManager(dir);
224
+ await storage.ensureDirectories();
225
+ const parentId = await storage.writeMemory("fact", "We used MongoDB until June 2025.", {
226
+ validAt: "2024-01-01T00:00:00.000Z",
227
+ invalidAt: "2025-06-01T00:00:00.000Z",
228
+ observedAt: "2025-06-20T00:00:00.000Z",
229
+ eventTimeSource: "extracted",
230
+ });
231
+ const chunkId = await storage.writeChunk(
232
+ parentId,
233
+ 0,
234
+ 1,
235
+ "fact",
236
+ "We used MongoDB until June 2025.",
237
+ {
238
+ validAt: "2024-01-01T00:00:00.000Z",
239
+ invalidAt: "2025-06-01T00:00:00.000Z",
240
+ observedAt: "2025-06-20T00:00:00.000Z",
241
+ eventTimeSource: "extracted",
242
+ },
243
+ );
244
+ const mems = await storage.readAllMemories();
245
+ const chunk = mems.find((m) => m.frontmatter.id === chunkId);
246
+ assert.ok(chunk, "chunk must be readable");
247
+ assert.equal(chunk!.frontmatter.valid_at, "2024-01-01T00:00:00.000Z");
248
+ assert.equal(chunk!.frontmatter.invalid_at, "2025-06-01T00:00:00.000Z",
249
+ "chunk must carry the parent's end bound so it expires independently");
250
+ assert.equal(chunk!.frontmatter.observedAt, "2025-06-20T00:00:00.000Z");
251
+ assert.equal(chunk!.frontmatter.eventTimeSource, "extracted");
252
+ } finally {
253
+ await rm(dir, { recursive: true, force: true });
254
+ }
255
+ });
256
+ test("resolveFactEventTime: 'until 2025-06' (YYYY-MM) → invalidAt = start of following month (#1578 r3)", () => {
257
+ const result = resolveFactEventTime("until 2025-06", "2025-06-20T00:00:00.000Z");
258
+ assert.equal(result.eventTimeSource, "extracted");
259
+ assert.equal(result.validFrom, undefined);
260
+ assert.equal(result.validUntil, "2025-07-01T00:00:00.000Z",
261
+ "YYYY-MM end bound must span the whole month, not fall back to assumed");
262
+ });
263
+
264
+ test("resolveFactEventTime: bare '2025-03' (YYYY-MM) → validFrom = first of month (#1578 r3)", () => {
265
+ const result = resolveFactEventTime("2025-03", "2025-06-20T00:00:00.000Z");
266
+ assert.equal(result.eventTimeSource, "extracted");
267
+ assert.equal(result.validFrom, "2025-03-01T00:00:00.000Z");
268
+ assert.equal(result.validUntil, undefined);
269
+ });
package/src/extraction.ts CHANGED
@@ -279,6 +279,12 @@ export class ExtractionEngine {
279
279
  typeof f?.quote === "string" && f.quote.trim().length > 0
280
280
  ? f.quote
281
281
  : undefined,
282
+ eventTime:
283
+ typeof f?.eventTime === "string" && f.eventTime.trim().length > 0
284
+ ? f.eventTime.trim()
285
+ : typeof f?.event_time === "string" && f.event_time.trim().length > 0
286
+ ? f.event_time.trim()
287
+ : undefined,
282
288
  }))
283
289
  .filter((f: any) => f.content.length > 0)
284
290
  : [];
@@ -750,6 +756,11 @@ export class ExtractionEngine {
750
756
  this.config.provenance?.enabled
751
757
  ? '- Source quotes: For each fact, include a "quote" field with the EXACT verbatim words from the conversation that support the fact (a contiguous span from a single turn, not a paraphrase). Cap at ~300 chars.'
752
758
  : "",
759
+ // #1578: emit the same event-time guidance as the primary extraction
760
+ // paths so proactive-recovered facts also carry an optional eventTime
761
+ // (chatgpt-codex thread on extraction.ts:1607). Returns "" when the
762
+ // bi-temporal gate is off, keeping the prompt unchanged by default.
763
+ this.eventTimePromptInstruction(),
753
764
  "",
754
765
  "Base extracted facts (do not repeat):",
755
766
  factsPreview || "(none)",
@@ -1250,10 +1261,23 @@ export class ExtractionEngine {
1250
1261
  // reasoningTrace through normalizeReasoningTrace before passing it on so
1251
1262
  // gateway output matches the shape local/direct-client paths produce.
1252
1263
  const normalizedFacts = result.facts.map((f: any) => {
1253
- if (!f?.reasoningTrace) return f;
1264
+ if (!f) return f;
1265
+ // Gateway tolerance: collapse snake_case event_time → camelCase
1266
+ // eventTime so the gateway path matches the local/direct/proactive
1267
+ // normalization (#1578 r3 — cursor bugbot).
1268
+ const eventTime =
1269
+ typeof f.eventTime === "string" && f.eventTime.trim().length > 0
1270
+ ? f.eventTime.trim()
1271
+ : typeof f.event_time === "string" && f.event_time.trim().length > 0
1272
+ ? f.event_time.trim()
1273
+ : undefined;
1274
+ if (!f.reasoningTrace && eventTime === undefined) return f;
1254
1275
  return {
1255
1276
  ...f,
1256
- reasoningTrace: normalizeReasoningTrace(f.reasoningTrace) ?? undefined,
1277
+ ...(f.reasoningTrace
1278
+ ? { reasoningTrace: normalizeReasoningTrace(f.reasoningTrace) ?? undefined }
1279
+ : {}),
1280
+ ...(eventTime !== undefined ? { eventTime } : {}),
1257
1281
  };
1258
1282
  });
1259
1283
  const sanitized = this.sanitizeExtractionResult({
@@ -1394,7 +1418,7 @@ Examples of when to add structuredAttributes:
1394
1418
  - Decisions: {"chosen": "PostgreSQL", "rejected": "MongoDB", "reason": "ACID compliance"}
1395
1419
  - Quantities/measurements: {"budget": "50000", "team_size": "5", "deadline": "2024-06-01"}
1396
1420
  Only add structuredAttributes when there are concrete values. Skip for abstract or narrative facts.
1397
-
1421
+ ${this.eventTimePromptInstruction()}
1398
1422
  Also generate:
1399
1423
  1. 1-3 genuine questions you're curious about from this conversation
1400
1424
  2. Profile updates about user patterns/behaviors (if any)
@@ -1595,6 +1619,28 @@ ${truncatedConversation}`;
1595
1619
  return { facts, entities, profileUpdates: [], questions: [] };
1596
1620
  }
1597
1621
 
1622
+ /**
1623
+ * Bi-temporal event-time extraction instruction (#1578 PR2). Emitted on
1624
+ * every extraction entry path when `temporal.biTemporal` is on so the LLM
1625
+ * emits an optional per-fact `eventTime` expression. The expression is
1626
+ * resolved against the source turn timestamp at write time — never
1627
+ * wall-clock — so replay/import of old transcripts anchors correctly.
1628
+ * Returns an empty string when the gate is off (byte-identical prompt).
1629
+ */
1630
+ private eventTimePromptInstruction(): string {
1631
+ if (!this.config.temporalBiTemporal) return "";
1632
+ return `
1633
+ === Event Time (bi-temporal) ===
1634
+ When a fact has an explicit temporal anchor — a date, month, season, or relative time expression stating WHEN the fact became (or stopped being) true — capture it verbatim in an "eventTime" field on that fact. Examples:
1635
+ - "We moved offices in March" → "eventTime": "last March"
1636
+ - "The API has been rate-limited since 2024" → "eventTime": "since 2024"
1637
+ - "I switched to PostgreSQL on 2025-01-15" → "eventTime": "2025-01-15"
1638
+ - "We used MongoDB until June 2025" → "eventTime": "until 2025-06"
1639
+ Accepted forms: ISO dates ("2025-03-01"), year-month ("2025-03"), month/season + year ("March 2025", "summer 2024"), relative ("yesterday", "last week", "this month", "next year", "last December"), and open-ended ("since 2024", "until 2025-06").
1640
+ Omit "eventTime" when the fact has no explicit temporal anchor — do NOT guess or infer dates. The system resolves the expression against the conversation's own timestamp, not today's date.
1641
+ `;
1642
+ }
1643
+
1598
1644
  /**
1599
1645
  * Build extraction instructions shared between local and cloud LLM.
1600
1646
  */
@@ -1663,6 +1709,7 @@ ${existingEntities.join(", ")}
1663
1709
 
1664
1710
  When you see something that matches a known entity, use THAT name exactly. Only create a NEW entity if nothing in this list represents it.
1665
1711
  ` : ""}
1712
+ ${this.eventTimePromptInstruction()}
1666
1713
  Also extract relationships between entities mentioned in the conversation.
1667
1714
  - Format: {source: "entity-name", target: "entity-name", label: "relationship description"}
1668
1715
  - Max 5 relationships per extraction
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Issue #1492 — Remnic Coding mode inconsistencies.
3
+ *
4
+ * Cross-subsystem characterization of the three coding-mode drifts an external
5
+ * reporter found when wiring per-repo/project namespace isolation for a shared
6
+ * multi-user Remnic deployment. Each reported symptom was fixed by a dedicated
7
+ * child issue; this file is the single #1492-named anchor that ties them
8
+ * together so a regression in ANY of the three subsystems is caught and
9
+ * attributed here.
10
+ *
11
+ * Symptom A (observe handler ignored coding context)
12
+ * → fixed by #1495: observe resolves ONE effective scope plan
13
+ * (`resolveMemoryScopePlan`) whose `writeNamespace` is byte-identical to
14
+ * what `memory_store`/`suggestion_submit` resolve via
15
+ * `resolveCodingScopedWriteNamespace`, so observed turns and explicit
16
+ * writes on the same project-scoped session land in the SAME store a
17
+ * same-session recall reads. Deep plan-level parity is guarded by
18
+ * `access-service-observe-scope.test.ts` (#1495). Here we pin the
19
+ * coding-namespace derivation invariant the drift violated: the scoped
20
+ * namespace is built by exactly ONE `combineNamespaces(principal, overlay)`
21
+ * and is stable/idempotent — never a second ad-hoc combination that could
22
+ * drift between the write and read paths.
23
+ *
24
+ * Symptom B (transcript/summarizer session-key parser conflated sessions)
25
+ * → fixed by #1496: arbitrary session keys (`pi-geek:abc123`, `pi:abc123`,
26
+ * `myuser:abc123`) are first-class `session/<hash>` identities — never the
27
+ * legacy `other/default` bucket — for BOTH transcript.ts and summarizer.ts.
28
+ *
29
+ * Symptom C (collectActiveMemoryPaths scanned only 4 of 12+ category dirs)
30
+ * → fixed by #1497: the QMD-unavailable filesystem fallback recall scans
31
+ * `RECALL_FALLBACK_DIRS` (every recall category dir), so a memory written
32
+ * to e.g. `preferences/` is found when QMD is disabled/missing/unhealthy.
33
+ *
34
+ * The architectural gaps the reporter also called out — the maintenance
35
+ * pipeline being blind to dynamic project-scoped namespaces (#1500), and the
36
+ * admin/webui not surfacing coding context (#1502) — were tracked by their own
37
+ * issues and are both CLOSED.
38
+ */
39
+
40
+ import assert from "node:assert/strict";
41
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
42
+ import type { Dirent } from "node:fs";
43
+ import os from "node:os";
44
+ import path from "node:path";
45
+ import test from "node:test";
46
+
47
+ import {
48
+ combineNamespaces,
49
+ projectNamespaceName,
50
+ projectTagProjectId,
51
+ resolveCodingNamespaceOverlay,
52
+ } from "./coding/coding-namespace.js";
53
+ import {
54
+ SESSION_CHANNEL_TYPE,
55
+ parseSessionIdentity,
56
+ sessionStoragePaths,
57
+ } from "./session-identity.js";
58
+ import { HourlySummarizer } from "./summarizer.js";
59
+ import type { HourlySummary, PluginConfig } from "./types.js";
60
+ import { StorageManager } from "./storage.js";
61
+ import { RECALL_FALLBACK_DIRS } from "./utils/category-dir.js";
62
+
63
+ /** Recursively collect the immediate child directory names under `root` that
64
+ * contain at least one file, into `out`. Used to verify the summarizer wrote
65
+ * each session's summary into its OWN dir (no conflation). */
66
+ async function walkCollectDirs(root: string, out: Set<string>): Promise<void> {
67
+ let entries: Dirent[];
68
+ try {
69
+ entries = await readdir(root, { withFileTypes: true });
70
+ } catch {
71
+ return;
72
+ }
73
+ for (const entry of entries) {
74
+ if (!entry.isDirectory()) continue;
75
+ const child = path.join(root, entry.name);
76
+ let childEntries: Dirent[];
77
+ try {
78
+ childEntries = await readdir(child, { withFileTypes: true });
79
+ } catch {
80
+ continue;
81
+ }
82
+ if (childEntries.some((e) => e.isFile())) {
83
+ out.add(entry.name);
84
+ } else {
85
+ await walkCollectDirs(child, out);
86
+ }
87
+ }
88
+ }
89
+
90
+ // ─── Symptom A: coding-namespace derivation is stable across write/read ────
91
+
92
+ test("#1492 Symptom A: a project-scoped session derives ONE stable overlay namespace — the write/read convergence point", () => {
93
+ // The reporter's scenario: user "pi-geek" in project "Blend/Supply". Both the
94
+ // observe write path and the recall read path must resolve the SAME scoped
95
+ // namespace. They do so by combining the principal base with the project
96
+ // overlay through exactly ONE helper (combineNamespaces); a second ad-hoc
97
+ // combination is what caused the original drift.
98
+ const principal = "pi-geek";
99
+ const projectId = projectTagProjectId("Blend/Supply");
100
+ const codingContext = {
101
+ projectId,
102
+ branch: null,
103
+ rootPath: projectId,
104
+ defaultBranch: null,
105
+ };
106
+ const overlay = resolveCodingNamespaceOverlay(
107
+ codingContext,
108
+ { projectScope: true, branchScope: false, globalFallback: false },
109
+ "default",
110
+ );
111
+ assert.ok(overlay, "projectScope=true must yield a coding overlay");
112
+
113
+ const scopedWrite = combineNamespaces(principal, overlay.namespace);
114
+ const scopedRead = combineNamespaces(principal, overlay.namespace);
115
+
116
+ // Write and read converge on the identical scoped namespace.
117
+ assert.equal(scopedRead, scopedWrite);
118
+ // It is well-formed: principal base + project overlay, not the flat default.
119
+ assert.equal(scopedWrite, combineNamespaces(principal, projectNamespaceName(projectId)));
120
+ assert.ok(
121
+ scopedWrite.startsWith(`${principal}-`),
122
+ "scoped namespace must be principal-prefixed, preserving per-user isolation",
123
+ );
124
+ assert.notEqual(scopedWrite, principal, "must layer the project overlay onto the principal base");
125
+ assert.notEqual(scopedWrite, "default", "must not collapse to the flat default namespace");
126
+ // Two principals in the same repo get distinct scoped namespaces.
127
+ const other = combineNamespaces("pi-friend", overlay.namespace);
128
+ assert.notEqual(scopedWrite, other, "per-user isolation: distinct principals stay distinct");
129
+ });
130
+
131
+ test("#1492 Symptom A: combineNamespaces is idempotent under re-derivation (no double-combination drift)", () => {
132
+ // A read path that (incorrectly) re-combined an already-scoped namespace with
133
+ // the overlay would produce `pi-friend-project-x-project-x` and miss the data.
134
+ // Guard the single-combination contract: combineNamespaces joins base+overlay
135
+ // with `-`, so re-combining must change the result (drift detector).
136
+ const principal = "pi-friend";
137
+ const overlayNs = projectNamespaceName(projectTagProjectId("Remnic"));
138
+ const once = combineNamespaces(principal, overlayNs);
139
+ const twice = combineNamespaces(once, overlayNs);
140
+ assert.notEqual(once, twice, "re-combining a scoped namespace must change it (drift detector)");
141
+ assert.ok(once.startsWith(`${principal}-`), "scoped namespace is principal-prefixed");
142
+ assert.ok(!once.includes(`${overlayNs}-${overlayNs}`), "no duplicated overlay fragment");
143
+ });
144
+
145
+ // ─── Symptom B: arbitrary session keys never conflate ──────────────────────
146
+
147
+ test("#1492 Symptom B: arbitrary session keys get distinct transcript AND summarizer paths, never other/default", async () => {
148
+ // Exact keys from the issue body. Pre-fix these all collapsed into
149
+ // transcripts/other/default/*.jsonl AND summaries/other/default/*.md,
150
+ // conflating every non-agent-prefixed session in BOTH subsystems.
151
+ const keys = ["pi-geek:abc123", "pi:abc123", "myuser:abc123", "pi-friend:def456"];
152
+
153
+ // ── Transcript path (sessionStoragePaths, consumed by transcript.ts) ──
154
+ const transcriptDirs = new Set<string>();
155
+ for (const key of keys) {
156
+ const id = parseSessionIdentity(key);
157
+ assert.equal(id.legacy, false, `${key} must not be treated as a legacy agent: key`);
158
+ assert.equal(id.channelType, SESSION_CHANNEL_TYPE, `${key} channelType must be "session"`);
159
+ assert.notEqual(id.channelType, "other", `${key} must not fall back to "other"`);
160
+ assert.notEqual(id.channelId, "default", `${key} must not fall back to "default"`);
161
+ const paths = sessionStoragePaths(key);
162
+ assert.match(paths.dir, /^session\/[0-9a-f]{16}$/, `${key} transcript dir must be session/<hash>`);
163
+ transcriptDirs.add(paths.dir);
164
+ }
165
+ assert.equal(transcriptDirs.size, keys.length, "each session key must map to a distinct transcript dir");
166
+
167
+ // ── Summarizer path: exercise the REAL HourlySummarizer.saveSummary() so a
168
+ // regression in summarizer.ts itself (reverting to the old parsed-session
169
+ // bucket) is caught here, not just a regression in session-identity.ts.
170
+ // Pre-fix the summarizer derived its dir from the same brittle split(':')
171
+ // parser; this round-trip proves the written summary files land in
172
+ // distinct per-session dirs (no other/default conflation). ──
173
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "engram-1492-sum-"));
174
+ try {
175
+ const config = {
176
+ memoryDir,
177
+ localLlmEnabled: false,
178
+ localLlmFallback: false,
179
+ localLlmUrl: "http://localhost:1234/v1",
180
+ localLlmModel: "local-model",
181
+ } as PluginConfig;
182
+ const summarizer = new HourlySummarizer(config);
183
+
184
+ const summary: HourlySummary = {
185
+ hour: "2026-06-28T14:00:00.000Z",
186
+ sessionKey: "", // set per key below
187
+ bullets: ["worked on shared memory pool"],
188
+ turnCount: 2,
189
+ generatedAt: "2026-06-28T14:05:00.000Z",
190
+ };
191
+
192
+ for (const key of keys) {
193
+ await summarizer.saveSummary({ ...summary, sessionKey: key });
194
+ }
195
+
196
+ // Walk summaries/hourly/ and collect the distinct per-session dirs that
197
+ // actually received a file. Each must be the encoded session key, never
198
+ // "other" or "default".
199
+ const hourlyRoot = path.join(memoryDir, "summaries", "hourly");
200
+ const sessionDirs = new Set<string>();
201
+ await walkCollectDirs(hourlyRoot, sessionDirs);
202
+ assert.ok(sessionDirs.size >= keys.length, "each session key must produce a distinct summary dir");
203
+ for (const dir of sessionDirs) {
204
+ assert.notEqual(dir, "other", `summary dir "${dir}" must not be the legacy "other" bucket`);
205
+ assert.notEqual(dir, "default", `summary dir "${dir}" must not be the legacy "default" bucket`);
206
+ }
207
+ } finally {
208
+ await rm(memoryDir, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ test("#1492 Symptom B: legacy agent: keys keep their readable identity (no regression from the fix)", () => {
213
+ // The fix must not break the legacy 5-part agent:<id>:discord:channel:<chan> shape.
214
+ const id = parseSessionIdentity("agent:generalist:discord:channel:998877");
215
+ assert.equal(id.legacy, true);
216
+ assert.equal(id.channelType, "discord");
217
+ assert.equal(id.channelId, "998877");
218
+ });
219
+
220
+ // ─── Symptom C: fallback recall reads EVERY category dir ───────────────────
221
+
222
+ function memoryFile(id: string, category: string, content: string): string {
223
+ const now = new Date().toISOString();
224
+ return [
225
+ "---",
226
+ `id: ${id}`,
227
+ `category: ${category}`,
228
+ `created: ${now}`,
229
+ `updated: ${now}`,
230
+ "confidence: 0.9",
231
+ "importance: 5",
232
+ "tags: []",
233
+ "---",
234
+ "",
235
+ content,
236
+ "",
237
+ ].join("\n");
238
+ }
239
+
240
+ async function makeStorage(prefix = "engram-1492-"): Promise<{
241
+ storage: StorageManager;
242
+ baseDir: string;
243
+ cleanup: () => Promise<void>;
244
+ }> {
245
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), prefix));
246
+ const storage = new StorageManager(baseDir);
247
+ await storage.ensureDirectories();
248
+ StorageManager.clearAllStaticCaches();
249
+ storage.invalidateAllMemoriesCacheForDir();
250
+ return {
251
+ storage,
252
+ baseDir,
253
+ cleanup: async () => {
254
+ StorageManager.clearAllStaticCaches();
255
+ await rm(baseDir, { recursive: true, force: true });
256
+ },
257
+ };
258
+ }
259
+
260
+ test("#1492 Symptom C: a memory in a non-legacy-4 category (preferences/) is found by fallback disk recall", async () => {
261
+ // The reporter found collectActiveMemoryPaths scanned only
262
+ // facts/procedures/reasoning-traces/corrections. A memory written to
263
+ // preferences/ was invisible to the QMD-unavailable fallback, so recall
264
+ // returned empty even though the file was on disk.
265
+ const { storage, baseDir, cleanup } = await makeStorage();
266
+ try {
267
+ const preferencesDir = path.join(baseDir, "preferences");
268
+ await mkdir(preferencesDir, { recursive: true });
269
+ await writeFile(
270
+ path.join(preferencesDir, "pref-1.md"),
271
+ memoryFile("pref-1", "preference", "Prefer tabs over spaces in this repo."),
272
+ "utf-8",
273
+ );
274
+ storage.invalidateAllMemoriesCacheForDir();
275
+
276
+ const memories = await storage.readAllMemories();
277
+ const ids = memories.map((m) => m.frontmatter?.id ?? "");
278
+ assert.ok(
279
+ ids.includes("pref-1"),
280
+ "fallback disk recall must read preferences/ — the 4-dir scan bug is fixed",
281
+ );
282
+ } finally {
283
+ await cleanup();
284
+ }
285
+ });
286
+
287
+ test("#1492 Symptom C: RECALL_FALLBACK_DIRS covers the categories the issue named as missed", () => {
288
+ // The issue enumerated questions/, entities/, decisions/, preferences/,
289
+ // commitments/, principles/, skills/, relationships/, moments/, rules/,
290
+ // artifacts/ as directories the system writes to but the old 4-dir scan
291
+ // missed. Assert the recall-memory categories among them are now covered.
292
+ // (questions/ is intentionally a non-memory queue dir; artifacts/ and
293
+ // entities/ are non-category content dirs — see category-dir.ts. The recall
294
+ // *memory* categories must all be present.)
295
+ const requiredRecallCategories = [
296
+ "decisions",
297
+ "preferences",
298
+ "commitments",
299
+ "principles",
300
+ "skills",
301
+ "relationships",
302
+ "moments",
303
+ "rules",
304
+ ];
305
+ for (const dir of requiredRecallCategories) {
306
+ assert.ok(
307
+ RECALL_FALLBACK_DIRS.includes(dir),
308
+ `RECALL_FALLBACK_DIRS must include ${dir}/ (missed by the pre-fix 4-dir scan)`,
309
+ );
310
+ }
311
+ // The legacy four are still present.
312
+ for (const dir of ["facts", "procedures", "reasoning-traces", "corrections"]) {
313
+ assert.ok(RECALL_FALLBACK_DIRS.includes(dir), `legacy recall dir ${dir}/ must remain covered`);
314
+ }
315
+ });