@remnic/core 9.3.652 → 9.3.654

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 (249) hide show
  1. package/dist/access-cli.js +17 -17
  2. package/dist/access-http.d.ts +4 -4
  3. package/dist/access-http.js +11 -11
  4. package/dist/access-mcp.d.ts +4 -4
  5. package/dist/access-mcp.js +10 -10
  6. package/dist/access-schema.d.ts +15 -12
  7. package/dist/access-schema.js +1 -1
  8. package/dist/{access-service-CdJFd3_b.d.ts → access-service-C8A5hoXJ.d.ts} +11 -2
  9. package/dist/access-service.d.ts +4 -4
  10. package/dist/access-service.js +8 -8
  11. package/dist/action-confidence.d.ts +1 -1
  12. package/dist/active-memory-bridge.d.ts +1 -1
  13. package/dist/active-recall.d.ts +1 -1
  14. package/dist/active-recall.js +1 -1
  15. package/dist/behavior-learner.d.ts +1 -1
  16. package/dist/behavior-signals.d.ts +1 -1
  17. package/dist/bootstrap.d.ts +3 -3
  18. package/dist/briefing.d.ts +1 -1
  19. package/dist/briefing.js +3 -3
  20. package/dist/buffer-surprise-report.d.ts +1 -1
  21. package/dist/buffer.d.ts +1 -1
  22. package/dist/calibration.d.ts +1 -1
  23. package/dist/causal-behavior.d.ts +1 -1
  24. package/dist/causal-consolidation.d.ts +1 -1
  25. package/dist/causal-consolidation.js +4 -4
  26. package/dist/{chunk-KJDKZVF3.js → chunk-2DSTAWNZ.js} +3 -3
  27. package/dist/chunk-3RACUBII.js +212 -0
  28. package/dist/chunk-3RACUBII.js.map +1 -0
  29. package/dist/{chunk-Y7NWBBHV.js → chunk-6CVI6BP6.js} +2 -2
  30. package/dist/{chunk-R3PQUPQ4.js → chunk-6IMKOIZ6.js} +85 -3
  31. package/dist/chunk-6IMKOIZ6.js.map +1 -0
  32. package/dist/{chunk-WTI35CVJ.js → chunk-BJA6DQOC.js} +5 -5
  33. package/dist/{chunk-GI45G4BK.js → chunk-BP2EV6W5.js} +3 -3
  34. package/dist/{chunk-WLGE6KEO.js → chunk-DBM2BD22.js} +3 -3
  35. package/dist/{chunk-IENGGY2C.js → chunk-ENV6RDTD.js} +2 -2
  36. package/dist/{chunk-BEMWL2FZ.js → chunk-FVRBLJP6.js} +2 -2
  37. package/dist/{chunk-H3PHZLMF.js → chunk-GKKAXVAJ.js} +20 -11
  38. package/dist/chunk-GKKAXVAJ.js.map +1 -0
  39. package/dist/{chunk-MGGNV3H2.js → chunk-GPW2E4LN.js} +23 -8
  40. package/dist/chunk-GPW2E4LN.js.map +1 -0
  41. package/dist/{chunk-KWM33SPU.js → chunk-JMQSYGXS.js} +2 -2
  42. package/dist/{chunk-WSFNYPAT.js → chunk-JYN7QNTA.js} +87 -18
  43. package/dist/chunk-JYN7QNTA.js.map +1 -0
  44. package/dist/{chunk-AJE7FJVE.js → chunk-K6X553JB.js} +2 -2
  45. package/dist/{chunk-5V3TAB7D.js → chunk-LJCEWTG3.js} +19 -8
  46. package/dist/{chunk-5V3TAB7D.js.map → chunk-LJCEWTG3.js.map} +1 -1
  47. package/dist/{chunk-YOVKPOMD.js → chunk-NAZWHTYV.js} +13 -6
  48. package/dist/chunk-NAZWHTYV.js.map +1 -0
  49. package/dist/{chunk-XMN6MMTU.js → chunk-NCGWXCSW.js} +2 -2
  50. package/dist/{chunk-C43KEWEV.js → chunk-NE2JBMLN.js} +1 -1
  51. package/dist/chunk-NE2JBMLN.js.map +1 -0
  52. package/dist/{chunk-TCX4WLKK.js → chunk-OL2364SB.js} +2020 -368
  53. package/dist/chunk-OL2364SB.js.map +1 -0
  54. package/dist/{chunk-JF7SFXTG.js → chunk-QKK64Z6M.js} +2 -2
  55. package/dist/{chunk-IVYSVAC6.js → chunk-QW6JZO5P.js} +2 -2
  56. package/dist/{chunk-EHQLDFSH.js → chunk-RGPUQ66K.js} +2 -2
  57. package/dist/{chunk-CFOCZPIQ.js → chunk-T2C6QJG2.js} +2 -2
  58. package/dist/{chunk-4HYSMH7D.js → chunk-UAU5U5ML.js} +3 -2
  59. package/dist/chunk-UAU5U5ML.js.map +1 -0
  60. package/dist/{chunk-V4UDXYGG.js → chunk-XWQ6ERUG.js} +2 -2
  61. package/dist/{chunk-IJHLC5CH.js → chunk-Y2RIIF6H.js} +32 -22
  62. package/dist/{chunk-IJHLC5CH.js.map → chunk-Y2RIIF6H.js.map} +1 -1
  63. package/dist/{chunk-C63WC454.js → chunk-YLZLPVKK.js} +22 -1
  64. package/dist/chunk-YLZLPVKK.js.map +1 -0
  65. package/dist/{chunk-RZOBQ23O.js → chunk-Z5MQI7K2.js} +2 -2
  66. package/dist/{chunk-PRQXUSQV.js → chunk-ZCORQM74.js} +2 -2
  67. package/dist/{cli-DDo7Qgs-.d.ts → cli-uQgvDFNE.d.ts} +3 -3
  68. package/dist/cli.d.ts +5 -5
  69. package/dist/cli.js +23 -23
  70. package/dist/compounding/engine.d.ts +1 -1
  71. package/dist/compounding/engine.js +3 -3
  72. package/dist/compounding/preference-consolidator.d.ts +1 -1
  73. package/dist/compression-optimizer.d.ts +1 -1
  74. package/dist/config.d.ts +1 -1
  75. package/dist/config.js +1 -1
  76. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  77. package/dist/connectors/codex-materialize-runner.js +3 -3
  78. package/dist/connectors/codex-materialize.d.ts +1 -1
  79. package/dist/connectors/index.d.ts +1 -1
  80. package/dist/connectors/index.js +3 -3
  81. package/dist/consolidation-provenance-check.d.ts +1 -1
  82. package/dist/consolidation-undo.d.ts +1 -1
  83. package/dist/contradiction/index.d.ts +19 -1
  84. package/dist/contradiction/index.js +1 -1
  85. package/dist/conversation-index/backend.d.ts +1 -1
  86. package/dist/conversation-index/chunker.d.ts +1 -1
  87. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  88. package/dist/conversation-index/indexer.d.ts +1 -1
  89. package/dist/conversation-index/search.d.ts +1 -1
  90. package/dist/day-summary.d.ts +1 -1
  91. package/dist/delinearize.d.ts +1 -1
  92. package/dist/direct-answer-wiring.d.ts +1 -1
  93. package/dist/direct-answer.d.ts +1 -1
  94. package/dist/embedding-fallback.d.ts +1 -1
  95. package/dist/enrichment/index.d.ts +1 -1
  96. package/dist/entity-retrieval.d.ts +1 -1
  97. package/dist/entity-retrieval.js +3 -3
  98. package/dist/entity-schema.d.ts +1 -1
  99. package/dist/explicit-capture.d.ts +3 -3
  100. package/dist/explicit-capture.js +1 -1
  101. package/dist/extraction-judge-telemetry.d.ts +1 -1
  102. package/dist/extraction-judge-training.d.ts +1 -1
  103. package/dist/extraction-judge.d.ts +1 -1
  104. package/dist/extraction.d.ts +1 -1
  105. package/dist/fallback-llm.d.ts +1 -1
  106. package/dist/identity-continuity.d.ts +1 -1
  107. package/dist/importance.d.ts +1 -1
  108. package/dist/index.d.ts +8 -8
  109. package/dist/index.js +31 -29
  110. package/dist/index.js.map +1 -1
  111. package/dist/intent.d.ts +1 -1
  112. package/dist/lcm/engine.d.ts +1 -1
  113. package/dist/lcm/index.d.ts +1 -1
  114. package/dist/lcm/tools.d.ts +1 -1
  115. package/dist/lifecycle.d.ts +1 -1
  116. package/dist/live-connectors-runner.d.ts +1 -1
  117. package/dist/local-llm.d.ts +1 -1
  118. package/dist/maintenance/memory-governance.d.ts +1 -1
  119. package/dist/maintenance/memory-governance.js +3 -3
  120. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  121. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  122. package/dist/mcp-memory-inspector-app.d.ts +4 -4
  123. package/dist/memory-action-policy.d.ts +1 -1
  124. package/dist/memory-cache.d.ts +1 -1
  125. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  126. package/dist/memory-projection-store.d.ts +1 -1
  127. package/dist/memory-provenance.d.ts +1 -1
  128. package/dist/memory-worth-outcomes.d.ts +1 -1
  129. package/dist/models-json.d.ts +1 -1
  130. package/dist/namespaces/migrate.d.ts +1 -1
  131. package/dist/namespaces/migrate.js +4 -4
  132. package/dist/namespaces/principal.d.ts +1 -1
  133. package/dist/namespaces/search.d.ts +1 -1
  134. package/dist/namespaces/storage.d.ts +52 -3
  135. package/dist/namespaces/storage.js +9 -5
  136. package/dist/native-knowledge.d.ts +1 -1
  137. package/dist/operator-toolkit.d.ts +1 -1
  138. package/dist/operator-toolkit.js +7 -7
  139. package/dist/{orchestrator-8fTZsa0y.d.ts → orchestrator-B4Y4sWQH.d.ts} +503 -3
  140. package/dist/orchestrator.d.ts +3 -3
  141. package/dist/orchestrator.js +13 -13
  142. package/dist/patterns-cli.d.ts +1 -1
  143. package/dist/policy-runtime.d.ts +1 -1
  144. package/dist/qmd-recall-cache.d.ts +1 -1
  145. package/dist/qmd.d.ts +1 -1
  146. package/dist/recall-disclosure-escalation.d.ts +1 -1
  147. package/dist/recall-explain-renderer.d.ts +1 -1
  148. package/dist/recall-explain-renderer.js +3 -3
  149. package/dist/recall-planner-llm.d.ts +1 -1
  150. package/dist/recall-state.d.ts +1 -1
  151. package/dist/recall-tag-filter.d.ts +1 -1
  152. package/dist/recall-xray-cli.d.ts +1 -1
  153. package/dist/recall-xray-cli.js +4 -4
  154. package/dist/recall-xray-renderer.d.ts +1 -1
  155. package/dist/recall-xray-renderer.js +3 -3
  156. package/dist/recall-xray.d.ts +1 -1
  157. package/dist/recall-xray.js +2 -2
  158. package/dist/{resolution-3SAP4SH2.js → resolution-IDTEBJFS.js} +2 -2
  159. package/dist/resolve-auth-token.d.ts +1 -1
  160. package/dist/resume-bundles.js +2 -2
  161. package/dist/retrieval-agents.d.ts +1 -1
  162. package/dist/retrieval-tiers.d.ts +1 -1
  163. package/dist/routing/engine.d.ts +1 -1
  164. package/dist/routing/store.d.ts +1 -1
  165. package/dist/search/embed-helper.d.ts +1 -1
  166. package/dist/search/factory.d.ts +1 -1
  167. package/dist/search/index.d.ts +1 -1
  168. package/dist/search/lancedb-backend.d.ts +1 -1
  169. package/dist/search/meilisearch-backend.d.ts +1 -1
  170. package/dist/search/noop-backend.d.ts +1 -1
  171. package/dist/search/orama-backend.d.ts +1 -1
  172. package/dist/search/port.d.ts +1 -1
  173. package/dist/search/remote-backend.d.ts +1 -1
  174. package/dist/{semantic-consolidation-DKdYzQOg.d.ts → semantic-consolidation-BKd0Pype.d.ts} +1 -1
  175. package/dist/semantic-consolidation.d.ts +2 -2
  176. package/dist/semantic-consolidation.js +4 -4
  177. package/dist/semantic-rule-promotion.js +3 -3
  178. package/dist/semantic-rule-verifier.d.ts +1 -1
  179. package/dist/semantic-rule-verifier.js +3 -3
  180. package/dist/session-observer-bands.d.ts +1 -1
  181. package/dist/session-observer-state.d.ts +1 -1
  182. package/dist/shared-context/manager.d.ts +1 -1
  183. package/dist/signal.d.ts +1 -1
  184. package/dist/storage.d.ts +1 -1
  185. package/dist/storage.js +2 -2
  186. package/dist/summarizer.d.ts +1 -1
  187. package/dist/summary-snapshot.d.ts +1 -1
  188. package/dist/temporal-supersession.d.ts +1 -1
  189. package/dist/temporal-validity.d.ts +1 -1
  190. package/dist/threading.d.ts +1 -1
  191. package/dist/tier-migration.d.ts +1 -1
  192. package/dist/tier-routing.d.ts +1 -1
  193. package/dist/topics.d.ts +1 -1
  194. package/dist/transcript.d.ts +1 -1
  195. package/dist/{types-D8yUmSik.d.ts → types-BgChEr0M.d.ts} +11 -0
  196. package/dist/types.d.ts +1 -1
  197. package/dist/types.js +1 -1
  198. package/dist/utility-runtime.d.ts +1 -1
  199. package/dist/verified-recall.js +3 -3
  200. package/package.json +1 -1
  201. package/src/access-http.ts +7 -0
  202. package/src/access-mcp.test.ts +70 -1
  203. package/src/access-mcp.ts +19 -2
  204. package/src/access-schema.ts +1 -0
  205. package/src/access-service.ts +12 -0
  206. package/src/briefing.test.ts +70 -0
  207. package/src/briefing.ts +30 -20
  208. package/src/cli.ts +104 -0
  209. package/src/config.test.ts +40 -0
  210. package/src/config.ts +29 -0
  211. package/src/contradiction/contradiction.test.ts +284 -0
  212. package/src/contradiction/resolution.ts +151 -4
  213. package/src/explicit-capture.ts +31 -10
  214. package/src/index.ts +10 -0
  215. package/src/namespaces/catalog.test.ts +3356 -0
  216. package/src/namespaces/catalog.ts +2123 -0
  217. package/src/namespaces/storage.ts +210 -30
  218. package/src/orchestrator-flush.test.ts +300 -0
  219. package/src/orchestrator.ts +851 -240
  220. package/src/types.ts +11 -0
  221. package/dist/chunk-4HYSMH7D.js.map +0 -1
  222. package/dist/chunk-C43KEWEV.js.map +0 -1
  223. package/dist/chunk-C63WC454.js.map +0 -1
  224. package/dist/chunk-H3PHZLMF.js.map +0 -1
  225. package/dist/chunk-MGGNV3H2.js.map +0 -1
  226. package/dist/chunk-ORGWWNJG.js +0 -131
  227. package/dist/chunk-ORGWWNJG.js.map +0 -1
  228. package/dist/chunk-R3PQUPQ4.js.map +0 -1
  229. package/dist/chunk-TCX4WLKK.js.map +0 -1
  230. package/dist/chunk-WSFNYPAT.js.map +0 -1
  231. package/dist/chunk-YOVKPOMD.js.map +0 -1
  232. /package/dist/{chunk-KJDKZVF3.js.map → chunk-2DSTAWNZ.js.map} +0 -0
  233. /package/dist/{chunk-Y7NWBBHV.js.map → chunk-6CVI6BP6.js.map} +0 -0
  234. /package/dist/{chunk-WTI35CVJ.js.map → chunk-BJA6DQOC.js.map} +0 -0
  235. /package/dist/{chunk-GI45G4BK.js.map → chunk-BP2EV6W5.js.map} +0 -0
  236. /package/dist/{chunk-WLGE6KEO.js.map → chunk-DBM2BD22.js.map} +0 -0
  237. /package/dist/{chunk-IENGGY2C.js.map → chunk-ENV6RDTD.js.map} +0 -0
  238. /package/dist/{chunk-BEMWL2FZ.js.map → chunk-FVRBLJP6.js.map} +0 -0
  239. /package/dist/{chunk-KWM33SPU.js.map → chunk-JMQSYGXS.js.map} +0 -0
  240. /package/dist/{chunk-AJE7FJVE.js.map → chunk-K6X553JB.js.map} +0 -0
  241. /package/dist/{chunk-XMN6MMTU.js.map → chunk-NCGWXCSW.js.map} +0 -0
  242. /package/dist/{chunk-JF7SFXTG.js.map → chunk-QKK64Z6M.js.map} +0 -0
  243. /package/dist/{chunk-IVYSVAC6.js.map → chunk-QW6JZO5P.js.map} +0 -0
  244. /package/dist/{chunk-EHQLDFSH.js.map → chunk-RGPUQ66K.js.map} +0 -0
  245. /package/dist/{chunk-CFOCZPIQ.js.map → chunk-T2C6QJG2.js.map} +0 -0
  246. /package/dist/{chunk-V4UDXYGG.js.map → chunk-XWQ6ERUG.js.map} +0 -0
  247. /package/dist/{chunk-RZOBQ23O.js.map → chunk-Z5MQI7K2.js.map} +0 -0
  248. /package/dist/{chunk-PRQXUSQV.js.map → chunk-ZCORQM74.js.map} +0 -0
  249. /package/dist/{resolution-3SAP4SH2.js.map → resolution-IDTEBJFS.js.map} +0 -0
