@remnic/core 9.7.11 → 9.7.13

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 (286) 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 +9 -9
  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-schema.d.ts +64 -64
  22. package/dist/{access-service-DSuzikHQ.d.ts → access-service-tHd3eQpd.d.ts} +4 -4
  23. package/dist/access-service.d.ts +6 -6
  24. package/dist/access-service.js +7 -7
  25. package/dist/access-surface-catalog.d.ts +6 -6
  26. package/dist/action-confidence.d.ts +1 -1
  27. package/dist/active-memory-bridge.d.ts +1 -1
  28. package/dist/active-recall.d.ts +1 -1
  29. package/dist/active-recall.js +1 -1
  30. package/dist/behavior-learner.d.ts +1 -1
  31. package/dist/behavior-signals.d.ts +1 -1
  32. package/dist/bootstrap.d.ts +5 -5
  33. package/dist/briefing.d.ts +2 -2
  34. package/dist/briefing.js +3 -3
  35. package/dist/buffer-surprise-report.d.ts +1 -1
  36. package/dist/buffer.d.ts +2 -2
  37. package/dist/bulk-import/index.d.ts +3 -3
  38. package/dist/calibration.d.ts +1 -1
  39. package/dist/capabilities.d.ts +1 -1
  40. package/dist/{catalog-BNwjACa5.d.ts → catalog-DEMiw6HZ.d.ts} +48 -1
  41. package/dist/causal-behavior.d.ts +1 -1
  42. package/dist/causal-consolidation.d.ts +1 -1
  43. package/dist/causal-consolidation.js +4 -4
  44. package/dist/causal-trajectory-graph.d.ts +1 -1
  45. package/dist/{chunk-U4KHQTJ6.js → chunk-2AL7N3BH.js} +4 -2
  46. package/dist/{chunk-U4KHQTJ6.js.map → chunk-2AL7N3BH.js.map} +1 -1
  47. package/dist/{chunk-L4H6MQ7I.js → chunk-2RSL6YRA.js} +2 -2
  48. package/dist/{chunk-QHT4W3RU.js → chunk-2V5QI6NQ.js} +41 -9
  49. package/dist/chunk-2V5QI6NQ.js.map +1 -0
  50. package/dist/{chunk-JYRFO4EH.js → chunk-2ZPQUTMW.js} +4 -4
  51. package/dist/{chunk-673NPXE3.js → chunk-3SIC3E65.js} +2 -2
  52. package/dist/{chunk-OVMS3DCB.js → chunk-675YU5IK.js} +2 -2
  53. package/dist/{chunk-7LMEY5KZ.js → chunk-6N7R7TR2.js} +34 -1
  54. package/dist/chunk-6N7R7TR2.js.map +1 -0
  55. package/dist/{chunk-TGBJ2OMK.js → chunk-BQTSGK5Y.js} +2 -2
  56. package/dist/{chunk-6LM6PPDX.js → chunk-CE3GMHT4.js} +2 -2
  57. package/dist/{chunk-YXOAQAH2.js → chunk-CLFY35ED.js} +3 -3
  58. package/dist/{chunk-HQ7QZJM5.js → chunk-D4TLWDYR.js} +2 -2
  59. package/dist/{chunk-LQKITFGB.js → chunk-FUUF7FMX.js} +2 -2
  60. package/dist/{chunk-3ZVFLMFQ.js → chunk-G5PVJJRA.js} +1 -1
  61. package/dist/chunk-G5PVJJRA.js.map +1 -0
  62. package/dist/{chunk-MXRMNKSY.js → chunk-I3GKTDM2.js} +3 -3
  63. package/dist/{chunk-EQF5763Z.js → chunk-IQULRIZM.js} +2 -2
  64. package/dist/{chunk-W6R43NLA.js → chunk-KQMKF4G2.js} +17 -17
  65. package/dist/{chunk-DJCKXI4M.js → chunk-NVXQTRBL.js} +2 -2
  66. package/dist/{chunk-GHVJASQ4.js → chunk-OCY3CRPG.js} +3 -3
  67. package/dist/{chunk-YLTG6BO4.js → chunk-OIFZII6J.js} +2 -2
  68. package/dist/{chunk-XF4KOVDK.js → chunk-PA6DMYEK.js} +6 -6
  69. package/dist/{chunk-3OQ4IZJN.js → chunk-QPZCAVDC.js} +7 -7
  70. package/dist/{chunk-DLWYAN6A.js → chunk-T6U7HDIY.js} +2 -2
  71. package/dist/{chunk-JZM5QUTE.js → chunk-TMGK6YE7.js} +171 -9
  72. package/dist/chunk-TMGK6YE7.js.map +1 -0
  73. package/dist/{chunk-KXLRMM35.js → chunk-TRH533CA.js} +30 -7
  74. package/dist/chunk-TRH533CA.js.map +1 -0
  75. package/dist/{chunk-UTDA43TB.js → chunk-TWGGNUWJ.js} +2 -2
  76. package/dist/{chunk-SBFDL2EQ.js → chunk-UTZW3X2R.js} +2 -2
  77. package/dist/{chunk-SECMZACR.js → chunk-XUQUBZ7O.js} +2 -2
  78. package/dist/{chunk-5U65RIVI.js → chunk-YI7XUXLX.js} +16 -15
  79. package/dist/chunk-YI7XUXLX.js.map +1 -0
  80. package/dist/{chunk-7XRUYC6G.js → chunk-Z6WN2KSH.js} +19 -5
  81. package/dist/chunk-Z6WN2KSH.js.map +1 -0
  82. package/dist/{chunk-WUDBMLC6.js → chunk-ZC3HG22I.js} +2 -2
  83. package/dist/{chunk-VCEH5UYS.js → chunk-ZRXSHGXT.js} +2 -2
  84. package/dist/{cli-DACyuK8-.d.ts → cli-D8Tsa4Sh.d.ts} +5 -5
  85. package/dist/cli.d.ts +8 -8
  86. package/dist/cli.js +25 -25
  87. package/dist/compounding/engine.d.ts +2 -2
  88. package/dist/compounding/engine.js +3 -3
  89. package/dist/compounding/preference-consolidator.d.ts +1 -1
  90. package/dist/compression-optimizer.d.ts +1 -1
  91. package/dist/config.d.ts +1 -1
  92. package/dist/config.js +1 -1
  93. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  94. package/dist/connectors/codex-materialize-runner.js +3 -3
  95. package/dist/connectors/codex-materialize.d.ts +1 -1
  96. package/dist/connectors/index.d.ts +1 -1
  97. package/dist/connectors/index.js +3 -3
  98. package/dist/consolidation-provenance-check.d.ts +2 -2
  99. package/dist/consolidation-undo.d.ts +2 -2
  100. package/dist/contradiction/index.d.ts +2 -2
  101. package/dist/conversation-index/backend.d.ts +1 -1
  102. package/dist/conversation-index/chunker.d.ts +1 -1
  103. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  104. package/dist/conversation-index/indexer.d.ts +1 -1
  105. package/dist/conversation-index/search.d.ts +1 -1
  106. package/dist/day-summary.d.ts +1 -1
  107. package/dist/delinearize.d.ts +1 -1
  108. package/dist/direct-answer-wiring.d.ts +1 -1
  109. package/dist/direct-answer.d.ts +1 -1
  110. package/dist/embedding-fallback.d.ts +1 -1
  111. package/dist/enrichment/index.d.ts +1 -1
  112. package/dist/entity-retrieval.d.ts +2 -2
  113. package/dist/entity-retrieval.js +3 -3
  114. package/dist/entity-schema.d.ts +1 -1
  115. package/dist/explicit-capture.d.ts +5 -5
  116. package/dist/extraction-faithfulness.d.ts +1 -1
  117. package/dist/extraction-judge-telemetry.d.ts +1 -1
  118. package/dist/extraction-judge-training.d.ts +1 -1
  119. package/dist/extraction-judge.d.ts +1 -1
  120. package/dist/extraction.d.ts +1 -1
  121. package/dist/fallback-llm.d.ts +1 -1
  122. package/dist/graph-dashboard-diff.d.ts +1 -1
  123. package/dist/graph-dashboard-key.d.ts +1 -1
  124. package/dist/graph-dashboard-parser.d.ts +1 -1
  125. package/dist/graph-edge-reinforcement.d.ts +1 -1
  126. package/dist/graph-snapshot.d.ts +1 -1
  127. package/dist/graph.d.ts +1 -1
  128. package/dist/identity-continuity.d.ts +1 -1
  129. package/dist/importance.d.ts +1 -1
  130. package/dist/importers/index.d.ts +1 -1
  131. package/dist/in-flight-reads.d.ts +1 -1
  132. package/dist/index.d.ts +386 -386
  133. package/dist/index.js +31 -31
  134. package/dist/intent.d.ts +1 -1
  135. package/dist/lcm/engine.d.ts +1 -1
  136. package/dist/lcm/index.d.ts +1 -1
  137. package/dist/lcm/tools.d.ts +1 -1
  138. package/dist/lifecycle.d.ts +1 -1
  139. package/dist/live-connectors-runner.d.ts +1 -1
  140. package/dist/local-llm.d.ts +1 -1
  141. package/dist/local-model-endpoint.d.ts +1 -1
  142. package/dist/maintenance/memory-governance.d.ts +1 -1
  143. package/dist/maintenance/memory-governance.js +3 -3
  144. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  145. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  146. package/dist/mcp-memory-inspector-app.d.ts +6 -6
  147. package/dist/memory-action-policy.d.ts +1 -1
  148. package/dist/memory-cache.d.ts +1 -1
  149. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  150. package/dist/memory-projection-store.d.ts +1 -1
  151. package/dist/memory-provenance.d.ts +1 -1
  152. package/dist/memory-worth-outcomes.d.ts +2 -2
  153. package/dist/models-json.d.ts +1 -1
  154. package/dist/namespaces/migrate.d.ts +3 -3
  155. package/dist/namespaces/migrate.js +4 -4
  156. package/dist/namespaces/principal.d.ts +1 -1
  157. package/dist/namespaces/search.d.ts +1 -1
  158. package/dist/namespaces/storage.d.ts +3 -3
  159. package/dist/namespaces/storage.js +3 -3
  160. package/dist/native-knowledge.d.ts +1 -1
  161. package/dist/operator-toolkit.d.ts +2 -2
  162. package/dist/operator-toolkit.js +8 -8
  163. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -2
  164. package/dist/orchestration/maintenance.d.ts +2 -2
  165. package/dist/orchestration/maintenance.js +5 -5
  166. package/dist/{orchestrator-D83AvrPf.d.ts → orchestrator-B_WXBmuE.d.ts} +4 -4
  167. package/dist/orchestrator.d.ts +5 -5
  168. package/dist/orchestrator.js +31 -31
  169. package/dist/patterns-cli.d.ts +1 -1
  170. package/dist/{pipeline-BSqGpYl1.d.ts → pipeline-Dvtvt141.d.ts} +1 -1
  171. package/dist/policy-runtime.d.ts +1 -1
  172. package/dist/provenance.d.ts +1 -1
  173. package/dist/{qmd-CutnpRVe.d.ts → qmd-DwBLh_hl.d.ts} +1 -1
  174. package/dist/qmd-preflight.d.ts +2 -2
  175. package/dist/qmd-recall-cache.d.ts +1 -1
  176. package/dist/qmd.d.ts +2 -2
  177. package/dist/recall-disclosure-escalation.d.ts +1 -1
  178. package/dist/recall-explain-renderer.d.ts +1 -1
  179. package/dist/recall-explain-renderer.js +3 -3
  180. package/dist/recall-planner-llm.d.ts +1 -1
  181. package/dist/recall-state.d.ts +1 -1
  182. package/dist/recall-tag-filter.d.ts +1 -1
  183. package/dist/recall-timings.d.ts +1 -1
  184. package/dist/recall-xray-cli.d.ts +1 -1
  185. package/dist/recall-xray-cli.js +4 -4
  186. package/dist/recall-xray-renderer.d.ts +1 -1
  187. package/dist/recall-xray-renderer.js +3 -3
  188. package/dist/recall-xray.d.ts +1 -1
  189. package/dist/recall-xray.js +2 -2
  190. package/dist/replay/normalizers/chatgpt.d.ts +1 -1
  191. package/dist/replay/normalizers/claude.d.ts +1 -1
  192. package/dist/replay/normalizers/openclaw.d.ts +1 -1
  193. package/dist/replay/normalizers/shared.d.ts +1 -1
  194. package/dist/replay/runner.d.ts +1 -1
  195. package/dist/replay/types.d.ts +1 -1
  196. package/dist/resolve-auth-token.d.ts +1 -1
  197. package/dist/resume-bundles.js +2 -2
  198. package/dist/retrieval-agents.d.ts +2 -2
  199. package/dist/retrieval-tiers.d.ts +1 -1
  200. package/dist/routing/engine.d.ts +1 -1
  201. package/dist/routing/store.d.ts +1 -1
  202. package/dist/schemas.d.ts +74 -74
  203. package/dist/search/embed-helper.d.ts +1 -1
  204. package/dist/search/factory.d.ts +1 -1
  205. package/dist/search/index.d.ts +1 -1
  206. package/dist/search/lancedb-backend.d.ts +1 -1
  207. package/dist/search/meilisearch-backend.d.ts +1 -1
  208. package/dist/search/noop-backend.d.ts +1 -1
  209. package/dist/search/orama-backend.d.ts +1 -1
  210. package/dist/search/port.d.ts +1 -1
  211. package/dist/search/remote-backend.d.ts +1 -1
  212. package/dist/{semantic-consolidation-btIVRC4l.d.ts → semantic-consolidation-Dia_6FOn.d.ts} +1 -1
  213. package/dist/semantic-consolidation.d.ts +2 -2
  214. package/dist/semantic-consolidation.js +4 -4
  215. package/dist/semantic-rule-promotion.js +3 -3
  216. package/dist/semantic-rule-verifier.d.ts +1 -1
  217. package/dist/semantic-rule-verifier.js +3 -3
  218. package/dist/session-observer-bands.d.ts +1 -1
  219. package/dist/session-observer-state.d.ts +1 -1
  220. package/dist/shared-context/manager.d.ts +9 -9
  221. package/dist/signal.d.ts +1 -1
  222. package/dist/{storage-BVv_Mqa0.d.ts → storage-RshM7i8-.d.ts} +18 -1
  223. package/dist/storage.d.ts +2 -2
  224. package/dist/storage.js +2 -2
  225. package/dist/summarizer.d.ts +1 -1
  226. package/dist/summary-snapshot.d.ts +1 -1
  227. package/dist/temporal-supersession.d.ts +2 -2
  228. package/dist/temporal-timeline-recall.d.ts +1 -1
  229. package/dist/temporal-validity.d.ts +1 -1
  230. package/dist/threading.d.ts +1 -1
  231. package/dist/tier-migration.d.ts +2 -2
  232. package/dist/tier-routing.d.ts +1 -1
  233. package/dist/topics.d.ts +1 -1
  234. package/dist/transcript.d.ts +1 -1
  235. package/dist/transfer/types.d.ts +66 -66
  236. package/dist/trust-score-stage.d.ts +1 -1
  237. package/dist/trust-score.d.ts +1 -1
  238. package/dist/{types-Cmkz7csa.d.ts → types-CHSXOq7s.d.ts} +34 -0
  239. package/dist/types.d.ts +1 -1
  240. package/dist/types.js +1 -1
  241. package/dist/utility-runtime.d.ts +1 -1
  242. package/dist/verified-recall.js +3 -3
  243. package/package.json +2 -2
  244. package/src/access-http.ts +40 -2
  245. package/src/config.test.ts +44 -0
  246. package/src/config.ts +43 -0
  247. package/src/namespaces/catalog.test.ts +258 -0
  248. package/src/namespaces/catalog.ts +235 -10
  249. package/src/namespaces/storage.ts +9 -0
  250. package/src/orchestrator.ts +3 -0
  251. package/src/relay/mission-access.test.ts +58 -3
  252. package/src/relay/mission-access.ts +17 -3
  253. package/src/relay/mission-ui.test.ts +90 -0
  254. package/src/storage-catalog-touch-gating.test.ts +70 -0
  255. package/src/storage.ts +31 -5
  256. package/src/types.ts +34 -0
  257. package/dist/chunk-3ZVFLMFQ.js.map +0 -1
  258. package/dist/chunk-5U65RIVI.js.map +0 -1
  259. package/dist/chunk-7LMEY5KZ.js.map +0 -1
  260. package/dist/chunk-7XRUYC6G.js.map +0 -1
  261. package/dist/chunk-JZM5QUTE.js.map +0 -1
  262. package/dist/chunk-KXLRMM35.js.map +0 -1
  263. package/dist/chunk-QHT4W3RU.js.map +0 -1
  264. /package/dist/{chunk-L4H6MQ7I.js.map → chunk-2RSL6YRA.js.map} +0 -0
  265. /package/dist/{chunk-JYRFO4EH.js.map → chunk-2ZPQUTMW.js.map} +0 -0
  266. /package/dist/{chunk-673NPXE3.js.map → chunk-3SIC3E65.js.map} +0 -0
  267. /package/dist/{chunk-OVMS3DCB.js.map → chunk-675YU5IK.js.map} +0 -0
  268. /package/dist/{chunk-TGBJ2OMK.js.map → chunk-BQTSGK5Y.js.map} +0 -0
  269. /package/dist/{chunk-6LM6PPDX.js.map → chunk-CE3GMHT4.js.map} +0 -0
  270. /package/dist/{chunk-YXOAQAH2.js.map → chunk-CLFY35ED.js.map} +0 -0
  271. /package/dist/{chunk-HQ7QZJM5.js.map → chunk-D4TLWDYR.js.map} +0 -0
  272. /package/dist/{chunk-LQKITFGB.js.map → chunk-FUUF7FMX.js.map} +0 -0
  273. /package/dist/{chunk-MXRMNKSY.js.map → chunk-I3GKTDM2.js.map} +0 -0
  274. /package/dist/{chunk-EQF5763Z.js.map → chunk-IQULRIZM.js.map} +0 -0
  275. /package/dist/{chunk-W6R43NLA.js.map → chunk-KQMKF4G2.js.map} +0 -0
  276. /package/dist/{chunk-DJCKXI4M.js.map → chunk-NVXQTRBL.js.map} +0 -0
  277. /package/dist/{chunk-GHVJASQ4.js.map → chunk-OCY3CRPG.js.map} +0 -0
  278. /package/dist/{chunk-YLTG6BO4.js.map → chunk-OIFZII6J.js.map} +0 -0
  279. /package/dist/{chunk-XF4KOVDK.js.map → chunk-PA6DMYEK.js.map} +0 -0
  280. /package/dist/{chunk-3OQ4IZJN.js.map → chunk-QPZCAVDC.js.map} +0 -0
  281. /package/dist/{chunk-DLWYAN6A.js.map → chunk-T6U7HDIY.js.map} +0 -0
  282. /package/dist/{chunk-UTDA43TB.js.map → chunk-TWGGNUWJ.js.map} +0 -0
  283. /package/dist/{chunk-SBFDL2EQ.js.map → chunk-UTZW3X2R.js.map} +0 -0
  284. /package/dist/{chunk-SECMZACR.js.map → chunk-XUQUBZ7O.js.map} +0 -0
  285. /package/dist/{chunk-WUDBMLC6.js.map → chunk-ZC3HG22I.js.map} +0 -0
  286. /package/dist/{chunk-VCEH5UYS.js.map → chunk-ZRXSHGXT.js.map} +0 -0
