@remnic/core 9.7.11 → 9.7.12

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 (277) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -6
  2. package/dist/access-admin-ops-surface.js +7 -7
  3. package/dist/access-boundary.d.ts +6 -6
  4. package/dist/access-boundary.js +8 -8
  5. package/dist/access-cli.js +31 -31
  6. package/dist/access-http.d.ts +6 -6
  7. package/dist/access-http.js +12 -12
  8. package/dist/access-identity-continuity-surface.d.ts +5 -5
  9. package/dist/access-identity-continuity-surface.js +7 -7
  10. package/dist/access-lcm-surface.d.ts +6 -6
  11. package/dist/access-lcm-surface.js +7 -7
  12. package/dist/access-mcp.d.ts +6 -6
  13. package/dist/access-mcp.js +11 -11
  14. package/dist/access-observe-write-surface.d.ts +6 -6
  15. package/dist/access-observe-write-surface.js +7 -7
  16. package/dist/access-operations-batch.js +9 -9
  17. package/dist/access-operations.d.ts +6 -6
  18. package/dist/access-operations.js +10 -10
  19. package/dist/access-recall-surface.d.ts +6 -6
  20. package/dist/access-recall-surface.js +7 -7
  21. package/dist/{access-service-DSuzikHQ.d.ts → access-service-tHd3eQpd.d.ts} +4 -4
  22. package/dist/access-service.d.ts +6 -6
  23. package/dist/access-service.js +7 -7
  24. package/dist/access-surface-catalog.d.ts +6 -6
  25. package/dist/action-confidence.d.ts +1 -1
  26. package/dist/active-memory-bridge.d.ts +1 -1
  27. package/dist/active-recall.d.ts +1 -1
  28. package/dist/active-recall.js +1 -1
  29. package/dist/behavior-learner.d.ts +1 -1
  30. package/dist/behavior-signals.d.ts +1 -1
  31. package/dist/bootstrap.d.ts +5 -5
  32. package/dist/briefing.d.ts +2 -2
  33. package/dist/briefing.js +3 -3
  34. package/dist/buffer-surprise-report.d.ts +1 -1
  35. package/dist/buffer.d.ts +2 -2
  36. package/dist/bulk-import/index.d.ts +3 -3
  37. package/dist/calibration.d.ts +1 -1
  38. package/dist/capabilities.d.ts +1 -1
  39. package/dist/{catalog-BNwjACa5.d.ts → catalog-DEMiw6HZ.d.ts} +48 -1
  40. package/dist/causal-behavior.d.ts +1 -1
  41. package/dist/causal-consolidation.d.ts +1 -1
  42. package/dist/causal-consolidation.js +4 -4
  43. package/dist/causal-trajectory-graph.d.ts +1 -1
  44. package/dist/{chunk-U4KHQTJ6.js → chunk-2AL7N3BH.js} +4 -2
  45. package/dist/{chunk-U4KHQTJ6.js.map → chunk-2AL7N3BH.js.map} +1 -1
  46. package/dist/{chunk-L4H6MQ7I.js → chunk-2RSL6YRA.js} +2 -2
  47. package/dist/{chunk-JYRFO4EH.js → chunk-2ZPQUTMW.js} +4 -4
  48. package/dist/{chunk-673NPXE3.js → chunk-3SIC3E65.js} +2 -2
  49. package/dist/{chunk-OVMS3DCB.js → chunk-675YU5IK.js} +2 -2
  50. package/dist/{chunk-7LMEY5KZ.js → chunk-6N7R7TR2.js} +34 -1
  51. package/dist/chunk-6N7R7TR2.js.map +1 -0
  52. package/dist/{chunk-W6R43NLA.js → chunk-AOWPZ7TM.js} +17 -17
  53. package/dist/{chunk-TGBJ2OMK.js → chunk-BQTSGK5Y.js} +2 -2
  54. package/dist/{chunk-6LM6PPDX.js → chunk-CE3GMHT4.js} +2 -2
  55. package/dist/{chunk-YXOAQAH2.js → chunk-CLFY35ED.js} +3 -3
  56. package/dist/{chunk-HQ7QZJM5.js → chunk-D4TLWDYR.js} +2 -2
  57. package/dist/{chunk-LQKITFGB.js → chunk-FUUF7FMX.js} +2 -2
  58. package/dist/{chunk-3ZVFLMFQ.js → chunk-G5PVJJRA.js} +1 -1
  59. package/dist/chunk-G5PVJJRA.js.map +1 -0
  60. package/dist/{chunk-MXRMNKSY.js → chunk-I3GKTDM2.js} +3 -3
  61. package/dist/{chunk-EQF5763Z.js → chunk-IQULRIZM.js} +2 -2
  62. package/dist/{chunk-DJCKXI4M.js → chunk-NVXQTRBL.js} +2 -2
  63. package/dist/{chunk-GHVJASQ4.js → chunk-OCY3CRPG.js} +3 -3
  64. package/dist/{chunk-YLTG6BO4.js → chunk-OIFZII6J.js} +2 -2
  65. package/dist/{chunk-XF4KOVDK.js → chunk-PA6DMYEK.js} +6 -6
  66. package/dist/{chunk-3OQ4IZJN.js → chunk-QPZCAVDC.js} +7 -7
  67. package/dist/{chunk-DLWYAN6A.js → chunk-T6U7HDIY.js} +2 -2
  68. package/dist/{chunk-7XRUYC6G.js → chunk-TITSEN2M.js} +3 -3
  69. package/dist/{chunk-JZM5QUTE.js → chunk-TMGK6YE7.js} +171 -9
  70. package/dist/chunk-TMGK6YE7.js.map +1 -0
  71. package/dist/{chunk-KXLRMM35.js → chunk-TRH533CA.js} +30 -7
  72. package/dist/chunk-TRH533CA.js.map +1 -0
  73. package/dist/{chunk-UTDA43TB.js → chunk-TWGGNUWJ.js} +2 -2
  74. package/dist/{chunk-SBFDL2EQ.js → chunk-UTZW3X2R.js} +2 -2
  75. package/dist/{chunk-SECMZACR.js → chunk-XUQUBZ7O.js} +2 -2
  76. package/dist/{chunk-5U65RIVI.js → chunk-YI7XUXLX.js} +16 -15
  77. package/dist/chunk-YI7XUXLX.js.map +1 -0
  78. package/dist/{chunk-QHT4W3RU.js → chunk-ZASJ53ML.js} +6 -6
  79. package/dist/{chunk-WUDBMLC6.js → chunk-ZC3HG22I.js} +2 -2
  80. package/dist/{chunk-VCEH5UYS.js → chunk-ZRXSHGXT.js} +2 -2
  81. package/dist/{cli-DACyuK8-.d.ts → cli-D8Tsa4Sh.d.ts} +5 -5
  82. package/dist/cli.d.ts +8 -8
  83. package/dist/cli.js +25 -25
  84. package/dist/compounding/engine.d.ts +2 -2
  85. package/dist/compounding/engine.js +3 -3
  86. package/dist/compounding/preference-consolidator.d.ts +1 -1
  87. package/dist/compression-optimizer.d.ts +1 -1
  88. package/dist/config.d.ts +1 -1
  89. package/dist/config.js +1 -1
  90. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  91. package/dist/connectors/codex-materialize-runner.js +3 -3
  92. package/dist/connectors/codex-materialize.d.ts +1 -1
  93. package/dist/connectors/index.d.ts +1 -1
  94. package/dist/connectors/index.js +3 -3
  95. package/dist/consolidation-provenance-check.d.ts +2 -2
  96. package/dist/consolidation-undo.d.ts +2 -2
  97. package/dist/contradiction/index.d.ts +2 -2
  98. package/dist/conversation-index/backend.d.ts +1 -1
  99. package/dist/conversation-index/chunker.d.ts +1 -1
  100. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  101. package/dist/conversation-index/indexer.d.ts +1 -1
  102. package/dist/conversation-index/search.d.ts +1 -1
  103. package/dist/day-summary.d.ts +1 -1
  104. package/dist/delinearize.d.ts +1 -1
  105. package/dist/direct-answer-wiring.d.ts +1 -1
  106. package/dist/direct-answer.d.ts +1 -1
  107. package/dist/embedding-fallback.d.ts +1 -1
  108. package/dist/enrichment/index.d.ts +1 -1
  109. package/dist/entity-retrieval.d.ts +2 -2
  110. package/dist/entity-retrieval.js +3 -3
  111. package/dist/entity-schema.d.ts +1 -1
  112. package/dist/explicit-capture.d.ts +5 -5
  113. package/dist/extraction-faithfulness.d.ts +1 -1
  114. package/dist/extraction-judge-telemetry.d.ts +1 -1
  115. package/dist/extraction-judge-training.d.ts +1 -1
  116. package/dist/extraction-judge.d.ts +1 -1
  117. package/dist/extraction.d.ts +1 -1
  118. package/dist/fallback-llm.d.ts +1 -1
  119. package/dist/graph-dashboard-diff.d.ts +1 -1
  120. package/dist/graph-dashboard-key.d.ts +1 -1
  121. package/dist/graph-dashboard-parser.d.ts +1 -1
  122. package/dist/graph-edge-reinforcement.d.ts +1 -1
  123. package/dist/graph-snapshot.d.ts +1 -1
  124. package/dist/graph.d.ts +1 -1
  125. package/dist/identity-continuity.d.ts +1 -1
  126. package/dist/importance.d.ts +1 -1
  127. package/dist/importers/index.d.ts +1 -1
  128. package/dist/in-flight-reads.d.ts +1 -1
  129. package/dist/index.d.ts +14 -14
  130. package/dist/index.js +31 -31
  131. package/dist/intent.d.ts +1 -1
  132. package/dist/lcm/engine.d.ts +1 -1
  133. package/dist/lcm/index.d.ts +1 -1
  134. package/dist/lcm/tools.d.ts +1 -1
  135. package/dist/lifecycle.d.ts +1 -1
  136. package/dist/live-connectors-runner.d.ts +1 -1
  137. package/dist/local-llm.d.ts +1 -1
  138. package/dist/local-model-endpoint.d.ts +1 -1
  139. package/dist/maintenance/memory-governance.d.ts +1 -1
  140. package/dist/maintenance/memory-governance.js +3 -3
  141. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  142. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  143. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  144. package/dist/memory-action-policy.d.ts +1 -1
  145. package/dist/memory-cache.d.ts +1 -1
  146. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  147. package/dist/memory-projection-store.d.ts +1 -1
  148. package/dist/memory-provenance.d.ts +1 -1
  149. package/dist/memory-worth-outcomes.d.ts +2 -2
  150. package/dist/models-json.d.ts +1 -1
  151. package/dist/namespaces/migrate.d.ts +3 -3
  152. package/dist/namespaces/migrate.js +4 -4
  153. package/dist/namespaces/principal.d.ts +1 -1
  154. package/dist/namespaces/search.d.ts +1 -1
  155. package/dist/namespaces/storage.d.ts +3 -3
  156. package/dist/namespaces/storage.js +3 -3
  157. package/dist/native-knowledge.d.ts +1 -1
  158. package/dist/operator-toolkit.d.ts +2 -2
  159. package/dist/operator-toolkit.js +8 -8
  160. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  161. package/dist/orchestration/maintenance.d.ts +2 -2
  162. package/dist/orchestration/maintenance.js +5 -5
  163. package/dist/{orchestrator-D83AvrPf.d.ts → orchestrator-B_WXBmuE.d.ts} +4 -4
  164. package/dist/orchestrator.d.ts +5 -5
  165. package/dist/orchestrator.js +31 -31
  166. package/dist/patterns-cli.d.ts +1 -1
  167. package/dist/{pipeline-BSqGpYl1.d.ts → pipeline-Dvtvt141.d.ts} +1 -1
  168. package/dist/policy-runtime.d.ts +1 -1
  169. package/dist/provenance.d.ts +1 -1
  170. package/dist/{qmd-CutnpRVe.d.ts → qmd-DwBLh_hl.d.ts} +1 -1
  171. package/dist/qmd-preflight.d.ts +2 -2
  172. package/dist/qmd-recall-cache.d.ts +1 -1
  173. package/dist/qmd.d.ts +2 -2
  174. package/dist/recall-disclosure-escalation.d.ts +1 -1
  175. package/dist/recall-explain-renderer.d.ts +1 -1
  176. package/dist/recall-explain-renderer.js +3 -3
  177. package/dist/recall-planner-llm.d.ts +1 -1
  178. package/dist/recall-state.d.ts +1 -1
  179. package/dist/recall-tag-filter.d.ts +1 -1
  180. package/dist/recall-timings.d.ts +1 -1
  181. package/dist/recall-xray-cli.d.ts +1 -1
  182. package/dist/recall-xray-cli.js +4 -4
  183. package/dist/recall-xray-renderer.d.ts +1 -1
  184. package/dist/recall-xray-renderer.js +3 -3
  185. package/dist/recall-xray.d.ts +1 -1
  186. package/dist/recall-xray.js +2 -2
  187. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  188. package/dist/replay/normalizers/claude.d.ts +1 -1
  189. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  190. package/dist/replay/normalizers/shared.d.ts +1 -1
  191. package/dist/replay/runner.d.ts +1 -1
  192. package/dist/replay/types.d.ts +1 -1
  193. package/dist/resolve-auth-token.d.ts +1 -1
  194. package/dist/resume-bundles.js +2 -2
  195. package/dist/retrieval-agents.d.ts +2 -2
  196. package/dist/retrieval-tiers.d.ts +1 -1
  197. package/dist/routing/engine.d.ts +1 -1
  198. package/dist/routing/store.d.ts +1 -1
  199. package/dist/search/embed-helper.d.ts +1 -1
  200. package/dist/search/factory.d.ts +1 -1
  201. package/dist/search/index.d.ts +1 -1
  202. package/dist/search/lancedb-backend.d.ts +1 -1
  203. package/dist/search/meilisearch-backend.d.ts +1 -1
  204. package/dist/search/noop-backend.d.ts +1 -1
  205. package/dist/search/orama-backend.d.ts +1 -1
  206. package/dist/search/port.d.ts +1 -1
  207. package/dist/search/remote-backend.d.ts +1 -1
  208. package/dist/{semantic-consolidation-btIVRC4l.d.ts → semantic-consolidation-Dia_6FOn.d.ts} +1 -1
  209. package/dist/semantic-consolidation.d.ts +2 -2
  210. package/dist/semantic-consolidation.js +4 -4
  211. package/dist/semantic-rule-promotion.js +3 -3
  212. package/dist/semantic-rule-verifier.d.ts +1 -1
  213. package/dist/semantic-rule-verifier.js +3 -3
  214. package/dist/session-observer-bands.d.ts +1 -1
  215. package/dist/session-observer-state.d.ts +1 -1
  216. package/dist/shared-context/manager.d.ts +1 -1
  217. package/dist/signal.d.ts +1 -1
  218. package/dist/{storage-BVv_Mqa0.d.ts → storage-RshM7i8-.d.ts} +18 -1
  219. package/dist/storage.d.ts +2 -2
  220. package/dist/storage.js +2 -2
  221. package/dist/summarizer.d.ts +1 -1
  222. package/dist/summary-snapshot.d.ts +1 -1
  223. package/dist/temporal-supersession.d.ts +2 -2
  224. package/dist/temporal-timeline-recall.d.ts +1 -1
  225. package/dist/temporal-validity.d.ts +1 -1
  226. package/dist/threading.d.ts +1 -1
  227. package/dist/tier-migration.d.ts +2 -2
  228. package/dist/tier-routing.d.ts +1 -1
  229. package/dist/topics.d.ts +1 -1
  230. package/dist/transcript.d.ts +1 -1
  231. package/dist/trust-score-stage.d.ts +1 -1
  232. package/dist/trust-score.d.ts +1 -1
  233. package/dist/{types-Cmkz7csa.d.ts → types-CHSXOq7s.d.ts} +34 -0
  234. package/dist/types.d.ts +1 -1
  235. package/dist/types.js +1 -1
  236. package/dist/utility-runtime.d.ts +1 -1
  237. package/dist/verified-recall.js +3 -3
  238. package/package.json +2 -2
  239. package/src/config.test.ts +44 -0
  240. package/src/config.ts +43 -0
  241. package/src/namespaces/catalog.test.ts +258 -0
  242. package/src/namespaces/catalog.ts +235 -10
  243. package/src/namespaces/storage.ts +9 -0
  244. package/src/orchestrator.ts +3 -0
  245. package/src/storage-catalog-touch-gating.test.ts +70 -0
  246. package/src/storage.ts +31 -5
  247. package/src/types.ts +34 -0
  248. package/dist/chunk-3ZVFLMFQ.js.map +0 -1
  249. package/dist/chunk-5U65RIVI.js.map +0 -1
  250. package/dist/chunk-7LMEY5KZ.js.map +0 -1
  251. package/dist/chunk-JZM5QUTE.js.map +0 -1
  252. package/dist/chunk-KXLRMM35.js.map +0 -1
  253. /package/dist/{chunk-L4H6MQ7I.js.map → chunk-2RSL6YRA.js.map} +0 -0
  254. /package/dist/{chunk-JYRFO4EH.js.map → chunk-2ZPQUTMW.js.map} +0 -0
  255. /package/dist/{chunk-673NPXE3.js.map → chunk-3SIC3E65.js.map} +0 -0
  256. /package/dist/{chunk-OVMS3DCB.js.map → chunk-675YU5IK.js.map} +0 -0
  257. /package/dist/{chunk-W6R43NLA.js.map → chunk-AOWPZ7TM.js.map} +0 -0
  258. /package/dist/{chunk-TGBJ2OMK.js.map → chunk-BQTSGK5Y.js.map} +0 -0
  259. /package/dist/{chunk-6LM6PPDX.js.map → chunk-CE3GMHT4.js.map} +0 -0
  260. /package/dist/{chunk-YXOAQAH2.js.map → chunk-CLFY35ED.js.map} +0 -0
  261. /package/dist/{chunk-HQ7QZJM5.js.map → chunk-D4TLWDYR.js.map} +0 -0
  262. /package/dist/{chunk-LQKITFGB.js.map → chunk-FUUF7FMX.js.map} +0 -0
  263. /package/dist/{chunk-MXRMNKSY.js.map → chunk-I3GKTDM2.js.map} +0 -0
  264. /package/dist/{chunk-EQF5763Z.js.map → chunk-IQULRIZM.js.map} +0 -0
  265. /package/dist/{chunk-DJCKXI4M.js.map → chunk-NVXQTRBL.js.map} +0 -0
  266. /package/dist/{chunk-GHVJASQ4.js.map → chunk-OCY3CRPG.js.map} +0 -0
  267. /package/dist/{chunk-YLTG6BO4.js.map → chunk-OIFZII6J.js.map} +0 -0
  268. /package/dist/{chunk-XF4KOVDK.js.map → chunk-PA6DMYEK.js.map} +0 -0
  269. /package/dist/{chunk-3OQ4IZJN.js.map → chunk-QPZCAVDC.js.map} +0 -0
  270. /package/dist/{chunk-DLWYAN6A.js.map → chunk-T6U7HDIY.js.map} +0 -0
  271. /package/dist/{chunk-7XRUYC6G.js.map → chunk-TITSEN2M.js.map} +0 -0
  272. /package/dist/{chunk-UTDA43TB.js.map → chunk-TWGGNUWJ.js.map} +0 -0
  273. /package/dist/{chunk-SBFDL2EQ.js.map → chunk-UTZW3X2R.js.map} +0 -0
  274. /package/dist/{chunk-SECMZACR.js.map → chunk-XUQUBZ7O.js.map} +0 -0
  275. /package/dist/{chunk-QHT4W3RU.js.map → chunk-ZASJ53ML.js.map} +0 -0
  276. /package/dist/{chunk-WUDBMLC6.js.map → chunk-ZC3HG22I.js.map} +0 -0
  277. /package/dist/{chunk-VCEH5UYS.js.map → chunk-ZRXSHGXT.js.map} +0 -0