@@ -6,7 +6,7 @@ import {
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
- import { existsSync } from "node:fs";
9
+ import { existsSync, readFileSync } from "node:fs";
10
10
  import {
11
11
  mkdir,
12
12
  readdir,
@@ -297,8 +297,20 @@ import {
297
297
  type ConversationIndexBackendInspection,
298
298
  type ConversationQmdRuntime,
299
299
  } from "./conversation-index/backend.js";
300
- import { NamespaceStorageRouter } from "./namespaces/storage.js";
301
- import { namespaceIdentityFromToken } from "./namespaces/identity.js";
300
+ import {
301
+ NamespaceStorageRouter,
302
+ resolveNamespaceStorageRoot,
303
+ } from "./namespaces/storage.js";
304
+ import {
305
+ NamespaceCatalog,
306
+ hasMemoryData,
307
+ type NamespaceRecord,
308
+ } from "./namespaces/catalog.js";
309
+ import {
310
+ namespaceIdentityFromToken,
311
+ namespaceIdentityToken,
312
+ normalizeNamespaceIdentity,
313
+ } from "./namespaces/identity.js";
302
314
  import {
303
315
  canReadNamespace,
304
316
  defaultNamespaceForPrincipal,
@@ -327,6 +339,7 @@ import { parseFlexibleIsoTimestamp } from "./utils/iso-timestamp.js";
327
339
  import { TierMigrationExecutor } from "./tier-migration.js";
328
340
  import { decideTierTransition, type MemoryTier } from "./tier-routing.js";
329
341
  import {
342
+ isSafeRouteNamespace,
330
343
  selectRouteRule,
331
344
  type RouteRule,
332
345
  type RoutingEngineOptions,
@@ -1757,6 +1770,10 @@ export function resolvePersistedMemoryRelativePath(options: {
1757
1770
  export class Orchestrator {
1758
1771
  readonly storage: StorageManager;
1759
1772
  private readonly storageRouter: NamespaceStorageRouter;
1773
+ /** Rebuildable namespace catalog (issue #1499). Inert unless namespaces enabled. */
1774
+ readonly namespaceCatalog: NamespaceCatalog;
1775
+ private readonly namespaceStorageDirHints = new Map<string, Set<string>>();
1776
+ private namespaceStorageDirHintsLoaded = false;
1760
1777
  private readonly namespaceSearchRouter: NamespaceSearchRouter;
1761
1778
  qmd: SearchBackend;
1762
1779
  private readonly conversationQmd?: ConversationQmdRuntime;
@@ -2245,6 +2262,220 @@ export class Orchestrator {
2245
2262
  );
2246
2263
  }
2247
2264
 
2265
+ private rememberNamespaceStorageDirHint(namespace: string, storageDir?: string): void {
2266
+ if (!this.config.namespacesEnabled || !storageDir) return;
2267
+ const ns = normalizeNamespaceIdentity(namespace);
2268
+ if (!ns) return;
2269
+ const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
2270
+ if (ns !== defaultNs && !isSafeRouteNamespace(ns)) return;
2271
+
2272
+ if (!this.storageDirMatchesNamespaceHint(ns, storageDir)) return;
2273
+
2274
+ const resolvedStorageDir = path.resolve(storageDir);
2275
+ let hints = this.namespaceStorageDirHints.get(resolvedStorageDir);
2276
+ if (!hints) {
2277
+ hints = new Set<string>();
2278
+ this.namespaceStorageDirHints.set(resolvedStorageDir, hints);
2279
+ }
2280
+ hints.add(ns);
2281
+ }
2282
+
2283
+ private storageDirMatchesNamespaceHint(namespace: string, storageDir: string): boolean {
2284
+ const ns = normalizeNamespaceIdentity(namespace);
2285
+ if (!ns) return false;
2286
+
2287
+ const resolvedStorageDir = path.resolve(storageDir);
2288
+ const resolvedMemoryDir = path.resolve(this.config.memoryDir);
2289
+ const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
2290
+ if (resolvedStorageDir === resolvedMemoryDir) return ns === defaultNs;
2291
+
2292
+ const resolvedNamespacesDir = path.join(resolvedMemoryDir, "namespaces");
2293
+ if (!isPathInsideStorageRoot(resolvedNamespacesDir, resolvedStorageDir)) return false;
2294
+
2295
+ const rawRoot = path.resolve(resolvedNamespacesDir, ns);
2296
+ const tokenRoot = path.resolve(resolvedNamespacesDir, namespaceIdentityToken(ns));
2297
+ return resolvedStorageDir === rawRoot || resolvedStorageDir === tokenRoot;
2298
+ }
2299
+
2300
+ private namespaceStorageDirHintOwnershipRank(
2301
+ record: { namespace: string },
2302
+ resolvedStorageDir: string,
2303
+ configured: Set<string>,
2304
+ ): number {
2305
+ if (resolvedStorageDir === path.resolve(this.config.memoryDir)) {
2306
+ return record.namespace === normalizeNamespaceIdentity(this.config.defaultNamespace)
2307
+ ? 0
2308
+ : 3;
2309
+ }
2310
+
2311
+ const leaf = path.basename(resolvedStorageDir);
2312
+ const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
2313
+ if (tokenOwnsRoot && configured.has(record.namespace)) return 0;
2314
+ if (record.namespace === leaf) return 1;
2315
+ if (tokenOwnsRoot) return 2;
2316
+ return 3;
2317
+ }
2318
+
2319
+ private preferNamespaceStorageDirHintOwner(
2320
+ current: { namespace: string; identityToken: string; storageDir: string },
2321
+ candidate: { namespace: string; identityToken: string; storageDir: string },
2322
+ resolvedStorageDir: string,
2323
+ configured: Set<string>,
2324
+ ): { namespace: string; identityToken: string; storageDir: string } {
2325
+ const currentRank = this.namespaceStorageDirHintOwnershipRank(
2326
+ current,
2327
+ resolvedStorageDir,
2328
+ configured,
2329
+ );
2330
+ const candidateRank = this.namespaceStorageDirHintOwnershipRank(
2331
+ candidate,
2332
+ resolvedStorageDir,
2333
+ configured,
2334
+ );
2335
+ if (candidateRank < currentRank) return candidate;
2336
+ if (candidateRank > currentRank) return current;
2337
+
2338
+ const byName = candidate.namespace.localeCompare(current.namespace);
2339
+ if (byName < 0) return candidate;
2340
+ if (byName > 0) return current;
2341
+ return candidate.identityToken.localeCompare(current.identityToken) < 0
2342
+ ? candidate
2343
+ : current;
2344
+ }
2345
+
2346
+ private loadNamespaceStorageDirHintsFromCatalog(): void {
2347
+ if (this.namespaceStorageDirHintsLoaded || !this.namespaceCatalog.enabled) return;
2348
+ this.namespaceStorageDirHintsLoaded = true;
2349
+ const catalogPath = path.join(this.config.memoryDir, "state", "namespaces.jsonl");
2350
+ if (!existsSync(catalogPath)) return;
2351
+
2352
+ let body: string;
2353
+ try {
2354
+ body = readFileSync(catalogPath, "utf8");
2355
+ } catch {
2356
+ return;
2357
+ }
2358
+
2359
+ const compactedByNamespace = new Map<
2360
+ string,
2361
+ { namespace: string; identityToken: string; storageDir: string }
2362
+ >();
2363
+ for (const line of body.split(/\r?\n/)) {
2364
+ const trimmed = line.trim();
2365
+ if (!trimmed) continue;
2366
+ try {
2367
+ const parsed = JSON.parse(trimmed) as unknown;
2368
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
2369
+ const record = parsed as Record<string, unknown>;
2370
+ if (
2371
+ typeof record.namespace !== "string" ||
2372
+ typeof record.storageDir !== "string" ||
2373
+ typeof record.identityToken !== "string"
2374
+ ) {
2375
+ continue;
2376
+ }
2377
+ const namespace = normalizeNamespaceIdentity(record.namespace);
2378
+ if (!namespace || record.identityToken !== namespaceIdentityToken(namespace)) continue;
2379
+ compactedByNamespace.set(namespace, {
2380
+ namespace,
2381
+ identityToken: record.identityToken,
2382
+ storageDir: record.storageDir,
2383
+ });
2384
+ } catch {
2385
+ // Catalog hints are best-effort. The catalog reader still owns full recovery.
2386
+ }
2387
+ }
2388
+
2389
+ const configured = new Set(
2390
+ this.configuredNamespaces().map((namespace) => normalizeNamespaceIdentity(namespace)),
2391
+ );
2392
+ const preferredByStorageDir = new Map<
2393
+ string,
2394
+ { namespace: string; identityToken: string; storageDir: string }
2395
+ >();
2396
+ for (const record of compactedByNamespace.values()) {
2397
+ if (!this.storageDirMatchesNamespaceHint(record.namespace, record.storageDir)) {
2398
+ continue;
2399
+ }
2400
+ const resolvedStorageDir = path.resolve(record.storageDir);
2401
+ const current = preferredByStorageDir.get(resolvedStorageDir);
2402
+ preferredByStorageDir.set(
2403
+ resolvedStorageDir,
2404
+ current
2405
+ ? this.preferNamespaceStorageDirHintOwner(
2406
+ current,
2407
+ record,
2408
+ resolvedStorageDir,
2409
+ configured,
2410
+ )
2411
+ : record,
2412
+ );
2413
+ }
2414
+ for (const record of preferredByStorageDir.values()) {
2415
+ this.rememberNamespaceStorageDirHint(record.namespace, record.storageDir);
2416
+ }
2417
+ }
2418
+
2419
+ /**
2420
+ * Namespaces that QMD maintenance (update/embed) must cover: the CONFIGURED set
2421
+ * PLUS every dynamic namespace recorded in the catalog (NGnei, codex P2). An
2422
+ * extraction that writes to a coding-scoped/dynamic namespace (not in
2423
+ * defaultNamespace/sharedNamespace/namespacePolicies) is only made discoverable
2424
+ * via the catalog; if maintenance embeds only `configuredNamespaces()`, that
2425
+ * namespace's QMD collection is never updated/embedded after writes and
2426
+ * recall/search stays stale or empty until it is manually configured. We union in
2427
+ * the catalog's namespaces so maintenance keeps dynamic namespaces fresh.
2428
+ * `updateNamespaces`/`embedNamespaces` already trim, dedup, and skip
2429
+ * unavailable/missing collections, so extra names are filtered safely. A catalog
2430
+ * read failure must never break maintenance — fall back to the configured set.
2431
+ */
2432
+ private async maintenanceNamespaces(): Promise<string[]> {
2433
+ const configured = this.configuredNamespaces();
2434
+ if (!this.namespaceCatalog.enabled) return configured;
2435
+ const configuredSet = new Set(configured);
2436
+ let cataloged: string[] = [];
2437
+ try {
2438
+ const records = await this.namespaceCatalog.listNamespaces();
2439
+ const safeRecords = await Promise.all(
2440
+ records.map(async (record) => {
2441
+ const namespace = record.namespace.trim();
2442
+ if (!namespace || configuredSet.has(namespace)) return null;
2443
+ return (await this.isCatalogedMaintenanceRootLive(record))
2444
+ ? namespace
2445
+ : null;
2446
+ }),
2447
+ );
2448
+ cataloged = safeRecords.filter(
2449
+ (namespace): namespace is string => namespace !== null,
2450
+ );
2451
+ } catch {
2452
+ // Best-effort: a catalog read failure must not break QMD maintenance.
2453
+ cataloged = [];
2454
+ }
2455
+ return Array.from(
2456
+ new Set(
2457
+ [...configured, ...cataloged].map((value) => value.trim()).filter(Boolean),
2458
+ ),
2459
+ );
2460
+ }
2461
+
2462
+ private async isCatalogedMaintenanceRootLive(
2463
+ record: NamespaceRecord,
2464
+ ): Promise<boolean> {
2465
+ if (typeof record.storageDir !== "string" || record.storageDir.length === 0) {
2466
+ return false;
2467
+ }
2468
+ try {
2469
+ const liveRoot = await resolveNamespaceStorageRoot(this.config, record.namespace);
2470
+ if (path.resolve(liveRoot) !== path.resolve(record.storageDir)) {
2471
+ return false;
2472
+ }
2473
+ return hasMemoryData(liveRoot);
2474
+ } catch {
2475
+ return false;
2476
+ }
2477
+ }
2478
+
2248
2479
  private buildConfiguredQmdSearchOptions(
2249
2480
  queryText: string,
2250
2481
  ): SearchQueryOptions | undefined {
@@ -2350,7 +2581,21 @@ export class Orchestrator {
2350
2581
  storageDir: config.profilingStorageDir || path.join(config.memoryDir, "profiling"),
2351
2582
  maxTraces: config.profilingMaxTraces,
2352
2583
  });
2353
- this.storageRouter = new NamespaceStorageRouter(config);
2584
+ // Namespace catalog (issue #1499): downstream, rebuildable metadata index.
2585
+ // Inert unless namespacesEnabled is true. Storage resolution registers
2586
+ // namespaces via the router's onResolve hook; the touch is best-effort and
2587
+ // a catalog write failure never affects storage resolution.
2588
+ this.namespaceCatalog = new NamespaceCatalog(config);
2589
+ this.storageRouter = new NamespaceStorageRouter(config, {
2590
+ // Return the registration promise (round 6, codex P2 — NEFoX) so the
2591
+ // router's resolve-hook dedup only marks a namespace notified when the
2592
+ // catalog actually APPENDED. A dropped append (rebuild-lock timeout) or a
2593
+ // failure resolves to `false`/rejects, so the next `storageFor` retries.
2594
+ onResolve: (namespace, storageDir) => {
2595
+ this.rememberNamespaceStorageDirHint(namespace, storageDir);
2596
+ return this.namespaceCatalog.registerResolved(namespace, storageDir);
2597
+ },
2598
+ });
2354
2599
  this.namespaceSearchRouter = new NamespaceSearchRouter(
2355
2600
  config,
2356
2601
  this.storageRouter,
@@ -2839,6 +3084,14 @@ export class Orchestrator {
2839
3084
  await sm.ensureDirectories();
2840
3085
  await sm.loadAliases().catch(() => undefined);
2841
3086
  }
3087
+ // Explicitly seed the catalog with all configured namespaces at startup
3088
+ // (round 6, cursor Medium — NBLlR). The storageFor loop above fires the
3089
+ // router's onResolve hook, but a warm router cache (reused instance
3090
+ // across stop/start) can skip onResolve, leaving policy namespaces absent
3091
+ // from the live catalog until an operator runs `rebuild --apply`. This
3092
+ // call is cheap, idempotent, and best-effort: a catalog failure must
3093
+ // never break initialization (rule #13, #40).
3094
+ await this.namespaceCatalog.registerConfiguredNamespaces().catch(() => undefined);
2842
3095
  }
2843
3096
  await this.relevance.load();
2844
3097
  await this.negatives.load();
@@ -2907,8 +3160,15 @@ export class Orchestrator {
2907
3160
  const available = await this.qmd.probe();
2908
3161
  if (available) {
2909
3162
  log.info(`Search backend: available ${this.qmd.debugStatus()}`);
3163
+ // Ensure collections at startup for the catalog-union namespace set, not
3164
+ // just the configured set (issue #1499 sweep, same class as NHZEV): a
3165
+ // dynamic namespace that exists only in the persisted catalog must have
3166
+ // its QMD collection checked/ensured on boot so recall against it works
3167
+ // after a restart. `registerConfiguredNamespaces()` already seeded the
3168
+ // catalog above, so `maintenanceNamespaces()` is readable here; it falls
3169
+ // back to the configured set on any catalog read failure.
2910
3170
  const namespaces = this.config.namespacesEnabled
2911
- ? this.configuredNamespaces()
3171
+ ? await this.maintenanceNamespaces()
2912
3172
  : [this.config.defaultNamespace];
2913
3173
  const states = await Promise.all(
2914
3174
  namespaces.map(async (namespace) => {
@@ -3031,8 +3291,12 @@ export class Orchestrator {
3031
3291
  try {
3032
3292
  log.info("QMD startup sync: updating index to match current disk state");
3033
3293
  if (this.config.namespacesEnabled) {
3294
+ // Cover cataloged dynamic namespaces at startup too (NHZEV, codex P2):
3295
+ // a dynamic namespace written before a daemon restart must be synced on
3296
+ // boot, not only by the debounced runQmdMaintenance() path. Same union +
3297
+ // catalog-read-failure fallback as runQmdMaintenance.
3034
3298
  await this.namespaceSearchRouter.updateNamespaces(
3035
- this.configuredNamespaces(),
3299
+ await this.maintenanceNamespaces(),
3036
3300
  { signal },
3037
3301
  );
3038
3302
  } else {
@@ -3310,9 +3574,16 @@ export class Orchestrator {
3310
3574
  this.namespaceSearchRouter.clearCache();
3311
3575
  }
3312
3576
 
3313
- // Ensure collections — namespace-aware when enabled
3577
+ // Ensure collections — namespace-aware when enabled.
3578
+ // Use the catalog-union namespace set (issue #1499 sweep, same class as
3579
+ // NHZEV): this is the QMD startup-recovery sync that ensures collections AND
3580
+ // runs `updateNamespaces(...)` below over the SAME `namespaces` set. A dynamic
3581
+ // namespace that exists only in the persisted catalog must be ensured and
3582
+ // re-synced here too, otherwise after a backend-was-unavailable-at-boot
3583
+ // recovery its collection stays stale. Falls back to the configured set on any
3584
+ // catalog read failure.
3314
3585
  const namespaces = this.config.namespacesEnabled
3315
- ? this.configuredNamespaces()
3586
+ ? await this.maintenanceNamespaces()
3316
3587
  : [this.config.defaultNamespace];
3317
3588
 
3318
3589
  const states = await Promise.all(
@@ -3934,6 +4205,7 @@ export class Orchestrator {
3934
4205
  }
3935
4206
 
3936
4207
  for (const cluster of clusters) {
4208
+ let canonicalWriteCompleted = false;
3937
4209
  try {
3938
4210
  // Operator-aware prompt (issue #561 PR 3): ask the LLM to pick the
3939
4211
  // SPLIT/MERGE/UPDATE operator alongside the canonical output. Falls
@@ -4057,6 +4329,7 @@ export class Orchestrator {
4057
4329
  derivedVia: operator,
4058
4330
  },
4059
4331
  );
4332
+ canonicalWriteCompleted = true;
4060
4333
 
4061
4334
  result.memoriesConsolidated++;
4062
4335
 
@@ -4090,17 +4363,27 @@ export class Orchestrator {
4090
4363
  this.contentHashIndex.remove(m.content);
4091
4364
  }
4092
4365
  }
4093
- await this.embeddingFallback.removeFromIndex(m.frontmatter.id);
4094
- if (
4095
- this.config.queryAwareIndexingEnabled &&
4096
- m.path &&
4097
- m.frontmatter?.created
4098
- ) {
4099
- deindexMemory(
4100
- targetStorage.dir,
4101
- m.path,
4102
- m.frontmatter.created,
4103
- m.frontmatter.tags ?? [],
4366
+ // Best-effort index cleanup: a failure here (e.g. on-disk index save
4367
+ // under disk-full) must NOT abort the archival loop and thereby skip
4368
+ // the catalog write touch below for an already-durable canonical write
4369
+ // (kilo NV0mh).
4370
+ try {
4371
+ await this.embeddingFallback.removeFromIndex(m.frontmatter.id);
4372
+ if (
4373
+ this.config.queryAwareIndexingEnabled &&
4374
+ m.path &&
4375
+ m.frontmatter?.created
4376
+ ) {
4377
+ deindexMemory(
4378
+ targetStorage.dir,
4379
+ m.path,
4380
+ m.frontmatter.created,
4381
+ m.frontmatter.tags ?? [],
4382
+ );
4383
+ }
4384
+ } catch (cleanupErr) {
4385
+ log.warn(
4386
+ `[semantic-consolidation] index cleanup failed (non-fatal): ${cleanupErr}`,
4104
4387
  );
4105
4388
  }
4106
4389
  result.memoriesArchived++;
@@ -4115,6 +4398,21 @@ export class Orchestrator {
4115
4398
  `[semantic-consolidation] cluster processing failed: ${err instanceof Error ? err.message : String(err)}`,
4116
4399
  );
4117
4400
  result.errors++;
4401
+ } finally {
4402
+ if (canonicalWriteCompleted) {
4403
+ // Catalog write touch (issue #1499 sweep): record after the canonical
4404
+ // write and, on the happy path, after archival of superseded cluster
4405
+ // memories, so `lastWriteAt` reflects every durable mutation in this
4406
+ // consolidation (cursor NUtCK). The `finally` also covers partial
4407
+ // failures where the canonical memory was written but a later archive
4408
+ // step throws and the cluster catch continues (codex NY-dK).
4409
+ // Best-effort; namespace decoded from the storage dir since this path
4410
+ // has no routed namespace name.
4411
+ this.markCatalogWrite(
4412
+ this.namespaceFromStorageDir(targetStorage.dir),
4413
+ targetStorage.dir,
4414
+ );
4415
+ }
4118
4416
  }
4119
4417
  }
4120
4418
 
@@ -7194,6 +7492,22 @@ export class Orchestrator {
7194
7492
  } else {
7195
7493
  recallNamespaces = readableRecallNamespaces;
7196
7494
  }
7495
+ // Catalog touch (issue #1499): record reads against the recalled namespaces
7496
+ // so the catalog reflects active read scopes. Best-effort, failure-tolerant.
7497
+ // Round 3 (codex P2): gate behind the no_recall guard — when the planner
7498
+ // selects `no_recall` retrieval is skipped entirely (see the early return at
7499
+ // `recallMode === "no_recall"` below), so marking every readable namespace as
7500
+ // read would falsely inflate `lastReadAt` / catalog recency.
7501
+ // Round 4 (codex P2): also skip when the effective memory result limit is
7502
+ // zero (`topK: 0`, a disabled/zero `memories` recall section, etc.). The QMD
7503
+ // path explicitly returns before searching when `recallResultLimit <= 0`, so
7504
+ // no namespace is actually read and the touch would be spurious.
7505
+ // NOTE: the catalog read touch is recorded LATER, immediately after the
7506
+ // Phase 1 `throwIfRecallAborted` gate (round 6, codex P2 / cursor Medium —
7507
+ // NDXHa/NDmle), so it fires only once retrieval is actually about to run.
7508
+ // Recording it here (recall entry) would set `lastReadAt` for recalls that
7509
+ // are aborted, error out, or short-circuit before any QMD/filesystem read.
7510
+
7197
7511
  // Effective LCM read NAMESPACE SET (#1505 thread "Include coding fallback
7198
7512
  // namespaces in LCM reads"). `observe` archives LCM / structured history
7199
7513
  // under `${effectiveNamespace}:${sessionKey}` for whichever namespace was
@@ -7473,6 +7787,21 @@ export class Orchestrator {
7473
7787
  // --- Phase 1: Launch ALL independent data fetches in parallel ---
7474
7788
  throwIfRecallAborted(options.abortSignal);
7475
7789
 
7790
+ // Catalog read touch (issue #1499): record reads against the recalled
7791
+ // namespaces HERE — after the abort gate, immediately before retrieval
7792
+ // actually runs — so `lastReadAt` reflects a real read, not a recall that was
7793
+ // aborted/errored/short-circuited before reaching this point (round 3/4/6,
7794
+ // codex/cursor — no_recall, zero-limit, aborted, and pre-read-error cases).
7795
+ // `no_recall` already returned earlier, so it cannot reach here. Best-effort
7796
+ // and failure-tolerant.
7797
+ if (
7798
+ this.namespaceCatalog.enabled &&
7799
+ recallResultLimit > 0 &&
7800
+ !options.abortSignal?.aborted
7801
+ ) {
7802
+ for (const ns of recallNamespaces) this.markCatalogRead(ns);
7803
+ }
7804
+
7476
7805
  // 0. Shared context (v4.0, optional)
7477
7806
  const sharedContextPromise = (async (): Promise<string | null> => {
7478
7807
  if (
@@ -12523,6 +12852,9 @@ export class Orchestrator {
12523
12852
  storage,
12524
12853
  threadIdForExtraction,
12525
12854
  { sessionKey, principal, validAt: sourceValidAt },
12855
+ // Pass the KNOWN base namespace (NHIdx) so the catalog write touch records the
12856
+ // real namespace rather than a guess decoded from the storage dir.
12857
+ selfNamespace,
12526
12858
  );
12527
12859
  let postPersistMetadataFailed = false;
12528
12860
  meta ??= await storage.loadMeta();
@@ -13004,25 +13336,28 @@ export class Orchestrator {
13004
13336
 
13005
13337
  try {
13006
13338
  if (this.config.namespacesEnabled) {
13007
- await this.namespaceSearchRouter.updateNamespaces(
13008
- this.configuredNamespaces(),
13009
- );
13339
+ // Include cataloged dynamic namespaces, not just the configured set
13340
+ // (NGnei) — resolve once and reuse for both update and embed.
13341
+ const maintenanceNamespaces = await this.maintenanceNamespaces();
13342
+ await this.namespaceSearchRouter.updateNamespaces(maintenanceNamespaces);
13343
+ const now = Date.now();
13344
+ if (
13345
+ this.config.qmdAutoEmbedEnabled &&
13346
+ now - this.lastQmdEmbedAtMs >= this.config.qmdEmbedMinIntervalMs
13347
+ ) {
13348
+ await this.namespaceSearchRouter.embedNamespaces(maintenanceNamespaces);
13349
+ this.lastQmdEmbedAtMs = now;
13350
+ }
13010
13351
  } else {
13011
13352
  await this.qmd.update();
13012
- }
13013
- const now = Date.now();
13014
- if (
13015
- this.config.qmdAutoEmbedEnabled &&
13016
- now - this.lastQmdEmbedAtMs >= this.config.qmdEmbedMinIntervalMs
13017
- ) {
13018
- if (this.config.namespacesEnabled) {
13019
- await this.namespaceSearchRouter.embedNamespaces(
13020
- this.configuredNamespaces(),
13021
- );
13022
- } else {
13353
+ const now = Date.now();
13354
+ if (
13355
+ this.config.qmdAutoEmbedEnabled &&
13356
+ now - this.lastQmdEmbedAtMs >= this.config.qmdEmbedMinIntervalMs
13357
+ ) {
13023
13358
  await this.qmd.embed();
13359
+ this.lastQmdEmbedAtMs = now;
13024
13360
  }
13025
- this.lastQmdEmbedAtMs = now;
13026
13361
  }
13027
13362
  } finally {
13028
13363
  this.qmdMaintenanceInFlight = false;
@@ -13037,6 +13372,7 @@ export class Orchestrator {
13037
13372
  storage: StorageManager,
13038
13373
  threadIdForExtraction?: string | null,
13039
13374
  sourceContext?: { sessionKey?: string; principal?: string; validAt?: string },
13375
+ baseNamespace?: string,
13040
13376
  ): Promise<string[]> {
13041
13377
  // Inline source attribution (issue #369). When enabled, every extracted
13042
13378
  // fact is rewritten to carry a compact provenance tag inside its body so
@@ -13315,7 +13651,7 @@ export class Orchestrator {
13315
13651
  // `createdAt` as the ordering anchor instead of the old fact's
13316
13652
  // timestamp, ensuring supersession fires correctly even when
13317
13653
  // the matching fact predates conflicting candidates.
13318
- await applyTemporalSupersession({
13654
+ const hashDedupSupersession = await applyTemporalSupersession({
13319
13655
  storage: sharedStorage,
13320
13656
  newMemoryId: hashDedupMatchingFact.frontmatter.id,
13321
13657
  entityRef: options.entityRef,
@@ -13324,6 +13660,19 @@ export class Orchestrator {
13324
13660
  enabled: true,
13325
13661
  useCallerTimestamp: true,
13326
13662
  });
13663
+ // Catalog touch (issue #1499 — codex P2 NElSf): this dedup branch
13664
+ // returns WITHOUT reaching the post-write `markCatalogWrite` below,
13665
+ // but `applyTemporalSupersession` mutated the shared namespace
13666
+ // (it rewrote frontmatter to retire stale shared facts). When any
13667
+ // ids were actually superseded, the shared namespace changed, so we
13668
+ // must record the write — otherwise the shared record's
13669
+ // `lastWriteAt` stays stale and `writtenSince` maintenance / QMD
13670
+ // fanout skips the namespace after a supersession-only update.
13671
+ // Best-effort and failure-tolerant (markCatalogWrite swallows
13672
+ // errors); only touch when work happened to avoid spurious writes.
13673
+ if (hashDedupSupersession.supersededIds.length > 0) {
13674
+ this.markCatalogWrite(this.config.sharedNamespace, sharedStorage.dir);
13675
+ }
13327
13676
  // Active matching fact exists — normal short-circuit is safe.
13328
13677
  return;
13329
13678
  }
@@ -13418,6 +13767,16 @@ export class Orchestrator {
13418
13767
  );
13419
13768
  }
13420
13769
  }
13770
+ // Catalog touch (issue #1499, Issue B + ordering sweep): a shared-
13771
+ // namespace promotion is the ONLY write the shared namespace receives on
13772
+ // this path, so without this the shared record's lastWriteAt stays stale
13773
+ // and `writtenSince` filters / maintenance fanout skip it. Record AFTER
13774
+ // the promoted write and the shared temporal-supersession attempt so the
13775
+ // catalog timestamp never precedes a later durable frontmatter mutation in
13776
+ // the same promotion pass. The hot-path source-namespace touch uses a
13777
+ // different storage dir, so this does not double-count the source.
13778
+ // Best-effort and failure-tolerant — it must never crash the promotion.
13779
+ this.markCatalogWrite(this.config.sharedNamespace, sharedStorage.dir);
13421
13780
  trackPersistedId(sharedStorage, promotedId, {
13422
13781
  includeReturnedIds: false,
13423
13782
  });
@@ -13778,6 +14137,19 @@ export class Orchestrator {
13778
14137
  // affect both the dedup fingerprint and importance (issue #519 procedure routing).
13779
14138
  let writeCategory = fact.category;
13780
14139
  let targetStorage = storage;
14140
+ // Track the KNOWN target namespace NAME alongside targetStorage (round 6,
14141
+ // codex P2 — NCQI0). Re-deriving it from `targetStorage.dir` mangles a raw
14142
+ // namespace literally named like a canonical token (e.g. `ns-616c706861`
14143
+ // served from its legacy raw dir decodes to `alpha`). We seed it from the
14144
+ // EXPLICIT base namespace the caller used to obtain `storage` (NHIdx, codex
14145
+ // P2) — `selfNamespace`/`writeNamespaceOverride` — so the catalog write touch
14146
+ // records the real namespace, not a guess decoded from the directory. We only
14147
+ // fall back to decoding the dir when no base namespace was passed (legacy
14148
+ // callers). The EXPLICIT routed name (below) still overrides this verbatim.
14149
+ let targetNamespaceName =
14150
+ baseNamespace && baseNamespace.length > 0
14151
+ ? baseNamespace
14152
+ : this.namespaceFromStorageDir(targetStorage.dir);
13781
14153
  let routedRuleId: string | undefined;
13782
14154
  let routedNamespaceExplicit = false;
13783
14155
  if (routeRules.length > 0) {
@@ -13794,6 +14166,7 @@ export class Orchestrator {
13794
14166
  targetStorage = await this.storageRouter.storageFor(
13795
14167
  selected.target.namespace,
13796
14168
  );
14169
+ targetNamespaceName = selected.target.namespace;
13797
14170
  }
13798
14171
  }
13799
14172
  } catch (err) {
@@ -13823,6 +14196,7 @@ export class Orchestrator {
13823
14196
  targetStorage = await this.storageRouter.storageFor(
13824
14197
  this.config.sharedNamespace,
13825
14198
  );
14199
+ targetNamespaceName = this.config.sharedNamespace;
13826
14200
  log.debug(
13827
14201
  `scope-routing: fact "${fact.content.slice(0, 60)}…" routed to shared namespace (scope=global)`,
13828
14202
  );
@@ -14198,41 +14572,49 @@ export class Orchestrator {
14198
14572
  contentHashSource: rawChunkedContent,
14199
14573
  },
14200
14574
  );
14575
+ try {
14576
+ // Write individual chunks with parent reference
14577
+ for (const chunk of chunkResult.chunks) {
14578
+ // Score each chunk's importance separately
14579
+ const chunkImportance = scoreImportance(
14580
+ chunk.content,
14581
+ writeCategory,
14582
+ fact.tags,
14583
+ );
14584
+ const chunkWriteSource =
14585
+ (fact as any).source === "proactive"
14586
+ ? "chunking-proactive"
14587
+ : "chunking";
14201
14588
 
14202
- // Write individual chunks with parent reference
14203
- for (const chunk of chunkResult.chunks) {
14204
- // Score each chunk's importance separately
14205
- const chunkImportance = scoreImportance(
14206
- chunk.content,
14207
- writeCategory,
14208
- fact.tags,
14209
- );
14210
- const chunkWriteSource =
14211
- (fact as any).source === "proactive"
14212
- ? "chunking-proactive"
14213
- : "chunking";
14214
-
14215
- await targetStorage.writeChunk(
14216
- parentId,
14217
- chunk.index,
14218
- chunkResult.chunks.length,
14219
- writeCategory,
14220
- // Each chunk carries its own inline citation so provenance
14221
- // survives when a single chunk is quoted in isolation.
14222
- applyInlineCitation(chunk.content),
14223
- {
14224
- confidence: fact.confidence,
14225
- tags: fact.tags,
14226
- entityRef: fact.entityRef,
14227
- source: chunkWriteSource,
14228
- importance: chunkImportance,
14229
- intentGoal: inferredIntent?.goal,
14230
- intentActionType: inferredIntent?.actionType,
14231
- intentEntityTypes: inferredIntent?.entityTypes,
14232
- memoryKind,
14233
- validAt: sourceContext?.validAt,
14234
- },
14235
- );
14589
+ await targetStorage.writeChunk(
14590
+ parentId,
14591
+ chunk.index,
14592
+ chunkResult.chunks.length,
14593
+ writeCategory,
14594
+ // Each chunk carries its own inline citation so provenance
14595
+ // survives when a single chunk is quoted in isolation.
14596
+ applyInlineCitation(chunk.content),
14597
+ {
14598
+ confidence: fact.confidence,
14599
+ tags: fact.tags,
14600
+ entityRef: fact.entityRef,
14601
+ source: chunkWriteSource,
14602
+ importance: chunkImportance,
14603
+ intentGoal: inferredIntent?.goal,
14604
+ intentActionType: inferredIntent?.actionType,
14605
+ intentEntityTypes: inferredIntent?.entityTypes,
14606
+ memoryKind,
14607
+ validAt: sourceContext?.validAt,
14608
+ },
14609
+ );
14610
+ }
14611
+ } finally {
14612
+ // The parent memory is durable once writeMemory returns `parentId`.
14613
+ // Touch immediately around the chunk-write loop so a later chunk
14614
+ // failure still surfaces the partially durable parent/chunk files to
14615
+ // catalog-driven `writtenSince` maintenance. The final touch below
14616
+ // still refreshes `lastWriteAt` after later durable writes on success.
14617
+ this.markCatalogWrite(targetNamespaceName, targetStorage.dir);
14236
14618
  }
14237
14619
 
14238
14620
  if (routedRuleId) {
@@ -14308,62 +14690,71 @@ export class Orchestrator {
14308
14690
  // directly for embedding-fallback sync of each chunk document.
14309
14691
  await this.indexPersistedMemory(targetStorage, chunkId);
14310
14692
  }
14311
- if (
14312
- this.config.verbatimArtifactsEnabled &&
14313
- this.config.verbatimArtifactCategories.includes(writeCategory) &&
14314
- fact.confidence >= this.config.verbatimArtifactsMinConfidence
14315
- ) {
14316
- // Reuse citedChunkedContent so the artifact carries the same citation
14317
- // timestamp as the parent memory write above (Fix #3 — duplicate-citation).
14318
- await targetStorage.writeArtifact(citedChunkedContent, {
14319
- confidence: fact.confidence,
14320
- tags: [...fact.tags, "artifact", "chunked-parent"],
14321
- artifactType: this.artifactTypeForCategory(writeCategory),
14322
- sourceMemoryId: parentId,
14323
- intentGoal: inferredIntent?.goal,
14324
- intentActionType: inferredIntent?.actionType,
14325
- intentEntityTypes: inferredIntent?.entityTypes,
14326
- });
14327
- }
14328
- // v8.2: graph edge building for chunked memories
14329
- if (this.config.multiGraphMemoryEnabled) {
14330
- try {
14331
- const graphContext = await ensureGraphContext(targetStorage);
14332
- const entityRef =
14333
- typeof (fact as any).entityRef === "string"
14334
- ? (fact as any).entityRef
14335
- : undefined;
14336
- const parentRelPath = resolvePersistedMemoryRelativePath({
14337
- memoryId: parentId,
14338
- pathById: graphContext.memoryPathById,
14339
- category: writeCategory,
14340
- });
14341
- graphContext.memoryPathById.set(parentId, parentRelPath);
14342
- appendMemoryToGraphContext({
14343
- allMemsForGraph: graphContext.allMemsForGraph,
14344
- storageDir: targetStorage.dir,
14345
- memoryRelPath: parentRelPath,
14346
- memoryId: parentId,
14347
- category: writeCategory,
14348
- content: fact.content ?? "",
14349
- entityRef,
14693
+ try {
14694
+ if (
14695
+ this.config.verbatimArtifactsEnabled &&
14696
+ this.config.verbatimArtifactCategories.includes(writeCategory) &&
14697
+ fact.confidence >= this.config.verbatimArtifactsMinConfidence
14698
+ ) {
14699
+ // Reuse citedChunkedContent so the artifact carries the same citation
14700
+ // timestamp as the parent memory write above (Fix #3 — duplicate-citation).
14701
+ await targetStorage.writeArtifact(citedChunkedContent, {
14702
+ confidence: fact.confidence,
14703
+ tags: [...fact.tags, "artifact", "chunked-parent"],
14704
+ artifactType: this.artifactTypeForCategory(writeCategory),
14705
+ sourceMemoryId: parentId,
14706
+ intentGoal: inferredIntent?.goal,
14707
+ intentActionType: inferredIntent?.actionType,
14708
+ intentEntityTypes: inferredIntent?.entityTypes,
14350
14709
  });
14351
- await this.buildGraphEdge(
14352
- targetStorage,
14353
- parentRelPath,
14354
- entityRef,
14355
- parentId,
14356
- fact.content ?? "",
14357
- graphContext.allMemsForGraph,
14358
- graphContext.memoryPathById,
14359
- threadIdForExtraction ?? undefined,
14360
- threadEpisodeIdsForGraph,
14361
- graphContext.previousPersistedRelPath,
14362
- );
14363
- graphContext.previousPersistedRelPath = parentRelPath;
14364
- } catch {
14365
- /* fail-open */
14366
14710
  }
14711
+ // v8.2: graph edge building for chunked memories
14712
+ if (this.config.multiGraphMemoryEnabled) {
14713
+ try {
14714
+ const graphContext = await ensureGraphContext(targetStorage);
14715
+ const entityRef =
14716
+ typeof (fact as any).entityRef === "string"
14717
+ ? (fact as any).entityRef
14718
+ : undefined;
14719
+ const parentRelPath = resolvePersistedMemoryRelativePath({
14720
+ memoryId: parentId,
14721
+ pathById: graphContext.memoryPathById,
14722
+ category: writeCategory,
14723
+ });
14724
+ graphContext.memoryPathById.set(parentId, parentRelPath);
14725
+ appendMemoryToGraphContext({
14726
+ allMemsForGraph: graphContext.allMemsForGraph,
14727
+ storageDir: targetStorage.dir,
14728
+ memoryRelPath: parentRelPath,
14729
+ memoryId: parentId,
14730
+ category: writeCategory,
14731
+ content: fact.content ?? "",
14732
+ entityRef,
14733
+ });
14734
+ await this.buildGraphEdge(
14735
+ targetStorage,
14736
+ parentRelPath,
14737
+ entityRef,
14738
+ parentId,
14739
+ fact.content ?? "",
14740
+ graphContext.allMemsForGraph,
14741
+ graphContext.memoryPathById,
14742
+ threadIdForExtraction ?? undefined,
14743
+ threadEpisodeIdsForGraph,
14744
+ graphContext.previousPersistedRelPath,
14745
+ );
14746
+ graphContext.previousPersistedRelPath = parentRelPath;
14747
+ } catch {
14748
+ /* fail-open */
14749
+ }
14750
+ }
14751
+ } finally {
14752
+ // Catalog touch (issue #1499): refresh AFTER later chunked
14753
+ // source-namespace durable mutations — temporal supersession, shared
14754
+ // promotion, optional artifact writes, and graph-edge writes — so
14755
+ // `lastWriteAt` cannot precede later file changes on successful
14756
+ // completion. Use the KNOWN routed name, not a dir-decoded guess.
14757
+ this.markCatalogWrite(targetNamespaceName, targetStorage.dir);
14367
14758
  }
14368
14759
  trackBehaviorSignals(
14369
14760
  targetStorage,
@@ -14469,120 +14860,150 @@ export class Orchestrator {
14469
14860
  } catch (err) {
14470
14861
  log.warn(`temporal-supersession: unexpected error: ${err}`);
14471
14862
  }
14472
- trackBehaviorSignals(
14473
- targetStorage,
14474
- buildBehaviorSignalsForMemory({
14475
- memoryId,
14863
+ try {
14864
+ trackBehaviorSignals(
14865
+ targetStorage,
14866
+ buildBehaviorSignalsForMemory({
14867
+ memoryId,
14868
+ category: writeCategory,
14869
+ content: fact.content,
14870
+ namespace: this.namespaceFromStorageDir(targetStorage.dir),
14871
+ confidence: fact.confidence,
14872
+ source: "extraction",
14873
+ }),
14874
+ );
14875
+ trackPersistedId(targetStorage, memoryId);
14876
+ if (
14877
+ threadEpisodeIdsForGraph &&
14878
+ !threadEpisodeIdsForGraph.includes(memoryId)
14879
+ ) {
14880
+ threadEpisodeIdsForGraph.push(memoryId);
14881
+ }
14882
+ await this.indexPersistedMemory(targetStorage, memoryId);
14883
+ await promoteMemoryToShared({
14884
+ sourceStorage: targetStorage,
14476
14885
  category: writeCategory,
14477
14886
  content: fact.content,
14478
- namespace: this.namespaceFromStorageDir(targetStorage.dir),
14479
14887
  confidence: fact.confidence,
14480
- source: "extraction",
14481
- }),
14482
- );
14483
- trackPersistedId(targetStorage, memoryId);
14484
- if (
14485
- threadEpisodeIdsForGraph &&
14486
- !threadEpisodeIdsForGraph.includes(memoryId)
14487
- ) {
14488
- threadEpisodeIdsForGraph.push(memoryId);
14489
- }
14490
- await this.indexPersistedMemory(targetStorage, memoryId);
14491
- await promoteMemoryToShared({
14492
- sourceStorage: targetStorage,
14493
- category: writeCategory,
14494
- content: fact.content,
14495
- confidence: fact.confidence,
14496
- tags: fact.tags,
14497
- entityRef:
14498
- typeof (fact as any).entityRef === "string"
14499
- ? (fact as any).entityRef
14500
- : undefined,
14501
- structuredAttributes: fact.structuredAttributes,
14502
- sourceMemoryId: memoryId,
14503
- importance,
14504
- intentGoal: inferredIntent?.goal,
14505
- intentActionType: inferredIntent?.actionType,
14506
- intentEntityTypes: inferredIntent?.entityTypes,
14507
- memoryKind,
14508
- validAt: sourceContext?.validAt,
14509
- source: extractionWriteSource,
14510
- });
14511
- // v8.2: graph edge building (fail-open — errors caught inside GraphIndex)
14512
- if (this.config.multiGraphMemoryEnabled) {
14513
- try {
14514
- const graphContext = await ensureGraphContext(targetStorage);
14515
- const entityRef =
14888
+ tags: fact.tags,
14889
+ entityRef:
14516
14890
  typeof (fact as any).entityRef === "string"
14517
14891
  ? (fact as any).entityRef
14518
- : undefined;
14519
- const memoryRelPath = resolvePersistedMemoryRelativePath({
14520
- memoryId,
14521
- pathById: graphContext.memoryPathById,
14522
- category: writeCategory,
14523
- });
14524
- graphContext.memoryPathById.set(memoryId, memoryRelPath);
14525
- appendMemoryToGraphContext({
14526
- allMemsForGraph: graphContext.allMemsForGraph,
14527
- storageDir: targetStorage.dir,
14528
- memoryRelPath: memoryRelPath,
14529
- memoryId,
14530
- category: writeCategory,
14531
- content: fact.content ?? "",
14532
- entityRef,
14533
- });
14534
- await this.buildGraphEdge(
14535
- targetStorage,
14536
- memoryRelPath,
14537
- entityRef,
14538
- memoryId,
14539
- fact.content ?? "",
14540
- graphContext.allMemsForGraph,
14541
- graphContext.memoryPathById,
14542
- threadIdForExtraction ?? undefined,
14543
- threadEpisodeIdsForGraph,
14544
- graphContext.previousPersistedRelPath,
14545
- );
14546
- graphContext.previousPersistedRelPath = memoryRelPath;
14547
- } catch {
14548
- /* fail-open */
14549
- }
14550
- }
14551
- if (
14552
- this.config.verbatimArtifactsEnabled &&
14553
- this.config.verbatimArtifactCategories.includes(writeCategory) &&
14554
- fact.confidence >= this.config.verbatimArtifactsMinConfidence
14555
- ) {
14556
- // Reuse citedFactContent so the artifact carries the same citation
14557
- // timestamp as the memory write above (Fix #3 — duplicate-citation).
14558
- await targetStorage.writeArtifact(citedFactContent, {
14559
- confidence: fact.confidence,
14560
- tags: [...fact.tags, "artifact"],
14561
- artifactType: this.artifactTypeForCategory(writeCategory),
14892
+ : undefined,
14893
+ structuredAttributes: fact.structuredAttributes,
14562
14894
  sourceMemoryId: memoryId,
14895
+ importance,
14563
14896
  intentGoal: inferredIntent?.goal,
14564
14897
  intentActionType: inferredIntent?.actionType,
14565
14898
  intentEntityTypes: inferredIntent?.entityTypes,
14899
+ memoryKind,
14900
+ validAt: sourceContext?.validAt,
14901
+ source: extractionWriteSource,
14566
14902
  });
14567
- }
14568
- // Register in content-hash index after successful write.
14569
- // Thread 3 fix: canonicalize by stripping any pre-existing citation so
14570
- // the stored hash matches what the dedup check computes via
14571
- // stripCitationForTemplate before calling contentHashIndex.has().
14572
- if (this.contentHashIndex) {
14573
- const canonicalFactContent =
14574
- citationEnabled &&
14575
- hasCitationForTemplate(fact.content, citationTemplate)
14576
- ? stripCitationForTemplate(fact.content, citationTemplate)
14577
- : fact.content;
14578
- const hashRegisterKey =
14579
- writeCategory === "procedure"
14580
- ? buildProcedurePersistBody(fact.content, fact.procedureSteps)
14581
- : canonicalFactContent;
14582
- this.contentHashIndex.add(hashRegisterKey);
14583
- }
14584
- }
14585
-
14903
+ // v8.2: graph edge building (fail-open — errors caught inside GraphIndex)
14904
+ if (this.config.multiGraphMemoryEnabled) {
14905
+ try {
14906
+ const graphContext = await ensureGraphContext(targetStorage);
14907
+ const entityRef =
14908
+ typeof (fact as any).entityRef === "string"
14909
+ ? (fact as any).entityRef
14910
+ : undefined;
14911
+ const memoryRelPath = resolvePersistedMemoryRelativePath({
14912
+ memoryId,
14913
+ pathById: graphContext.memoryPathById,
14914
+ category: writeCategory,
14915
+ });
14916
+ graphContext.memoryPathById.set(memoryId, memoryRelPath);
14917
+ appendMemoryToGraphContext({
14918
+ allMemsForGraph: graphContext.allMemsForGraph,
14919
+ storageDir: targetStorage.dir,
14920
+ memoryRelPath: memoryRelPath,
14921
+ memoryId,
14922
+ category: writeCategory,
14923
+ content: fact.content ?? "",
14924
+ entityRef,
14925
+ });
14926
+ await this.buildGraphEdge(
14927
+ targetStorage,
14928
+ memoryRelPath,
14929
+ entityRef,
14930
+ memoryId,
14931
+ fact.content ?? "",
14932
+ graphContext.allMemsForGraph,
14933
+ graphContext.memoryPathById,
14934
+ threadIdForExtraction ?? undefined,
14935
+ threadEpisodeIdsForGraph,
14936
+ graphContext.previousPersistedRelPath,
14937
+ );
14938
+ graphContext.previousPersistedRelPath = memoryRelPath;
14939
+ } catch {
14940
+ /* fail-open */
14941
+ }
14942
+ }
14943
+ if (
14944
+ this.config.verbatimArtifactsEnabled &&
14945
+ this.config.verbatimArtifactCategories.includes(writeCategory) &&
14946
+ fact.confidence >= this.config.verbatimArtifactsMinConfidence
14947
+ ) {
14948
+ // Reuse citedFactContent so the artifact carries the same citation
14949
+ // timestamp as the memory write above (Fix #3 — duplicate-citation).
14950
+ await targetStorage.writeArtifact(citedFactContent, {
14951
+ confidence: fact.confidence,
14952
+ tags: [...fact.tags, "artifact"],
14953
+ artifactType: this.artifactTypeForCategory(writeCategory),
14954
+ sourceMemoryId: memoryId,
14955
+ intentGoal: inferredIntent?.goal,
14956
+ intentActionType: inferredIntent?.actionType,
14957
+ intentEntityTypes: inferredIntent?.entityTypes,
14958
+ });
14959
+ }
14960
+ // Register in content-hash index after successful write.
14961
+ // Thread 3 fix: canonicalize by stripping any pre-existing citation so
14962
+ // the stored hash matches what the dedup check computes via
14963
+ // stripCitationForTemplate before calling contentHashIndex.has().
14964
+ if (this.contentHashIndex) {
14965
+ const canonicalFactContent =
14966
+ citationEnabled &&
14967
+ hasCitationForTemplate(fact.content, citationTemplate)
14968
+ ? stripCitationForTemplate(fact.content, citationTemplate)
14969
+ : fact.content;
14970
+ const hashRegisterKey =
14971
+ writeCategory === "procedure"
14972
+ ? buildProcedurePersistBody(fact.content, fact.procedureSteps)
14973
+ : canonicalFactContent;
14974
+ this.contentHashIndex.add(hashRegisterKey);
14975
+ }
14976
+ } finally {
14977
+ // Catalog touch (issue #1499): record AFTER every synchronous
14978
+ // source-namespace mutation in the non-chunked path: writeMemory,
14979
+ // temporal supersession, graph edges, and optional verbatim artifacts.
14980
+ // The `finally` preserves the write touch when post-write indexing or
14981
+ // promotion fails after the canonical memory is already durable. Use the
14982
+ // KNOWN routed name, not a dir-decoded guess (NCQI0).
14983
+ this.markCatalogWrite(targetNamespaceName, targetStorage.dir);
14984
+ }
14985
+ }
14986
+
14987
+ // Tracks whether THIS extraction persisted any durable, non-fact output to the
14988
+ // BASE namespace's storage (entity / relationship / profile / question). The
14989
+ // per-fact `markCatalogWrite` only fires inside the fact write loop, so a
14990
+ // fact-less extraction that still persists durable data must record exactly one
14991
+ // base-namespace catalog touch after all writes complete (NHZEZ, codex P2).
14992
+ let durableNonFactWritten = false;
14993
+ let durableNonFactTouchRecorded = false;
14994
+ const touchBaseNonFactNamespace = () => {
14995
+ const baseTouchNamespace =
14996
+ baseNamespace && baseNamespace.length > 0
14997
+ ? baseNamespace
14998
+ : this.namespaceFromStorageDir(storage.dir);
14999
+ this.markCatalogWrite(baseTouchNamespace, storage.dir);
15000
+ };
15001
+ const recordDurableNonFactWrite = () => {
15002
+ durableNonFactWritten = true;
15003
+ if (durableNonFactTouchRecorded) return;
15004
+ durableNonFactTouchRecorded = true;
15005
+ touchBaseNonFactNamespace();
15006
+ };
14586
15007
  for (const entity of entities) {
14587
15008
  try {
14588
15009
  const name = (entity as any)?.name;
@@ -14607,7 +15028,10 @@ export class Orchestrator {
14607
15028
  ? (entity as any).structuredSections
14608
15029
  : undefined,
14609
15030
  });
14610
- if (id) trackPersistedId(storage, id);
15031
+ if (id) {
15032
+ trackPersistedId(storage, id);
15033
+ recordDurableNonFactWrite();
15034
+ }
14611
15035
  } catch (err) {
14612
15036
  log.warn(`persistExtraction: entity write failed: ${err}`);
14613
15037
  }
@@ -14626,10 +15050,12 @@ export class Orchestrator {
14626
15050
  target: rel.target,
14627
15051
  label: rel.label,
14628
15052
  });
15053
+ recordDurableNonFactWrite();
14629
15054
  await storage.addEntityRelationship(rel.target, {
14630
15055
  target: rel.source,
14631
15056
  label: `${rel.label} (reverse)`,
14632
15057
  });
15058
+ recordDurableNonFactWrite();
14633
15059
  } catch (err) {
14634
15060
  log.debug(`relationship persist failed: ${err}`);
14635
15061
  }
@@ -14658,23 +15084,49 @@ export class Orchestrator {
14658
15084
 
14659
15085
  if (profileUpdates.length > 0) {
14660
15086
  await storage.appendToProfile(profileUpdates);
15087
+ recordDurableNonFactWrite();
14661
15088
  }
14662
15089
 
14663
15090
  // Persist questions
14664
15091
  for (const q of questions) {
14665
15092
  const id = await storage.writeQuestion(q.question, q.context, q.priority);
14666
- if (id) trackPersistedId(storage, id);
15093
+ if (id) {
15094
+ trackPersistedId(storage, id);
15095
+ recordDurableNonFactWrite();
15096
+ }
14667
15097
  }
14668
15098
 
14669
- // Persist identity reflection
15099
+ // Persist identity reflection. This writes durable namespace-local state, so
15100
+ // an identity-ONLY extraction (no facts/entities/profile/questions) still
15101
+ // counts as a durable non-fact write for the catalog touch below (NIIly).
15102
+ // Only count it when the write actually succeeds (best-effort write); the
15103
+ // touch is recorded AFTER this so a rolled-back/failed write never touches.
14670
15104
  if (this.config.identityEnabled && result.identityReflection) {
14671
15105
  try {
14672
15106
  await storage.appendIdentityReflection(result.identityReflection);
15107
+ recordDurableNonFactWrite();
14673
15108
  } catch (err) {
14674
15109
  log.debug(`identity reflection write failed: ${err}`);
14675
15110
  }
14676
15111
  }
14677
15112
 
15113
+ // Catalog touch for durable NON-FACT outputs (NHZEZ / NIIly, codex P2). The
15114
+ // per-fact `markCatalogWrite` above only fires inside the fact write loop, so
15115
+ // an extraction that persists ONLY entities, relationships, profile updates,
15116
+ // questions, or an identity reflection (no facts) would record durable data to
15117
+ // the BASE namespace's storage without ever touching the catalog — leaving that
15118
+ // namespace's `lastWriteAt` stale so `listNamespaces({writtenSince})` /
15119
+ // write-recency QMD maintenance miss the write. All of these are written to the
15120
+ // BASE `storage` (not the per-fact routed `targetStorage`), so we record ONE
15121
+ // base-namespace touch here, AFTER every non-fact write completes. Use the
15122
+ // KNOWN base namespace name, not a dir-decoded guess (NCQI0). One touch per
15123
+ // namespace per extraction — `markWrite` is idempotent, so if the fact path
15124
+ // already touched the base namespace this only refreshes `lastWriteAt`.
15125
+ // Best-effort and failure-tolerant (markCatalogWrite swallows errors).
15126
+ if (durableNonFactWritten) {
15127
+ touchBaseNonFactNamespace();
15128
+ }
15129
+
14678
15130
  // Save content-hash index after batch
14679
15131
  if (this.contentHashIndex) {
14680
15132
  await this.contentHashIndex
@@ -14912,6 +15364,11 @@ export class Orchestrator {
14912
15364
  log.info("running consolidation pass");
14913
15365
  let merged = 0;
14914
15366
  let invalidated = 0;
15367
+ // Tracks whether any consolidation memory-item action (UPDATE / MERGE /
15368
+ // INVALIDATE) durably rewrote memory state. A consolidation pass that only
15369
+ // mutates memory items (no profile/entity updates) still changes the default
15370
+ // namespace's data, so its catalog `lastWriteAt` must refresh too (NIBOi).
15371
+ let memoryItemMutated = false;
14915
15372
 
14916
15373
  // Flush access tracking buffer first
14917
15374
  if (this.accessTrackingBuffer.size > 0) {
@@ -14955,6 +15412,7 @@ export class Orchestrator {
14955
15412
  : null;
14956
15413
  if (await this.storage.invalidateMemory(item.existingId)) {
14957
15414
  invalidated += 1;
15415
+ memoryItemMutated = true;
14958
15416
  await this.embeddingFallback.removeFromIndex(item.existingId);
14959
15417
  if (toInvalidate?.path && toInvalidate.frontmatter?.created) {
14960
15418
  deindexMemory(
@@ -14976,6 +15434,7 @@ export class Orchestrator {
14976
15434
  lineage: [item.existingId],
14977
15435
  },
14978
15436
  );
15437
+ memoryItemMutated = true;
14979
15438
  await this.indexPersistedMemory(this.storage, item.existingId);
14980
15439
  // updateMemory() only changes content/updated/lineage — path, created, and tags
14981
15440
  // are preserved, so the temporal/tag index entry is already correct; no reindex needed.
@@ -14991,6 +15450,7 @@ export class Orchestrator {
14991
15450
  lineage: [item.existingId, item.mergeWith],
14992
15451
  },
14993
15452
  );
15453
+ memoryItemMutated = true;
14994
15454
  await this.indexPersistedMemory(this.storage, item.existingId);
14995
15455
  // updateMemory() only changes content/updated/supersedes/lineage — path, created, and tags
14996
15456
  // are preserved, so the temporal/tag index entry for the survivor is already correct.
@@ -15035,9 +15495,24 @@ export class Orchestrator {
15035
15495
  });
15036
15496
  }
15037
15497
 
15498
+ // Catalog write touch accounting (issue #1499 sweep): consolidation persists
15499
+ // durable mutations directly to the default-namespace `this.storage`, bypassing
15500
+ // the extraction write path. We do NOT touch here — later maintenance steps in
15501
+ // this same function (entity-file merges, expired-commitment / TTL cleanup,
15502
+ // fact archival) can ALSO mutate the namespace on a run with no LLM outputs
15503
+ // (NIjwl). So we accumulate every durable mutation into `memoryItemMutated` and
15504
+ // record ONE consolidated touch AFTER all mutation-producing steps complete,
15505
+ // just before returning (rule #25: touch after the write commits). LLM
15506
+ // profile/entity updates and memory-item actions (UPDATE / MERGE / INVALIDATE)
15507
+ // count here (NIBOi).
15508
+ if (result.profileUpdates.length > 0 || result.entityUpdates.length > 0) {
15509
+ memoryItemMutated = true;
15510
+ }
15511
+
15038
15512
  // Merge fragmented entity files
15039
15513
  const entitiesMerged = await this.storage.mergeFragmentedEntities();
15040
15514
  if (entitiesMerged > 0) {
15515
+ memoryItemMutated = true;
15041
15516
  log.info(`merged ${entitiesMerged} fragmented entity files`);
15042
15517
  }
15043
15518
 
@@ -15048,6 +15523,10 @@ export class Orchestrator {
15048
15523
  5,
15049
15524
  );
15050
15525
  if (synthesized > 0) {
15526
+ // Entity synthesis rewrites entity files — a durable namespace mutation,
15527
+ // so record it for the catalog touch even when it is the only change in
15528
+ // the pass (codex). Otherwise lastWriteAt goes stale.
15529
+ memoryItemMutated = true;
15051
15530
  log.info(`refreshed ${synthesized} entity syntheses`);
15052
15531
  }
15053
15532
  } catch (err) {
@@ -15060,6 +15539,7 @@ export class Orchestrator {
15060
15539
  this.config.commitmentDecayDays,
15061
15540
  );
15062
15541
  if (deletedCommitments.length > 0) {
15542
+ memoryItemMutated = true;
15063
15543
  log.info(`cleaned ${deletedCommitments.length} expired commitments`);
15064
15544
  if (this.config.queryAwareIndexingEnabled) {
15065
15545
  for (const m of deletedCommitments) {
@@ -15089,6 +15569,7 @@ export class Orchestrator {
15089
15569
  lifecycle.transitionedToExpired.length > 0 ||
15090
15570
  lifecycle.deletedResolved.length > 0
15091
15571
  ) {
15572
+ memoryItemMutated = true;
15092
15573
  log.info(
15093
15574
  `commitment ledger lifecycle: expired ${lifecycle.transitionedToExpired.length}, cleaned ${lifecycle.deletedResolved.length}`,
15094
15575
  );
@@ -15101,6 +15582,7 @@ export class Orchestrator {
15101
15582
  // Clean memories past their TTL (speculative memories auto-expire)
15102
15583
  const deletedTTL = await this.storage.cleanExpiredTTL();
15103
15584
  if (deletedTTL.length > 0) {
15585
+ memoryItemMutated = true;
15104
15586
  log.info(`cleaned ${deletedTTL.length} TTL-expired memories`);
15105
15587
  if (this.config.queryAwareIndexingEnabled) {
15106
15588
  for (const m of deletedTTL) {
@@ -15119,7 +15601,12 @@ export class Orchestrator {
15119
15601
  try {
15120
15602
  const lightSleepStartedAt = new Date().toISOString();
15121
15603
  const lifecycleCorpus = await this.storage.readAllMemories();
15122
- await this.runLifecyclePolicyPass(lifecycleCorpus);
15604
+ // Lifecycle frontmatter writes count as durable mutations for the catalog
15605
+ // touch below (codex NR-tS), even when no other consolidation step set
15606
+ // memoryItemMutated.
15607
+ if ((await this.runLifecyclePolicyPass(lifecycleCorpus)) > 0) {
15608
+ memoryItemMutated = true;
15609
+ }
15123
15610
  await this.recordScheduledDreamsPhaseRun(
15124
15611
  "lightSleep",
15125
15612
  lifecycleCorpus.length,
@@ -15139,13 +15626,17 @@ export class Orchestrator {
15139
15626
 
15140
15627
  try {
15141
15628
  const deepSleepStartedAt = new Date().toISOString();
15142
- await this.runTierMigrationCycle(this.storage, "maintenance");
15629
+ // Tier migrations move/rewrite memory files; count them as durable
15630
+ // mutations for the catalog touch below (codex NThSW).
15631
+ const tierMigration = await this.runTierMigrationCycle(this.storage, "maintenance");
15632
+ if (tierMigration.migrated > 0) memoryItemMutated = true;
15143
15633
  allMemories = await this.storage.readAllMemories();
15144
15634
 
15145
15635
  // Fact archival pass (v6.0) — move old, low-importance, rarely-accessed facts to archive/
15146
15636
  if (this.config.factArchivalEnabled) {
15147
15637
  const archived = await this.runFactArchival(allMemories);
15148
15638
  if (archived > 0) {
15639
+ memoryItemMutated = true;
15149
15640
  log.info(`archived ${archived} old low-importance facts`);
15150
15641
  }
15151
15642
  }
@@ -15268,6 +15759,10 @@ export class Orchestrator {
15268
15759
  );
15269
15760
  if (profileResult) {
15270
15761
  await this.storage.writeProfile(profileResult.consolidatedProfile);
15762
+ // Profile consolidation rewrites profile.md — a durable namespace
15763
+ // mutation; record it for the catalog touch even when it is the only
15764
+ // change in the pass (codex). Otherwise lastWriteAt goes stale.
15765
+ memoryItemMutated = true;
15271
15766
  log.info(
15272
15767
  `profile.md consolidated: removed ${profileResult.removedCount} items — ${profileResult.summary}`,
15273
15768
  );
@@ -15352,6 +15847,21 @@ export class Orchestrator {
15352
15847
  }
15353
15848
  }
15354
15849
 
15850
+ // Consolidated catalog write touch (issue #1499 sweep; NIBOi + NIjwl). One
15851
+ // touch covering EVERY durable namespace mutation this pass made — LLM
15852
+ // profile/entity/memory-item actions AND cleanup-only maintenance (entity-file
15853
+ // merges, expired-commitment / ledger-lifecycle / TTL cleanup, fact archival).
15854
+ // Recorded here, after all mutation-producing steps, so a cleanup-only run that
15855
+ // rewrote the store still refreshes `lastWriteAt` (rule #25). The default
15856
+ // namespace is always configured/cataloged; `markWrite` is idempotent so this
15857
+ // only refreshes recency. Best-effort and failure-tolerant.
15858
+ if (memoryItemMutated) {
15859
+ this.markCatalogWrite(
15860
+ this.namespaceFromStorageDir(this.storage.dir),
15861
+ this.storage.dir,
15862
+ );
15863
+ }
15864
+
15355
15865
  log.info("consolidation complete");
15356
15866
  return { memoriesProcessed: allMemories.length, merged, invalidated };
15357
15867
  }
@@ -15801,14 +16311,17 @@ export class Orchestrator {
15801
16311
 
15802
16312
  async runLifecyclePolicyNow(storage: StorageManager = this.storage): Promise<{ memoriesAssessed: number }> {
15803
16313
  const lifecycleCorpus = await storage.readAllMemories();
15804
- await this.runLifecyclePolicyPass(lifecycleCorpus, storage);
16314
+ // Record the catalog write when the pass rewrote any frontmatter (codex NR-tS).
16315
+ if ((await this.runLifecyclePolicyPass(lifecycleCorpus, storage)) > 0) {
16316
+ this.markCatalogWrite(this.namespaceFromStorageDir(storage.dir), storage.dir);
16317
+ }
15805
16318
  return { memoriesAssessed: lifecycleCorpus.length };
15806
16319
  }
15807
16320
 
15808
16321
  private async runLifecyclePolicyPass(
15809
16322
  allMemories: MemoryFile[],
15810
16323
  storage: StorageManager = this.storage,
15811
- ): Promise<void> {
16324
+ ): Promise<number> {
15812
16325
  const now = new Date();
15813
16326
  const nowIso = now.toISOString();
15814
16327
  const countsByState: Record<LifecycleState, number> = {
@@ -15885,7 +16398,9 @@ export class Orchestrator {
15885
16398
  if (wrote) updatedCount += 1;
15886
16399
  }
15887
16400
 
15888
- if (!this.config.lifecycleMetricsEnabled) return;
16401
+ // Report how many memories had frontmatter rewritten so callers can record a
16402
+ // catalog write touch for lifecycle-only passes (codex NR-tS).
16403
+ if (!this.config.lifecycleMetricsEnabled) return updatedCount;
15889
16404
 
15890
16405
  const total = evaluatedCount;
15891
16406
  const metrics = {
@@ -15910,6 +16425,7 @@ export class Orchestrator {
15910
16425
  );
15911
16426
  await mkdir(path.dirname(metricsPath), { recursive: true });
15912
16427
  await writeFile(metricsPath, JSON.stringify(metrics, null, 2), "utf-8");
16428
+ return updatedCount;
15913
16429
  }
15914
16430
 
15915
16431
  /**
@@ -16030,9 +16546,10 @@ export class Orchestrator {
16030
16546
  new Date(b.frontmatter.created).getTime(),
16031
16547
  );
16032
16548
 
16033
- // Keep recent memories
16034
- const toKeep = sorted.slice(-this.config.summarizationRecentToKeep);
16035
- const toSummarize = sorted.slice(0, -this.config.summarizationRecentToKeep);
16549
+ // Keep recent memories, with explicit zero handling so `slice(-0)` does not
16550
+ // accidentally keep every memory out of the summarization candidate set.
16551
+ const recentToKeep = Math.max(0, this.config.summarizationRecentToKeep);
16552
+ const toSummarize = recentToKeep > 0 ? sorted.slice(0, -recentToKeep) : sorted;
16036
16553
 
16037
16554
  // Filter candidates for summarization
16038
16555
  const candidates = toSummarize.filter((m) => {
@@ -16093,6 +16610,15 @@ export class Orchestrator {
16093
16610
  summary.id,
16094
16611
  );
16095
16612
 
16613
+ // Catalog write touch (issue #1499 sweep): summarization writes a durable
16614
+ // summary and then rewrites source-memory archive status, bypassing the
16615
+ // extraction write path. Record the touch after both mutations complete so
16616
+ // `lastWriteAt` covers the final archived-state write.
16617
+ this.markCatalogWrite(
16618
+ this.namespaceFromStorageDir(this.storage.dir),
16619
+ this.storage.dir,
16620
+ );
16621
+
16096
16622
  log.info(
16097
16623
  `created summary ${summary.id} from ${batch.length} memories, archived ${archived}`,
16098
16624
  );
@@ -16127,8 +16653,12 @@ export class Orchestrator {
16127
16653
  private static readonly IDENTITY_CONSOLIDATE_THRESHOLD = 8_000;
16128
16654
 
16129
16655
  private async autoConsolidateIdentity(): Promise<void> {
16656
+ // Fan out over the catalog-union namespace set (issue #1499 sweep): a dynamic
16657
+ // namespace that accumulated IDENTITY.md reflections must also be eligible for
16658
+ // auto-consolidation, otherwise its identity file grows unbounded and is never
16659
+ // consolidated. Falls back to the configured set on any catalog read failure.
16130
16660
  const namespaces = this.config.namespacesEnabled
16131
- ? this.configuredNamespaces()
16661
+ ? await this.maintenanceNamespaces()
16132
16662
  : [this.config.defaultNamespace];
16133
16663
 
16134
16664
  for (const namespace of namespaces) {
@@ -16190,6 +16720,19 @@ export class Orchestrator {
16190
16720
  identityNamespace,
16191
16721
  );
16192
16722
  await storage.writeIdentityReflections("");
16723
+ // NRcCL (codex P2): record a per-namespace catalog write for THIS namespace
16724
+ // after the identity files are updated. This fan-out can mutate a dynamic
16725
+ // namespace via `writeIdentity`/`writeIdentityReflections`, but the
16726
+ // consolidation pass's only consolidated touch covers `this.storage` (the
16727
+ // default) and only fires when `memoryItemMutated` was set by OTHER work — so
16728
+ // a namespace whose sole mutation in the pass is identity consolidation would
16729
+ // otherwise keep a stale `lastWriteAt`, making `listNamespaces({ writtenSince })`
16730
+ // and catalog-recency consumers miss the write. Best-effort and
16731
+ // failure-tolerant (`markCatalogWrite` swallows errors, never crashing the
16732
+ // consolidation; gotcha #13, rule #40). No double-count with the consolidated
16733
+ // touch above: that one is gated on `memoryItemMutated` (which identity
16734
+ // consolidation does not set), and `markWrite` is idempotent regardless.
16735
+ this.markCatalogWrite(namespace, storage.dir);
16193
16736
  log.info(
16194
16737
  `IDENTITY(${namespace}) consolidated: ${identityContent.length} → ${newContent.length} chars, ${result.learnedPatterns.length} patterns`,
16195
16738
  );
@@ -18538,7 +19081,75 @@ export class Orchestrator {
18538
19081
  return this.config.defaultNamespace;
18539
19082
  const m = resolvedStorageDir.match(/[\\/]namespaces[\\/]([^\\/]+)$/);
18540
19083
  if (!m?.[1]) return this.config.defaultNamespace;
18541
- return namespaceIdentityFromToken(m[1]) ?? m[1];
19084
+ const dirName = m[1];
19085
+ // Token-shaped raw names (round 6, codex P2 — NBsFz): a dir name might be a
19086
+ // tokenized identity OR a literal raw namespace name that merely LOOKS like a
19087
+ // token (e.g. a configured or dynamic name `ns-616c706861`). The round-trip check below
19088
+ // (`namespaceIdentityToken(decoded) === dirName`) is TAUTOLOGICAL for a
19089
+ // canonical token string, so it cannot tell a tokenized dir for `alpha` apart
19090
+ // from the legacy raw root of a namespace literally named `ns-616c706861`
19091
+ // (codex NRCve). A dir name that is itself a KNOWN namespace (configured or
19092
+ // catalog-owned at this exact storage root) is therefore preserved as the
19093
+ // literal namespace BEFORE attempting to decode it.
19094
+ if (this.configuredNamespaces().includes(dirName)) {
19095
+ return dirName;
19096
+ }
19097
+ this.loadNamespaceStorageDirHintsFromCatalog();
19098
+ const hintedNamespaces = this.namespaceStorageDirHints.get(resolvedStorageDir);
19099
+ if (hintedNamespaces?.has(dirName)) {
19100
+ return dirName;
19101
+ }
19102
+ if (hintedNamespaces?.size === 1) {
19103
+ const [hintedNamespace] = hintedNamespaces;
19104
+ if (hintedNamespace) return hintedNamespace;
19105
+ }
19106
+ const decoded = namespaceIdentityFromToken(dirName);
19107
+ if (decoded && namespaceIdentityToken(decoded) === dirName) {
19108
+ return decoded;
19109
+ }
19110
+ return dirName;
19111
+ }
19112
+
19113
+ /**
19114
+ * Record a namespace write in the catalog (issue #1499). Best-effort and
19115
+ * failure-tolerant: a catalog write error MUST NOT crash the primary memory
19116
+ * write (CLAUDE.md gotcha #13, rule #40). Fire-and-forget by design.
19117
+ */
19118
+ private markCatalogWrite(namespace: string, storageDir?: string): void {
19119
+ if (!this.namespaceCatalog.enabled) return;
19120
+ this.rememberNamespaceStorageDirHint(namespace, storageDir);
19121
+ void this.namespaceCatalog
19122
+ .markWrite(namespace, { discoveredBy: "write", storageDir })
19123
+ .catch(() => undefined);
19124
+ }
19125
+
19126
+ /**
19127
+ * Public best-effort catalog write touch (issue #1499). User-facing explicit
19128
+ * captures (`memory_store`) and review-queue approvals persist via
19129
+ * `persistExplicitCapture()` → `storage.writeMemory()`, which bypasses the
19130
+ * extraction write path that calls `markCatalogWrite`. Without this their
19131
+ * namespaces never record `lastWriteAt`, so the catalog under-reports write
19132
+ * recency (round 5, codex P2). Fire-and-forget and failure-tolerant — a
19133
+ * catalog error must never affect the explicit write (gotcha #13, rule #40).
19134
+ *
19135
+ * An undefined/empty `namespace` means the write targeted the DEFAULT namespace
19136
+ * (`getStorage(undefined)` routes there), so we record it under the configured
19137
+ * default rather than skipping it (round 6, codex P2 — default `memory_store`
19138
+ * and inline-note writes were missing from `writtenSince`/maintenance).
19139
+ */
19140
+ recordCatalogWrite(namespace?: string, storageDir?: string): void {
19141
+ const ns = namespace && namespace.trim().length > 0 ? namespace : this.config.defaultNamespace;
19142
+ if (!ns) return;
19143
+ this.markCatalogWrite(ns, storageDir);
19144
+ }
19145
+
19146
+ /** Record a namespace read in the catalog. Best-effort, failure-tolerant. */
19147
+ private markCatalogRead(namespace: string, storageDir?: string): void {
19148
+ if (!this.namespaceCatalog.enabled) return;
19149
+ this.rememberNamespaceStorageDirHint(namespace, storageDir);
19150
+ void this.namespaceCatalog
19151
+ .markRead(namespace, { discoveredBy: "read", storageDir })
19152
+ .catch(() => undefined);
18542
19153
  }
18543
19154
 
18544
19155
  private async readAllMemoriesForNamespaces(