@@ -2161,3 +2161,47 @@ test("parseConfig rejects non-positive extraction retry/breaker numerics and fal
2161
2161
  assert.equal(c.extractionBreakerAuthCooldownMs, 1_800_000, "non-positive auth cooldown -> default");
2162
2162
  assert.deepEqual(c.extractionRetryScheduleMs, [60_000, 300_000, 1_800_000, 7_200_000], "schedule with non-positive entry -> default");
2163
2163
  });
2164
+
2165
+ test("parseConfig namespace-catalog touch-path knobs: defaults, 0-accepting numerics, and validation (#1903)", () => {
2166
+ // Documented defaults when absent.
2167
+ const d = parseConfig({});
2168
+ assert.equal(d.namespacesCatalogCompactBytes, 16 * 1024 * 1024, "compactBytes default 16 MB");
2169
+ assert.equal(d.namespacesCatalogReadTouchCoalesceMs, 60_000, "read coalesce default 60000ms");
2170
+ assert.equal(d.namespacesCatalogWriteTouchCoalesceMs, 1_000, "write coalesce default 1000ms");
2171
+ assert.equal(d.namespacesCatalogTouchStateWrites, false, "state-write touch default false");
2172
+
2173
+ // 0 is an accepted disable switch for every numeric (resolveNonNegativeIntegerConfig).
2174
+ assert.equal(parseConfig({ namespacesCatalogCompactBytes: 0 }).namespacesCatalogCompactBytes, 0);
2175
+ assert.equal(parseConfig({ namespacesCatalogReadTouchCoalesceMs: 0 }).namespacesCatalogReadTouchCoalesceMs, 0);
2176
+ assert.equal(parseConfig({ namespacesCatalogWriteTouchCoalesceMs: 0 }).namespacesCatalogWriteTouchCoalesceMs, 0);
2177
+
2178
+ // Positive integers pass through; CLI/env numeric strings coerce.
2179
+ assert.equal(parseConfig({ namespacesCatalogCompactBytes: 4096 }).namespacesCatalogCompactBytes, 4096);
2180
+ assert.equal(parseConfig({ namespacesCatalogReadTouchCoalesceMs: "30000" }).namespacesCatalogReadTouchCoalesceMs, 30_000);
2181
+
2182
+ // Boolean-like coercion for the state-write toggle (gotcha #36).
2183
+ assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: true }).namespacesCatalogTouchStateWrites, true);
2184
+ assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: "true" }).namespacesCatalogTouchStateWrites, true);
2185
+ assert.equal(parseConfig({ namespacesCatalogTouchStateWrites: "off" }).namespacesCatalogTouchStateWrites, false);
2186
+
2187
+ // Negative and non-integer numerics are rejected with the documented message.
2188
+ for (const key of [
2189
+ "namespacesCatalogCompactBytes",
2190
+ "namespacesCatalogReadTouchCoalesceMs",
2191
+ "namespacesCatalogWriteTouchCoalesceMs",
2192
+ ]) {
2193
+ for (const bad of [-1, 1.5, "1.5", "abc", Number.NaN, Infinity]) {
2194
+ assert.throws(
2195
+ () => parseConfig({ [key]: bad }),
2196
+ new RegExp(`${key} must be a non-negative integer`),
2197
+ `${key}=${JSON.stringify(bad)} should throw`,
2198
+ );
2199
+ }
2200
+ }
2201
+
2202
+ // A present-but-malformed boolean toggle fails fast (gotcha #51).
2203
+ assert.throws(
2204
+ () => parseConfig({ namespacesCatalogTouchStateWrites: "maybe" }),
2205
+ /namespacesCatalogTouchStateWrites must be a boolean-like value/,
2206
+ );
2207
+ });
package/src/config.ts CHANGED
@@ -435,6 +435,26 @@ function resolvePositiveIntegerConfig(
435
435
  return coerced;
436
436
  }