@@ -12,6 +12,7 @@ import {
12
12
  stat,
13
13
  writeFile,
14
14
  } from "node:fs/promises";
15
+ import { StringDecoder } from "node:string_decoder";
15
16
  import type { PluginConfig } from "../types.js";
16
17
  import {
17
18
  MutationSerializer,
@@ -497,12 +498,46 @@ export class NamespaceCatalog {
497
498
  // against this normalized form everywhere instead.
498
499
  private readonly defaultNamespaceIdentity: string;
499
500
 
501
+ // Issue #1903 — touch-path performance knobs, resolved with the documented
502
+ // defaults when an externally-built PluginConfig omits them (so a partial
503
+ // config object still yields the production behavior). `0` is a real disable
504
+ // switch for each and is preserved by `??` (0 is not null/undefined).
505
+ private readonly compactBytesLimit: number;
506
+ private readonly readCoalesceMs: number;
507
+ private readonly writeCoalesceMs: number;
508
+ // Size after the most recent auto-compaction (0 = none yet). Hysteresis for
509
+ // maybeAutoCompact: a folded catalog that is itself above the limit (many
510
+ // distinct namespaces) would otherwise be re-folded + re-rewritten on every
511
+ // touch. We only compact again once the log has grown by >= one limit-worth
512
+ // since the last compaction (#1903, Codex).
513
+ private lastCompactedSize = 0;
514
+
515
+ // Issue #1903 — per-(namespace, kind) coalescing buffer. Keyed `${kind}:${ns}`.
516
+ // Holds the latest buffered timestamp/metadata for a pure-timestamp touch on
517
+ // an already-known record; a `.unref()`'d timer flushes it once per window so
518
+ // the append-only log does not grow one line per repeat touch. Semantically
519
+ // observable touches (first sight, provenance/field change) bypass this buffer
520
+ // and flush immediately (see `coalesceTouch`).
521
+ private readonly pendingTouches = new Map<
522
+ string,
523
+ {
524
+ namespace: string;
525
+ kind: "read" | "write";
526
+ metadata?: NamespaceTouchMetadata;
527
+ at: Date;
528
+ timer: NodeJS.Timeout;
529
+ }
530
+ >();
531
+
500
532
  constructor(private readonly config: PluginConfig) {
501
533
  this.memoryDir = config.memoryDir;
502
534
  this.stateDir = path.join(this.memoryDir, STATE_DIR);
503
535
  this.catalogPath = path.join(this.stateDir, CATALOG_FILE);
504
536
  this.rebuildLockPath = path.join(this.stateDir, REBUILD_LOCK_FILE);
505
537
  this.defaultNamespaceIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
538
+ this.compactBytesLimit = config.namespacesCatalogCompactBytes ?? 16 * 1024 * 1024;
539
+ this.readCoalesceMs = config.namespacesCatalogReadTouchCoalesceMs ?? 60_000;
540
+ this.writeCoalesceMs = config.namespacesCatalogWriteTouchCoalesceMs ?? 1_000;
506
541
  }
507
542
 
508
543
  /** Whether the catalog is active (namespaces enabled and catalog not opted out). */
@@ -663,11 +698,137 @@ export class NamespaceCatalog {
663
698
  // ── Touch API (cheap, failure-tolerant) ─────────────────────────────────
664
699
 
665
700
  async markRead(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void> {
666
- await this.touch(namespace, "read", metadata);
701
+ await this.coalesceTouch(namespace, "read", metadata);
667
702
  }
668
703
 
669
704
  async markWrite(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void> {
670
- await this.touch(namespace, "write", metadata);
705
+ await this.coalesceTouch(namespace, "write", metadata);
706
+ }
707
+
708
+ /**
709
+ * Route a read/write touch through the per-(namespace, kind) coalescing buffer
710
+ * (issue #1903). A touch flushes IMMEDIATELY (bypassing the buffer) whenever it
711
+ * is semantically observable to an in-process reader — first sight of the
712
+ * namespace, a `config`→`write` provenance upgrade, the first time a touch
713
+ * field is set (so `listNamespaces({ writtenSince })` and record presence stay
714
+ * correct), or a change to `kind`/`principal`/`projectId`/`branch`/
715
+ * `parentNamespace`. A pure timestamp refresh on an already-known record is
716
+ * DEFERRED and coalesced: the newest buffered `at`/metadata wins and a single
717
+ * `touch` runs when the window elapses (or on `flushPendingTouches`). Window
718
+ * `0` skips the buffer entirely (the pre-#1903 immediate-append behavior).
719
+ * Best-effort: coalescing decisions read only the warm `compactedCache`, never
720
+ * forcing a disk parse; a cold cache flushes immediately (and re-warms it).
721
+ */
722
+ private async coalesceTouch(
723
+ namespace: string,
724
+ kind: "read" | "write",
725
+ metadata?: NamespaceTouchMetadata,
726
+ ): Promise<void> {
727
+ if (!this.enabled) return;
728
+ const window = kind === "read" ? this.readCoalesceMs : this.writeCoalesceMs;
729
+ if (window <= 0) {
730
+ await this.touch(namespace, kind, metadata);
731
+ return;
732
+ }
733
+ // Validate up front so an unsafe namespace rejects deterministically, exactly
734
+ // as the immediate-append path does (touch validates too).
735
+ const ns = this.validateNamespace(namespace);
736
+ const cached = this.compactedCache?.records.get(ns);
737
+ if (cached === undefined || this.touchMustFlushImmediately(kind, metadata, cached)) {
738
+ // Semantically observable in-process → flush now (also warms the cache).
739
+ // Any older buffered touch for this key is now redundant (this newer touch
740
+ // updates the same field with a newer timestamp): drop it so its stale
741
+ // timer cannot regress the timestamp after this flush.
742
+ const stale = this.pendingTouches.get(`${kind}:${ns}`);
743
+ if (stale) {
744
+ clearTimeout(stale.timer);
745
+ this.pendingTouches.delete(`${kind}:${ns}`);
746
+ }
747
+ await this.touch(namespace, kind, metadata);
748
+ return;
749
+ }
750
+ // Pure timestamp refresh on a known record → coalesce within a FIXED window.
751
+ const key = `${kind}:${ns}`;
752
+ const at = metadata?.at ?? new Date();
753
+ const existing = this.pendingTouches.get(key);
754
+ if (existing) {
755
+ // Keep the ORIGINAL timer and only refresh the buffered payload (#1903,
756
+ // Codex). Re-arming on every touch would make this a debounce that never
757
+ // flushes under continuous traffic (e.g. a sustained import), leaving
758
+ // lastWriteAt stale and writtenSince/maintenance consumers blind to an
759
+ // actively-written namespace. With a fixed window, each window flushes on
760
+ // schedule carrying the newest buffered timestamp.
761
+ existing.metadata = metadata;
762
+ existing.at = at;
763
+ return;
764
+ }
765
+ // Cap at Node's maximum setTimeout delay (2^31-1 ms ≈ 24.8 days). Values
766
+ // above that are clamped by Node to 1ms, which would append almost every
767
+ // touch and recreate the churn this coalescing exists to prevent (#1903, Codex).
768
+ const safeWindow = Math.min(window, 2_147_483_647);
769
+ const timer = setTimeout(() => {
770
+ void this.flushPendingTouch(key);
771
+ }, safeWindow);
772
+ timer.unref();
773
+ this.pendingTouches.set(key, { namespace: ns, kind, metadata, at, timer });
774
+ }
775
+
776
+ /**
777
+ * Whether a read/write touch on an EXISTING cached record must flush
778
+ * immediately rather than coalesce. Only pure timestamp refreshes (the value
779
+ * returns false) are safe to defer.
780
+ */
781
+ private touchMustFlushImmediately(
782
+ kind: "read" | "write",
783
+ metadata: NamespaceTouchMetadata | undefined,
784
+ cached: NamespaceRecord,
785
+ ): boolean {
786
+ // A write upgrading a config pre-registration to "write" changes the
787
+ // discoveredBy filter result — must be observable at once (~:1261).
788
+ if (kind === "write" && cached.discoveredBy === "config") return true;
789
+ // The first time a touch field is set makes the namespace newly match
790
+ // presence/`writtenSince` reads, so it must not be deferred.
791
+ if (kind === "write" && cached.lastWriteAt === undefined) return true;
792
+ if (kind === "read" && cached.lastReadAt === undefined) return true;
793
+ if (!metadata) return false;
794
+ if (metadata.kind !== undefined && metadata.kind !== cached.kind) return true;
795
+ if (metadata.principal !== undefined && metadata.principal !== cached.principal) return true;
796
+ if (metadata.projectId !== undefined && metadata.projectId !== cached.projectId) return true;
797
+ if (metadata.branch !== undefined && metadata.branch !== cached.branch) return true;
798
+ if (metadata.parentNamespace !== undefined && metadata.parentNamespace !== cached.parentNamespace) {
799
+ return true;
800
+ }
801
+ // A changed storageDir repoints routing/containment for this namespace —
802
+ // must be observable at once, not held stale by the coalesce window (#1903, Cursor).
803
+ if (metadata.storageDir !== undefined && metadata.storageDir !== cached.storageDir) return true;
804
+ return false;
805
+ }
806
+
807
+ /** Fire a single buffered touch when its coalescing window elapses. */
808
+ private async flushPendingTouch(key: string): Promise<void> {
809
+ const pending = this.pendingTouches.get(key);
810
+ if (!pending) return;
811
+ this.pendingTouches.delete(key);
812
+ clearTimeout(pending.timer);
813
+ await this.touch(pending.namespace, pending.kind, { ...pending.metadata, at: pending.at }).catch(
814
+ () => undefined,
815
+ );
816
+ }
817
+
818
+ /**
819
+ * Flush every buffered coalesced touch now (issue #1903). Called from the
820
+ * router/orchestrator shutdown hook so a long-lived host does not drop
821
+ * buffered read/write timestamps on teardown, and by tests to make coalesced
822
+ * touches deterministically observable. Best-effort: a failed flush never
823
+ * throws (each touch is `.catch()`-guarded).
824
+ */
825
+ async flushPendingTouches(): Promise<void> {
826
+ const pending = [...this.pendingTouches.values()];
827
+ this.pendingTouches.clear();
828
+ for (const p of pending) {
829
+ clearTimeout(p.timer);
830
+ await this.touch(p.namespace, p.kind, { ...p.metadata, at: p.at }).catch(() => undefined);
831
+ }
671
832
  }
672
833
 
673
834
  async markMaintenance(namespace: string, jobName: string, at?: Date): Promise<void> {
@@ -1269,11 +1430,48 @@ export class NamespaceCatalog {
1269
1430
  }
1270
1431
 
1271
1432
  await this.appendUnchained(record);
1433
+ // Size-triggered auto-compaction (issue #1903), still inside the held
1434
+ // cross-process lock + queueCritical turn so the fold→rename is atomic
1435
+ // against concurrent touches and cross-process rebuilds.
1436
+ await this.maybeAutoCompact();
1272
1437
  return true;
1273
1438
  }),
1274
1439
  );
1275
1440
  }
1276
1441
 
1442
+ /**
1443
+ * Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
1444
+ * (issue #1903). MUST be called only from inside `touch`'s held-lock +
1445
+ * `queueCritical` critical section — it reuses `loadCompacted` (last-record-
1446
+ * wins fold; every namespace row survives) and `rewriteUnchained` (atomic
1447
+ * temp-file + rename), so the collapse is cross-process safe and never
1448
+ * invokes the disk-scan purge in `finishRebuild`. Best-effort and fail-open
1449
+ * (rule #40): the append already succeeded and is durable, so a failed
1450
+ * compaction must not fail the touch — the next over-limit touch retries.
1451
+ */
1452
+ private async maybeAutoCompact(): Promise<void> {
1453
+ try {
1454
+ const limit = this.compactBytesLimit; // 0 => auto-compaction disabled
1455
+ if (limit <= 0) return;
1456
+ // Exact post-append size when the cache is warm; skip this touch's check
1457
+ // when the cache is cold (a cross-process append invalidated it) — the
1458
+ // next touch re-warms and re-checks.
1459
+ const size = this.compactedCache?.identity.size;
1460
+ if (size === undefined || size <= limit) return;
1461
+ // Hysteresis (issue #1903, Codex): a folded catalog whose deduped state is
1462
+ // itself above the limit (many distinct namespaces) would otherwise be
1463
+ // re-folded + re-rewritten on EVERY touch — O(catalog) per touch. Only
1464
+ // compact again once the log has grown by >= one limit-worth since the last
1465
+ // compaction; by then the new appends are duplicates the fold can collapse.
1466
+ if (this.lastCompactedSize > 0 && size - this.lastCompactedSize < limit) return;
1467
+ const folded = await this.loadCompacted();
1468
+ await this.rewriteUnchained([...folded.values()]);
1469
+ this.lastCompactedSize = this.compactedCache?.identity.size ?? size;
1470
+ } catch {
1471
+ // Fail-open: compaction is best-effort maintenance on already-durable data.
1472
+ }
1473
+ }
1474
+
1277
1475
  // ── Rebuild from disk ────────────────────────────────────────────────────
1278
1476
 
1279
1477
  async rebuildFromDisk(
@@ -1957,25 +2155,52 @@ export class NamespaceCatalog {
1957
2155
 
1958
2156
  try {
1959
2157
  this.onCatalogReadForTest?.();
1960
- const raw = await handle.readFile("utf8");
1961
- for (const line of raw.split("\n")) {
2158
+ // Streaming chunked parse (issue #1903): the log can reach 100+ MB in
2159
+ // production, so read it in bounded chunks and yield to the event loop
2160
+ // every ~5000 lines instead of buffering the whole file and blocking on a
2161
+ // single giant `split("\n")`. The fold is unchanged (last-record-wins with
2162
+ // field-level touch merge across cross-process full-snapshot appends).
2163
+ const CHUNK_BYTES = 1 << 16; // 64 KiB
2164
+ const YIELD_EVERY_LINES = 5000;
2165
+ const chunk = Buffer.allocUnsafe(CHUNK_BYTES);
2166
+ // StringDecoder buffers partial multi-byte UTF-8 sequences that straddle a
2167
+ // chunk boundary, so a namespace/principal with non-ASCII bytes never
2168
+ // corrupts at the seam.
2169
+ const decoder = new StringDecoder("utf8");
2170
+ let leftover = "";
2171
+ let linesSinceYield = 0;
2172
+ const foldLine = (line: string): void => {
1962
2173
  const trimmed = line.trim();
1963
- if (trimmed.length === 0) continue;
2174
+ if (trimmed.length === 0) return;
1964
2175
  let parsed: unknown;
1965
2176
  try {
1966
2177
  parsed = JSON.parse(trimmed);
1967
2178
  } catch {
1968
2179
  // Skip corrupt lines (CLAUDE.md rule #18 robustness).
1969
- continue;
2180
+ return;
1970
2181
  }
1971
2182
  const record = coerceRecord(parsed);
1972
- if (!record) continue;
1973
- // Preserve each touch field across cross-process full-snapshot appends.
1974
- // Field-level touch merge preserves the newest value for every touch
1975
- // field when full-snapshot appends from separate processes interleave.
2183
+ if (!record) return;
1976
2184
  const prior = records.get(record.namespace);
1977
2185
  records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
2186
+ };
2187
+ for (;;) {
2188
+ const { bytesRead } = await handle.read(chunk, 0, CHUNK_BYTES, null);
2189
+ if (bytesRead === 0) break;
2190
+ leftover += decoder.write(chunk.subarray(0, bytesRead));
2191
+ let newlineIndex = leftover.indexOf("\n");
2192
+ while (newlineIndex !== -1) {
2193
+ foldLine(leftover.slice(0, newlineIndex));
2194
+ leftover = leftover.slice(newlineIndex + 1);
2195
+ if (++linesSinceYield >= YIELD_EVERY_LINES) {
2196
+ linesSinceYield = 0;
2197
+ await new Promise<void>((resolve) => setImmediate(resolve));
2198
+ }
2199
+ newlineIndex = leftover.indexOf("\n");
2200
+ }
1978
2201
  }
2202
+ leftover += decoder.end();
2203
+ foldLine(leftover);
1979
2204
  } catch {
1980
2205
  this.compactedCache = undefined;
1981
2206
  return new Map();
@@ -414,6 +414,10 @@ export class NamespaceStorageRouter {
414
414
  * default-namespace storage (`this.storage`) that bypasses the router.
415
415
  */
416
416
  bindCatalogWriteHook(sm: StorageManager, namespace: string): void {
417
+ // Issue #1903: pure state-file writes only touch the catalog when explicitly
418
+ // opted in. Router-created storages (storageFor) and the legacy default
419
+ // storage both flow through here, so this is the single wiring point.
420
+ sm.touchStateWrites = this.config.namespacesCatalogTouchStateWrites === true;
417
421
  sm.onCatalogWrite = () => this.touchCatalogWrite(namespace, sm.dir);
418
422
  }
419
423
 
@@ -505,6 +509,11 @@ export class NamespaceStorageRouter {
505
509
  * moved should await this instead of racing a timer.
506
510
  */
507
511
  async whenWriteTouchesSettled(): Promise<void> {
512
+ // Flush any coalesced (buffered) touches first so their appends join the
513
+ // pending set below — otherwise this could resolve while a buffered touch's
514
+ // lastWriteAt is still unwritten on disk (#1903, Cursor). No-op when
515
+ // coalescing is disabled (window 0) or the catalog is inert.
516
+ await this.catalog?.flushPendingTouches().catch(() => undefined);
508
517
  while (this.pendingWriteTouches.size > 0) {
509
518
  await Promise.allSettled([...this.pendingWriteTouches]);
510
519
  }
@@ -948,6 +948,9 @@ export class Orchestrator {
948
948
  }
949
949
  this.maintenanceScheduler.dispose();
950
950
  await drainRecallWrites(this);
951
+ // Issue #1903: flush any coalesced namespace-catalog touches before teardown
952
+ // so a long-lived host does not drop buffered read/write timestamps.
953
+ await this.namespaceCatalog.flushPendingTouches().catch(() => undefined);
951
954
  await this.namespaceSearchRouter.dispose();
952
955
  await (this.qmd as { dispose?: () => void | Promise<void> }).dispose?.();
953
956
  if (this.conversationQmd && this.conversationQmd !== this.qmd) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Issue #1903 — state-file writes must not touch the namespace catalog.
3
+ *
4
+ * The post-write catalog hook (`onCatalogWrite`) fires on every successful
5
+ * secure-file write. Pure state files (`state/buffer.json`, ledgers, indexes)
6
+ * are NOT namespace memory data, so by default their writes must be excluded
7
+ * from the catalog touch path (they were the dominant source of catalog churn
8
+ * that grew `state/namespaces.jsonl` unbounded). Namespace data writes
9
+ * (`facts/`, ...) still touch. The `touchStateWrites` opt-in restores the
10
+ * pre-#1903 behavior where every write touched.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+
19
+ import { StorageManager } from "./storage.js";
20
+ import type { BufferState } from "./types.js";
21
+
22
+ async function mkDir(): Promise<string> {
23
+ return await mkdtemp(path.join(os.tmpdir(), "remnic-touch-gate-"));
24
+ }
25
+
26
+ function emptyBuffer(): BufferState {
27
+ return { turns: [], lastExtractionAt: null, extractionCount: 0 };
28
+ }
29
+
30
+ test("#1903 state-file writes do not touch the catalog by default; data writes do", async () => {
31
+ const dir = await mkDir();
32
+ try {
33
+ const storage = new StorageManager(dir);
34
+ await storage.ensureDirectories();
35
+ let touches = 0;
36
+ storage.onCatalogWrite = () => {
37
+ touches += 1;
38
+ };
39
+
40
+ // A pure state-file write (state/buffer.json) must NOT touch the catalog.
41
+ await storage.saveBuffer(emptyBuffer());
42
+ assert.equal(touches, 0, "saving buffer.json (a state file) records no catalog touch");
43
+
44
+ // A namespace-data write (facts/) MUST touch exactly once. The lifecycle
45
+ // ledger append it also performs is a state write and stays excluded.
46
+ touches = 0;
47
+ await storage.writeMemory("fact", "The database is PostgreSQL.", { confidence: 0.9 });
48
+ assert.equal(touches, 1, "writing a fact records exactly one catalog touch");
49
+ } finally {
50
+ await rm(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test("#1903 touchStateWrites=true restores the state-file catalog touch", async () => {
55
+ const dir = await mkDir();
56
+ try {
57
+ const storage = new StorageManager(dir);
58
+ await storage.ensureDirectories();
59
+ storage.touchStateWrites = true;
60
+ let touches = 0;
61
+ storage.onCatalogWrite = () => {
62
+ touches += 1;
63
+ };
64
+
65
+ await storage.saveBuffer(emptyBuffer());
66
+ assert.ok(touches >= 1, "with touchStateWrites enabled, saving buffer.json touches the catalog");
67
+ } finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
package/src/storage.ts CHANGED
@@ -2447,6 +2447,32 @@ export class StorageManager {
2447
2447
  }
2448
2448
  }
2449
2449
 
2450
+ /**
2451
+ * Whether pure state-file writes touch the namespace catalog (issue #1903).
2452
+ * Set by `NamespaceStorageRouter.bindCatalogWriteHook` from
2453
+ * `namespacesCatalogTouchStateWrites` (default false). State files
2454
+ * (`state/buffer.json`, ledgers, indexes) are not namespace memory data, so by
2455
+ * default their writes do NOT record a catalog touch.
2456
+ */
2457
+ touchStateWrites = false;
2458
+
2459
+ /**
2460
+ * Post-write catalog hook, gated by file path (issue #1903). Namespace data
2461
+ * (`facts/`, `cold/`, `entities/`, `artifacts/`, `profile.md`, ...) lives
2462
+ * OUTSIDE `stateDir`, so a data write always touches; a write under `stateDir`
2463
+ * is a pure state write and is skipped unless `touchStateWrites` is enabled.
2464
+ */
2465
+ private notifyCatalogWriteForPath(filePath: string): void {
2466
+ if (!this.touchStateWrites && this.isStateFilePath(filePath)) return;
2467
+ this.notifyCatalogWrite();
2468
+ }
2469
+
2470
+ /** Whether `filePath` resolves to `stateDir` itself or a path contained in it. */
2471
+ private isStateFilePath(filePath: string): boolean {
2472
+ const rel = path.relative(this.stateDir, path.resolve(filePath));
2473
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
2474
+ }
2475
+
2450
2476
  // ── Tombstone store (issue #1579) ───────────────────────────────────────
2451
2477
  // Instance-scoped (rule 11) + namespace-scoped (rule 42) tombstone index.
2452
2478
  // Lazily loaded from <stateDir>/tombstones.jsonl; invalidated together with
@@ -3083,12 +3109,12 @@ export class StorageManager {
3083
3109
  }
3084
3110
  private writeStorageSecureFile(filePath: string, content: string | Buffer): Promise<void> {
3085
3111
  return writeMaybeEncryptedFile(filePath, content, this.resolveWriteKey(), {}, this.baseDir).then(() =>
3086
- this.notifyCatalogWrite()
3112
+ this.notifyCatalogWriteForPath(filePath)
3087
3113
  );
3088
3114
  }
3089
3115
  private writeStorageSecureFileChunks(filePath: string, chunks: AsyncIterable<Buffer>): Promise<void> {
3090
3116
  return writeMaybeEncryptedFileFromChunks(filePath, chunks, this.resolveWriteKey(), {}, this.baseDir).then(() =>
3091
- this.notifyCatalogWrite()
3117
+ this.notifyCatalogWriteForPath(filePath)
3092
3118
  );
3093
3119
  }
3094
3120
 
@@ -3340,14 +3366,14 @@ export class StorageManager {
3340
3366
  if (isEncryptedFile(await readFile(filePath))) {
3341
3367
  const existing = await this.readStorageSecureFile(filePath);
3342
3368
  await writeMaybeEncryptedFile(filePath, `${existing}${content}`, null, {}, this.baseDir);
3343
- this.notifyCatalogWrite();
3369
+ this.notifyCatalogWriteForPath(filePath);
3344
3370
  return;
3345
3371
  }
3346
3372
  } catch (err) {
3347
3373
  if (!isErrnoCode(err, "ENOENT")) throw err;
3348
3374
  }
3349
3375
  await appendFile(filePath, content, "utf-8");
3350
- this.notifyCatalogWrite();
3376
+ this.notifyCatalogWriteForPath(filePath);
3351
3377
  return;
3352
3378
  }
3353
3379
 
@@ -3358,7 +3384,7 @@ export class StorageManager {
3358
3384
  if (!isErrnoCode(err, "ENOENT")) throw err;
3359
3385
  }
3360
3386
  await writeMaybeEncryptedFile(filePath, `${existing}${content}`, writeKey, {}, this.baseDir);
3361
- this.notifyCatalogWrite();
3387
+ this.notifyCatalogWriteForPath(filePath);
3362
3388
  }
3363
3389
  private get stateDir(): string {
3364
3390
  return path.join(this.baseDir, "state");
package/src/types.ts CHANGED
@@ -1690,6 +1690,40 @@ export interface PluginConfig {
1690
1690
  * existing single-namespace behavior is unchanged. Default on.
1691
1691
  */
1692
1692
  namespaceCatalogEnabled: boolean;
1693
+ /**
1694
+ * Size (bytes) at which a namespace-catalog touch triggers an in-lock
1695
+ * auto-compaction of `state/namespaces.jsonl` (issue #1903). The append-only
1696
+ * log grows one line per touch; once it exceeds this many bytes the next
1697
+ * touch folds it (last-record-wins, every namespace row preserved) and
1698
+ * atomically rewrites it. Default `16 * 1024 * 1024` (16 MB). `0` disables
1699
+ * auto-compaction (restores the pre-#1903 unbounded-append behavior).
1700
+ */
1701
+ namespacesCatalogCompactBytes: number;
1702
+ /**
1703
+ * Coalescing window (ms) for pure-timestamp READ touches on an
1704
+ * already-known namespace record (issue #1903). Repeated `markRead` touches
1705
+ * that only refresh `lastReadAt` are buffered per (namespace, kind) and
1706
+ * flushed once per window instead of appending a line each time.
1707
+ * Semantically-observable touches (first sight, provenance/field changes)
1708
+ * always flush immediately. Default `60000`. `0` disables read coalescing
1709
+ * (each touch appends immediately, the pre-#1903 behavior).
1710
+ */
1711
+ namespacesCatalogReadTouchCoalesceMs: number;
1712
+ /**
1713
+ * Coalescing window (ms) for pure-timestamp WRITE touches on an
1714
+ * already-known namespace record (issue #1903). Mirrors
1715
+ * `namespacesCatalogReadTouchCoalesceMs` for `markWrite`/`lastWriteAt`.
1716
+ * Default `1000`. `0` disables write coalescing.
1717
+ */
1718
+ namespacesCatalogWriteTouchCoalesceMs: number;
1719
+ /**
1720
+ * Whether pure state-file writes (`state/buffer.json`, ledgers, indexes)
1721
+ * touch the namespace catalog (issue #1903). State files are not namespace
1722
+ * memory data, so by default their writes do NOT record a catalog touch.
1723
+ * Set `true` to restore the pre-#1903 behavior where every secure-file
1724
+ * write touched the catalog. Default `false`.
1725
+ */
1726
+ namespacesCatalogTouchStateWrites: boolean;
1693
1727
  defaultNamespace: string;
1694
1728
  sharedNamespace: string;
1695
1729
  principalFromSessionKeyMode: PrincipalFromSessionKeyMode;