437
437
 
438
+ function resolveNonNegativeIntegerConfig(
439
+ value: unknown,
440
+ defaultValue: number,
441
+ keyName: string,
442
+ ): number {
443
+ if (value === undefined || value === null) return defaultValue;
444
+ const coerced = coerceNumber(value);
445
+ if (
446
+ coerced === undefined ||
447
+ !Number.isFinite(coerced) ||
448
+ !Number.isInteger(coerced) ||
449
+ coerced < 0
450
+ ) {
451
+ throw new Error(
452
+ `${keyName} must be a non-negative integer; got ${JSON.stringify(value)}`,
453
+ );
454
+ }
455
+ return coerced;
456
+ }
457
+
438
458
  export function isOpenaiApiKeyDisabled(value: unknown): boolean {
439
459
  return value === false || (typeof value === "string" && value.trim().toLowerCase() === "false");
440
460
  }
@@ -2934,6 +2954,29 @@ export function parseConfig(
2934
2954
  // silently defaulting to enabled (CLAUDE.md rule #51); default to enabled
2935
2955
  // only when the value is absent.
2936
2956
  namespaceCatalogEnabled: resolveNamespaceCatalogEnabled(cfg.namespaceCatalogEnabled, rawOperatorConfig, runtimeSet),
2957
+ // Namespace catalog touch-path performance knobs (issue #1903). Numerics use
2958
+ // resolveNonNegativeIntegerConfig so 0 is an accepted disable switch; a
2959
+ // present-but-invalid value (negative/non-integer) is REJECTED (rule #51).
2960
+ namespacesCatalogCompactBytes: resolveNonNegativeIntegerConfig(
2961
+ cfg.namespacesCatalogCompactBytes,
2962
+ 16 * 1024 * 1024,
2963
+ "namespacesCatalogCompactBytes",
2964
+ ),
2965
+ namespacesCatalogReadTouchCoalesceMs: resolveNonNegativeIntegerConfig(
2966
+ cfg.namespacesCatalogReadTouchCoalesceMs,
2967
+ 60_000,
2968
+ "namespacesCatalogReadTouchCoalesceMs",
2969
+ ),
2970
+ namespacesCatalogWriteTouchCoalesceMs: resolveNonNegativeIntegerConfig(
2971
+ cfg.namespacesCatalogWriteTouchCoalesceMs,
2972
+ 1_000,
2973
+ "namespacesCatalogWriteTouchCoalesceMs",
2974
+ ),
2975
+ namespacesCatalogTouchStateWrites: resolveBooleanConfig(
2976
+ cfg.namespacesCatalogTouchStateWrites,
2977
+ false,
2978
+ "namespacesCatalogTouchStateWrites",
2979
+ ),
2937
2980
  // NOTE: namespace identifiers are intentionally NOT sanitized here — the
2938
2981
  // codebase rejects unsafe namespaces at the point of use (see
2939
2982
  // codex-materialize-runner and NamespaceStorageRouter / resolveNamespaceDir),
@@ -3694,3 +3694,261 @@ test("router resolve-hook serializer recovers after a prior hook rejects (issue
3694
3694
  await rm(memoryDir, { recursive: true, force: true });
3695
3695
  }
3696
3696
  });
3697
+
3698
+ // ── Issue #1903: touch-path performance (auto-compaction, coalescing, streaming) ──
3699
+
3700
+ async function countCatalogLines(memoryDir: string): Promise<number> {
3701
+ const raw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8").catch(() => "");
3702
+ return raw.split("\n").filter((line) => line.trim().length > 0).length;
3703
+ }
3704
+
3705
+ // 1. Warm cache: with coalescing OFF every touch appends, but a warm cache means
3706
+ // none of them re-parses the JSONL after the initial warm-up parse.
3707
+ test("#1903 warm cache: repeated touches never re-parse the log (coalescing off)", async () => {
3708
+ const memoryDir = await mkMemoryDir();
3709
+ try {
3710
+ const catalog = new CountingNamespaceCatalog(
3711
+ makeConfig(memoryDir, {
3712
+ namespacesCatalogReadTouchCoalesceMs: 0,
3713
+ namespacesCatalogWriteTouchCoalesceMs: 0,
3714
+ }),
3715
+ );
3716
+ // Warm the cache: the first write creates the file; a read then performs the
3717
+ // one-time full parse and warms the in-process cache.
3718
+ await catalog.markWrite("alpha", { discoveredBy: "write" });
3719
+ await catalog.listNamespaces();
3720
+ const warm = catalog.catalogReadCount;
3721
+ assert.ok(warm >= 1, "the warm-up performed at least one full parse");
3722
+ for (let i = 0; i < 25; i++) {
3723
+ await catalog.markWrite("alpha", { discoveredBy: "write" });
3724
+ await catalog.markRead("alpha", { discoveredBy: "read" });
3725
+ }
3726
+ assert.equal(
3727
+ catalog.catalogReadCount,
3728
+ warm,
3729
+ "every subsequent touch is served from the warm cache without a re-parse",
3730
+ );
3731
+ } finally {
3732
+ await rm(memoryDir, { recursive: true, force: true });
3733
+ }
3734
+ });
3735
+
3736
+ // 2. Write-touch coalescing collapses many in-window writes into a single append,
3737
+ // and the newest buffered timestamp wins on flush.
3738
+ test("#1903 write-touch coalescing collapses 50 in-window writes to one append", async () => {
3739
+ const memoryDir = await mkMemoryDir();
3740
+ try {
3741
+ const catalog = new NamespaceCatalog(
3742
+ makeConfig(memoryDir, { namespacesCatalogWriteTouchCoalesceMs: 60_000 }),
3743
+ );
3744
+ // Seed the record, then read it back so the in-process cache is warm — the
3745
+ // following writes are then pure-timestamp refreshes on a known record and
3746
+ // are deferred (a cold cache would force the first one to flush immediately).
3747
+ await catalog.markWrite("alpha", { discoveredBy: "write" });
3748
+ await catalog.getNamespaceRecord("alpha");
3749
+ const linesAfterSeed = await countCatalogLines(memoryDir);
3750
+ let newest = new Date();
3751
+ for (let i = 0; i < 50; i++) {
3752
+ newest = new Date(Date.now() + (i + 1) * 1000);
3753
+ await catalog.markWrite("alpha", { discoveredBy: "write", at: newest });
3754
+ }
3755
+ assert.equal(
3756
+ await countCatalogLines(memoryDir),
3757
+ linesAfterSeed,
3758
+ "coalesced writes stay buffered — no per-touch append",
3759
+ );
3760
+ await catalog.flushPendingTouches();
3761
+ assert.equal(
3762
+ await countCatalogLines(memoryDir),
3763
+ linesAfterSeed + 1,
3764
+ "the 50 buffered writes collapse into exactly one append on flush",
3765
+ );
3766
+ const record = await catalog.getNamespaceRecord("alpha");
3767
+ assert.equal(
3768
+ record?.lastWriteAt,
3769
+ newest.toISOString(),
3770
+ "the newest in-window write timestamp wins",
3771
+ );
3772
+ } finally {
3773
+ await rm(memoryDir, { recursive: true, force: true });
3774
+ }
3775
+ });
3776
+
3777
+ // 3. Read-touch coalescing collapses in-window reads and keeps the latest freshness.
3778
+ test("#1903 read-touch coalescing keeps the latest in-window lastReadAt", async () => {
3779
+ const memoryDir = await mkMemoryDir();
3780
+ try {
3781
+ const catalog = new NamespaceCatalog(
3782
+ makeConfig(memoryDir, { namespacesCatalogReadTouchCoalesceMs: 60_000 }),
3783
+ );
3784
+ // Seed the read field, then warm the cache so the following reads defer.
3785
+ await catalog.markRead("alpha", { discoveredBy: "read" });
3786
+ await catalog.getNamespaceRecord("alpha");
3787
+ const linesAfterSeed = await countCatalogLines(memoryDir);
3788
+ let newest = new Date();
3789
+ for (let i = 0; i < 30; i++) {
3790
+ newest = new Date(Date.now() + (i + 1) * 1000);
3791
+ await catalog.markRead("alpha", { discoveredBy: "read", at: newest });
3792
+ }
3793
+ assert.equal(
3794
+ await countCatalogLines(memoryDir),
3795
+ linesAfterSeed,
3796
+ "coalesced reads stay buffered — no per-touch append",
3797
+ );
3798
+ await catalog.flushPendingTouches();
3799
+ assert.equal(await countCatalogLines(memoryDir), linesAfterSeed + 1);
3800
+ const record = await catalog.getNamespaceRecord("alpha");
3801
+ assert.equal(
3802
+ record?.lastReadAt,
3803
+ newest.toISOString(),
3804
+ "the newest in-window read timestamp wins",
3805
+ );
3806
+ } finally {
3807
+ await rm(memoryDir, { recursive: true, force: true });
3808
+ }
3809
+ });
3810
+
3811
+ // 4. Provenance upgrade under coalescing: a config pre-registration followed by a
3812
+ // write must read back "write" WITHOUT an explicit flush (immediate-flush rule).
3813
+ test("#1903 markWrite upgrades config→write under coalescing without an explicit flush", async () => {
3814
+ const memoryDir = await mkMemoryDir();
3815
+ try {
3816
+ const catalog = new NamespaceCatalog(
3817
+ makeConfig(memoryDir, {
3818
+ namespacesCatalogReadTouchCoalesceMs: 60_000,
3819
+ namespacesCatalogWriteTouchCoalesceMs: 60_000,
3820
+ }),
3821
+ );
3822
+ const ns = "project-origin-prov";
3823
+ const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
3824
+ await catalog.registerResolved(ns, storageDir);
3825
+ assert.equal(
3826
+ (await catalog.getNamespaceRecord(ns))?.discoveredBy,
3827
+ "config",
3828
+ "pre-registration is discovered by config",
3829
+ );
3830
+ // A real write must upgrade the provenance immediately even with coalescing on.
3831
+ await catalog.markWrite(ns, { discoveredBy: "write", storageDir });
3832
+ assert.equal(
3833
+ (await catalog.getNamespaceRecord(ns))?.discoveredBy,
3834
+ "write",
3835
+ "the config→write upgrade flushes immediately (not coalesced away)",
3836
+ );
3837
+ } finally {
3838
+ await rm(memoryDir, { recursive: true, force: true });
3839
+ }
3840
+ });
3841
+
3842
+ // 5. Zero-limit semantics: CompactBytes=0 never rewrites and CoalesceMs=0 appends
3843
+ // one line per touch (line count == touch count) — the pre-#1903 behavior.
3844
+ test("#1903 zero knobs restore append-per-touch with no compaction", async () => {
3845
+ const memoryDir = await mkMemoryDir();
3846
+ try {
3847
+ const catalog = new NamespaceCatalog(
3848
+ makeConfig(memoryDir, {
3849
+ namespacesCatalogCompactBytes: 0,
3850
+ namespacesCatalogReadTouchCoalesceMs: 0,
3851
+ namespacesCatalogWriteTouchCoalesceMs: 0,
3852
+ }),
3853
+ );
3854
+ const touches = 12;
3855
+ for (let i = 0; i < touches; i++) {
3856
+ await catalog.markWrite("alpha", { discoveredBy: "write" });
3857
+ }
3858
+ assert.equal(
3859
+ await countCatalogLines(memoryDir),
3860
+ touches,
3861
+ "no coalescing and no compaction → exactly one append per touch",
3862
+ );
3863
+ } finally {
3864
+ await rm(memoryDir, { recursive: true, force: true });
3865
+ }
3866
+ });
3867
+
3868
+ // 6. Auto-compaction bounds the log to one row per namespace and preserves every
3869
+ // namespace with its newest fields. A tiny limit forces compaction on each touch.
3870
+ test("#1903 auto-compaction bounds the log to one row per namespace and preserves all", async () => {
3871
+ const memoryDir = await mkMemoryDir();
3872
+ try {
3873
+ const catalog = new NamespaceCatalog(
3874
+ makeConfig(memoryDir, {
3875
+ namespacesCatalogCompactBytes: 100,
3876
+ namespacesCatalogReadTouchCoalesceMs: 0,
3877
+ namespacesCatalogWriteTouchCoalesceMs: 0,
3878
+ }),
3879
+ );
3880
+ const namespaces = [
3881
+ "project-origin-a",
3882
+ "project-origin-b",
3883
+ "project-origin-c",
3884
+ "project-origin-d",
3885
+ "project-origin-e",
3886
+ ];
3887
+ const newest = new Map<string, string>();
3888
+ for (let round = 0; round < 20; round++) {
3889
+ for (const ns of namespaces) {
3890
+ const at = new Date(Date.now() + (round * namespaces.length + namespaces.indexOf(ns)) * 1000);
3891
+ newest.set(ns, at.toISOString());
3892
+ await catalog.markWrite(ns, { discoveredBy: "write", at });
3893
+ }
3894
+ }
3895
+ assert.equal(
3896
+ await countCatalogLines(memoryDir),
3897
+ namespaces.length,
3898
+ "the log is bounded to exactly one row per namespace after compaction",
3899
+ );
3900
+ const list = await catalog.listNamespaces();
3901
+ for (const ns of namespaces) {
3902
+ const record = list.find((r) => r.namespace === ns);
3903
+ assert.ok(record, `namespace ${ns} survives compaction`);
3904
+ assert.equal(
3905
+ record?.lastWriteAt,
3906
+ newest.get(ns),
3907
+ `namespace ${ns} keeps its newest write timestamp through compaction`,
3908
+ );
3909
+ }
3910
+ } finally {
3911
+ await rm(memoryDir, { recursive: true, force: true });
3912
+ }
3913
+ });
3914
+
3915
+ // 7. Cross-process cache-coherence: compaction on one instance must never drop
3916
+ // another instance's rows (mirrors the rebuild-race tests). A third fresh
3917
+ // instance must read back every namespace touched by either.
3918
+ test("#1903 cross-instance compaction never drops another instance's rows", async () => {
3919
+ const memoryDir = await mkMemoryDir();
3920
+ try {
3921
+ const cfg = () =>
3922
+ makeConfig(memoryDir, {
3923
+ namespacesCatalogCompactBytes: 100,
3924
+ namespacesCatalogReadTouchCoalesceMs: 0,
3925
+ namespacesCatalogWriteTouchCoalesceMs: 0,
3926
+ });
3927
+ // Separate instances behave like separate processes (own lock owner id).
3928
+ const a = new NamespaceCatalog(cfg());
3929
+ const b = new NamespaceCatalog(cfg());
3930
+ const written = new Set<string>();
3931
+ for (let round = 0; round < 8; round++) {
3932
+ const nsA = `project-origin-a${round}`;
3933
+ const nsB = `project-origin-b${round}`;
3934
+ written.add(nsA);
3935
+ written.add(nsB);
3936
+ // Interleave: A appends a fresh namespace while B triggers a compaction of
3937
+ // the shared log. The cross-process lock serializes the two.
3938
+ await Promise.all([
3939
+ a.markWrite(nsA, { discoveredBy: "write" }),
3940
+ b.markWrite(nsB, { discoveredBy: "write" }),
3941
+ ]);
3942
+ }
3943
+ const fresh = new NamespaceCatalog(makeConfig(memoryDir));
3944
+ const list = await fresh.listNamespaces();
3945
+ for (const ns of written) {
3946
+ assert.ok(
3947
+ list.some((r) => r.namespace === ns),
3948
+ `namespace ${ns} must survive cross-instance interleaved compaction`,
3949
+ );
3950
+ }
3951
+ } finally {
3952
+ await rm(memoryDir, { recursive: true, force: true });
3953
+ }
3954
+ });
@@ -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();