@remnic/core 9.48.1 → 9.49.0

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 (240) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -5
  2. package/dist/access-admin-ops-surface.js +27 -26
  3. package/dist/access-audit.js +2 -2
  4. package/dist/access-authorization-probe.d.ts +6 -5
  5. package/dist/access-authorization-probe.js +28 -27
  6. package/dist/access-boundary.d.ts +6 -5
  7. package/dist/access-boundary.js +27 -26
  8. package/dist/access-cli.js +64 -63
  9. package/dist/access-cli.js.map +1 -1
  10. package/dist/access-extraction-force-flush.d.ts +6 -5
  11. package/dist/access-extraction-force-flush.js +27 -26
  12. package/dist/access-http-lcm-compaction.d.ts +6 -5
  13. package/dist/access-http-lifecycle-flush.d.ts +6 -5
  14. package/dist/access-http-offline-stream.d.ts +6 -5
  15. package/dist/access-http-query.js +27 -26
  16. package/dist/access-http.d.ts +6 -5
  17. package/dist/access-http.js +35 -34
  18. package/dist/access-identity-continuity-surface.d.ts +5 -4
  19. package/dist/access-identity-continuity-surface.js +27 -26
  20. package/dist/access-lcm-surface.d.ts +6 -5
  21. package/dist/access-lcm-surface.js +27 -26
  22. package/dist/access-mcp.d.ts +6 -5
  23. package/dist/access-mcp.js +33 -32
  24. package/dist/access-namespace-preflight.js +27 -26
  25. package/dist/access-observe-write-surface.d.ts +6 -5
  26. package/dist/access-observe-write-surface.js +27 -26
  27. package/dist/access-offline-manifest.d.ts +5 -4
  28. package/dist/access-operations-batch.js +29 -28
  29. package/dist/access-operations.d.ts +6 -5
  30. package/dist/access-operations.js +32 -31
  31. package/dist/access-recall-concurrency.d.ts +6 -5
  32. package/dist/access-recall-concurrency.js +27 -26
  33. package/dist/access-recall-response.d.ts +6 -5
  34. package/dist/access-recall-response.js +27 -26
  35. package/dist/access-recall-surface.d.ts +6 -5
  36. package/dist/access-recall-surface.js +27 -26
  37. package/dist/access-schema.js +3 -3
  38. package/dist/{access-service-andNjwfD.d.ts → access-service-DvXOzCrM.d.ts} +3 -3
  39. package/dist/access-service-helpers.d.ts +6 -5
  40. package/dist/access-service.d.ts +6 -5
  41. package/dist/access-service.js +27 -26
  42. package/dist/access-surface-catalog.d.ts +6 -5
  43. package/dist/access-wearables-meetings-surface.d.ts +3 -2
  44. package/dist/bootstrap.d.ts +5 -4
  45. package/dist/briefing.d.ts +2 -1
  46. package/dist/briefing.js +7 -6
  47. package/dist/buffer.d.ts +2 -1
  48. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  49. package/dist/{capsule-merge-Q6AR3XL7.js → capsule-merge-6GFHWECG.js} +4 -4
  50. package/dist/causal-consolidation.js +8 -7
  51. package/dist/causal-consolidation.js.map +1 -1
  52. package/dist/{chunk-FMCHFSZF.js → chunk-2RIWJUSQ.js} +6 -6
  53. package/dist/{chunk-S55OAS5P.js → chunk-2VHLLPTX.js} +4 -4
  54. package/dist/{chunk-GWHPQOUC.js → chunk-3R6D33UF.js} +2 -2
  55. package/dist/{chunk-PELT6ADC.js → chunk-5AGR7SAI.js} +3 -3
  56. package/dist/chunk-5GINB5LC.js +13 -0
  57. package/dist/chunk-5GINB5LC.js.map +1 -0
  58. package/dist/{chunk-FJ2ND6CZ.js → chunk-5JRC6BYS.js} +10 -10
  59. package/dist/{chunk-A2TFK5PG.js → chunk-ABR2HEJO.js} +2 -2
  60. package/dist/{chunk-NXUJRXSF.js → chunk-B6KGGRUL.js} +2 -2
  61. package/dist/{chunk-PRVJH7CY.js → chunk-BMCHFWGX.js} +2 -2
  62. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  63. package/dist/{chunk-ANCC3JJH.js → chunk-C3KVFJH5.js} +2 -2
  64. package/dist/{chunk-JYPO7L3G.js → chunk-CNQQMB67.js} +2 -2
  65. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  66. package/dist/{chunk-2EJNXE53.js → chunk-ENEA47UC.js} +2 -2
  67. package/dist/{chunk-PLCZD3LG.js → chunk-EY64JLOM.js} +66 -31
  68. package/dist/chunk-EY64JLOM.js.map +1 -0
  69. package/dist/{chunk-4WBGZYZZ.js → chunk-EYAIZBLM.js} +4 -4
  70. package/dist/chunk-EYAIZBLM.js.map +1 -0
  71. package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
  72. package/dist/{chunk-HAYSYUAB.js → chunk-FMXJ555A.js} +2 -2
  73. package/dist/{chunk-VANZKHTN.js → chunk-G5OB4XZX.js} +4 -4
  74. package/dist/{chunk-DUEEQCTT.js → chunk-I3YHVVT4.js} +2 -2
  75. package/dist/{chunk-3JUYWPFJ.js → chunk-I4TQST3L.js} +2 -2
  76. package/dist/{chunk-HIDEBEAN.js → chunk-IPBNC77A.js} +8 -8
  77. package/dist/{chunk-45OTV7FZ.js → chunk-IXPKESVP.js} +11 -11
  78. package/dist/{chunk-KSGEGHYA.js → chunk-IZANPXMV.js} +34 -34
  79. package/dist/{chunk-IP3DHDU2.js → chunk-JFLOOTXU.js} +3 -3
  80. package/dist/{chunk-XL3TATMJ.js → chunk-JHMAYABJ.js} +4 -4
  81. package/dist/{chunk-HRMEYQRO.js → chunk-JXZ7NLYH.js} +2 -2
  82. package/dist/{chunk-53AJILXF.js → chunk-K7VKAHFE.js} +4 -4
  83. package/dist/{chunk-IJ7HK452.js → chunk-KMAP7SNQ.js} +2 -2
  84. package/dist/{chunk-D5NGH2EW.js → chunk-L6DCVUCA.js} +2 -2
  85. package/dist/{chunk-M4DMO67I.js → chunk-LQL7ARIC.js} +3 -3
  86. package/dist/{chunk-3NDN5P55.js → chunk-MXPB6PJQ.js} +2 -2
  87. package/dist/{chunk-RSPSHRZA.js → chunk-MZZR3J6M.js} +2 -2
  88. package/dist/{chunk-YLHM5BQQ.js → chunk-OLX2HJFW.js} +4 -4
  89. package/dist/{chunk-NIS256NW.js → chunk-QKD65RGL.js} +2 -2
  90. package/dist/{chunk-A4JMGKCD.js → chunk-R5IGA2NH.js} +4 -4
  91. package/dist/chunk-RBOJCG4T.js +77 -0
  92. package/dist/chunk-RBOJCG4T.js.map +1 -0
  93. package/dist/{chunk-7YKVM5H3.js → chunk-RHTO4HTL.js} +2 -2
  94. package/dist/{chunk-GD7UPIBG.js → chunk-RJV6Q2FT.js} +2 -2
  95. package/dist/{chunk-5L7PTX2Q.js → chunk-SHDZNSK4.js} +2 -2
  96. package/dist/{chunk-WOEVJKKQ.js → chunk-SMPVHGHG.js} +3 -3
  97. package/dist/{chunk-7K5Q6COX.js → chunk-TVVEYCNW.js} +4 -4
  98. package/dist/{chunk-52I3YZZ6.js → chunk-ULWHRQEQ.js} +2 -2
  99. package/dist/{chunk-KQAFEZQX.js → chunk-VDX2J7OX.js} +2 -2
  100. package/dist/{chunk-2P3GQVM2.js → chunk-YQIVWS7Y.js} +50 -41
  101. package/dist/chunk-YQIVWS7Y.js.map +1 -0
  102. package/dist/{chunk-G2RBWVFQ.js → chunk-Z4U6U3M3.js} +2 -2
  103. package/dist/{chunk-EZNXW27Q.js → chunk-Z5UC3OKB.js} +6 -6
  104. package/dist/{cli-p_WmVo2h.d.ts → cli-PyAUaozE.d.ts} +3 -3
  105. package/dist/cli.d.ts +7 -6
  106. package/dist/cli.js +52 -51
  107. package/dist/compounding/engine.d.ts +2 -1
  108. package/dist/compounding/engine.js +7 -6
  109. package/dist/connectors/codex-materialize-runner.js +7 -6
  110. package/dist/connectors/index.js +7 -6
  111. package/dist/consolidation-provenance-check.d.ts +2 -1
  112. package/dist/consolidation-provenance-check.js +4 -4
  113. package/dist/consolidation-undo.d.ts +2 -1
  114. package/dist/consolidation-undo.js +3 -3
  115. package/dist/contradiction/index.d.ts +2 -1
  116. package/dist/convergence-refresh.d.ts +2 -1
  117. package/dist/convergence-refresh.js +9 -8
  118. package/dist/corpus-read-cancellation.d.ts +38 -0
  119. package/dist/corpus-read-cancellation.js +9 -0
  120. package/dist/corpus-read-cancellation.js.map +1 -0
  121. package/dist/corpus-watermark.js +16 -15
  122. package/dist/dashboard-runtime.js +2 -2
  123. package/dist/entity-retrieval.d.ts +2 -1
  124. package/dist/entity-retrieval.js +7 -6
  125. package/dist/explicit-capture.d.ts +5 -4
  126. package/dist/external-wiki-access.d.ts +6 -5
  127. package/dist/external-wiki-access.js +28 -27
  128. package/dist/external-wiki-mcp-tools.d.ts +6 -5
  129. package/dist/extraction.js +8 -7
  130. package/dist/in-flight-reads.d.ts +46 -2
  131. package/dist/in-flight-reads.js +5 -3
  132. package/dist/index.d.ts +11 -10
  133. package/dist/index.js +68 -67
  134. package/dist/lcm/index.js +3 -3
  135. package/dist/maintenance/memory-governance.js +7 -6
  136. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +2 -1
  137. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -6
  138. package/dist/maintenance/rebuild-memory-projection.d.ts +2 -1
  139. package/dist/maintenance/rebuild-memory-projection.js +8 -7
  140. package/dist/{maintenance-BfcuwfJS.d.ts → maintenance-j7sfNQdK.d.ts} +1 -1
  141. package/dist/mcp-memory-inspector-app.d.ts +6 -5
  142. package/dist/memory-corpus-version.js +2 -2
  143. package/dist/memory-worth-outcomes.d.ts +2 -1
  144. package/dist/namespaces/migrate.d.ts +2 -1
  145. package/dist/namespaces/migrate.js +15 -14
  146. package/dist/namespaces/search.js +7 -7
  147. package/dist/namespaces/storage.d.ts +2 -1
  148. package/dist/namespaces/storage.js +7 -6
  149. package/dist/operator-doctor-corpus.js +17 -16
  150. package/dist/operator-toolkit.d.ts +2 -1
  151. package/dist/operator-toolkit.js +22 -21
  152. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -1
  153. package/dist/orchestration/maintenance.d.ts +3 -2
  154. package/dist/orchestration/maintenance.js +12 -11
  155. package/dist/{orchestrator-B7nLo4z1.d.ts → orchestrator-Haa9xVPV.d.ts} +3 -3
  156. package/dist/orchestrator.d.ts +5 -4
  157. package/dist/orchestrator.js +64 -63
  158. package/dist/page-versioning.js +3 -3
  159. package/dist/search/factory.js +6 -6
  160. package/dist/search/index.js +11 -11
  161. package/dist/search/lancedb-backend.js +2 -2
  162. package/dist/search/meilisearch-backend.js +2 -2
  163. package/dist/search/orama-backend.js +2 -2
  164. package/dist/semantic-consolidation.js +8 -7
  165. package/dist/semantic-rule-promotion.js +7 -6
  166. package/dist/semantic-rule-verifier.js +7 -6
  167. package/dist/{service-B5W5IYCx.d.ts → service-Da_BI4S8.d.ts} +1 -1
  168. package/dist/source-agent-qualifier.js +7 -6
  169. package/dist/{storage-COL907QF.d.ts → storage-D1apCiCS.d.ts} +4 -3
  170. package/dist/storage.d.ts +2 -1
  171. package/dist/storage.js +6 -5
  172. package/dist/temporal-supersession.d.ts +2 -1
  173. package/dist/tier-migration.d.ts +2 -1
  174. package/dist/transfer/backup.js +2 -2
  175. package/dist/transfer/capsule-export.js +2 -2
  176. package/dist/transfer/capsule-import.js +5 -5
  177. package/dist/verified-recall.js +7 -6
  178. package/package.json +2 -2
  179. package/src/corpus-read-cancellation.ts +42 -0
  180. package/src/entity-retrieval.ts +4 -4
  181. package/src/in-flight-reads.ts +119 -6
  182. package/src/orchestration/namespace-read-fanout.ts +13 -2
  183. package/src/orchestration/recall-internal-deps.ts +2 -0
  184. package/src/orchestration/recall-internal.ts +4 -1
  185. package/src/orchestration/recall-search-pipeline.ts +2 -0
  186. package/src/orchestration/recall-search-prefilter.ts +5 -1
  187. package/src/orchestrator.ts +5 -5
  188. package/src/storage/entity-store.ts +9 -2
  189. package/src/storage/memory-read-store.ts +33 -4
  190. package/src/storage-contract/corpus-read-cancellation.test.ts +408 -0
  191. package/src/storage.ts +61 -19
  192. package/dist/chunk-2P3GQVM2.js.map +0 -1
  193. package/dist/chunk-4WBGZYZZ.js.map +0 -1
  194. package/dist/chunk-PDJTHRAW.js +0 -36
  195. package/dist/chunk-PDJTHRAW.js.map +0 -1
  196. package/dist/chunk-PLCZD3LG.js.map +0 -1
  197. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  198. /package/dist/{capsule-merge-Q6AR3XL7.js.map → capsule-merge-6GFHWECG.js.map} +0 -0
  199. /package/dist/{chunk-FMCHFSZF.js.map → chunk-2RIWJUSQ.js.map} +0 -0
  200. /package/dist/{chunk-S55OAS5P.js.map → chunk-2VHLLPTX.js.map} +0 -0
  201. /package/dist/{chunk-GWHPQOUC.js.map → chunk-3R6D33UF.js.map} +0 -0
  202. /package/dist/{chunk-PELT6ADC.js.map → chunk-5AGR7SAI.js.map} +0 -0
  203. /package/dist/{chunk-FJ2ND6CZ.js.map → chunk-5JRC6BYS.js.map} +0 -0
  204. /package/dist/{chunk-A2TFK5PG.js.map → chunk-ABR2HEJO.js.map} +0 -0
  205. /package/dist/{chunk-NXUJRXSF.js.map → chunk-B6KGGRUL.js.map} +0 -0
  206. /package/dist/{chunk-PRVJH7CY.js.map → chunk-BMCHFWGX.js.map} +0 -0
  207. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  208. /package/dist/{chunk-ANCC3JJH.js.map → chunk-C3KVFJH5.js.map} +0 -0
  209. /package/dist/{chunk-JYPO7L3G.js.map → chunk-CNQQMB67.js.map} +0 -0
  210. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  211. /package/dist/{chunk-2EJNXE53.js.map → chunk-ENEA47UC.js.map} +0 -0
  212. /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
  213. /package/dist/{chunk-HAYSYUAB.js.map → chunk-FMXJ555A.js.map} +0 -0
  214. /package/dist/{chunk-VANZKHTN.js.map → chunk-G5OB4XZX.js.map} +0 -0
  215. /package/dist/{chunk-DUEEQCTT.js.map → chunk-I3YHVVT4.js.map} +0 -0
  216. /package/dist/{chunk-3JUYWPFJ.js.map → chunk-I4TQST3L.js.map} +0 -0
  217. /package/dist/{chunk-HIDEBEAN.js.map → chunk-IPBNC77A.js.map} +0 -0
  218. /package/dist/{chunk-45OTV7FZ.js.map → chunk-IXPKESVP.js.map} +0 -0
  219. /package/dist/{chunk-KSGEGHYA.js.map → chunk-IZANPXMV.js.map} +0 -0
  220. /package/dist/{chunk-IP3DHDU2.js.map → chunk-JFLOOTXU.js.map} +0 -0
  221. /package/dist/{chunk-XL3TATMJ.js.map → chunk-JHMAYABJ.js.map} +0 -0
  222. /package/dist/{chunk-HRMEYQRO.js.map → chunk-JXZ7NLYH.js.map} +0 -0
  223. /package/dist/{chunk-53AJILXF.js.map → chunk-K7VKAHFE.js.map} +0 -0
  224. /package/dist/{chunk-IJ7HK452.js.map → chunk-KMAP7SNQ.js.map} +0 -0
  225. /package/dist/{chunk-D5NGH2EW.js.map → chunk-L6DCVUCA.js.map} +0 -0
  226. /package/dist/{chunk-M4DMO67I.js.map → chunk-LQL7ARIC.js.map} +0 -0
  227. /package/dist/{chunk-3NDN5P55.js.map → chunk-MXPB6PJQ.js.map} +0 -0
  228. /package/dist/{chunk-RSPSHRZA.js.map → chunk-MZZR3J6M.js.map} +0 -0
  229. /package/dist/{chunk-YLHM5BQQ.js.map → chunk-OLX2HJFW.js.map} +0 -0
  230. /package/dist/{chunk-NIS256NW.js.map → chunk-QKD65RGL.js.map} +0 -0
  231. /package/dist/{chunk-A4JMGKCD.js.map → chunk-R5IGA2NH.js.map} +0 -0
  232. /package/dist/{chunk-7YKVM5H3.js.map → chunk-RHTO4HTL.js.map} +0 -0
  233. /package/dist/{chunk-GD7UPIBG.js.map → chunk-RJV6Q2FT.js.map} +0 -0
  234. /package/dist/{chunk-5L7PTX2Q.js.map → chunk-SHDZNSK4.js.map} +0 -0
  235. /package/dist/{chunk-WOEVJKKQ.js.map → chunk-SMPVHGHG.js.map} +0 -0
  236. /package/dist/{chunk-7K5Q6COX.js.map → chunk-TVVEYCNW.js.map} +0 -0
  237. /package/dist/{chunk-52I3YZZ6.js.map → chunk-ULWHRQEQ.js.map} +0 -0
  238. /package/dist/{chunk-KQAFEZQX.js.map → chunk-VDX2J7OX.js.map} +0 -0
  239. /package/dist/{chunk-G2RBWVFQ.js.map → chunk-Z4U6U3M3.js.map} +0 -0
  240. /package/dist/{chunk-EZNXW27Q.js.map → chunk-Z5UC3OKB.js.map} +0 -0
@@ -18,6 +18,8 @@ import { type BufferSurpriseEvent, type CompressionGuidelineOptimizerState, type
18
18
  import { RECALL_FALLBACK_DIRS } from "../utils/category-dir.js";
19
19
  import { isErrnoCode } from "../utils/errno.js";
20
20
  import { assertPathInsideRoot } from "../utils/path-containment.js";
21
+ import { isAbortError } from "../abort-error.js";
22
+ import { checkCorpusReadAbort, type CorpusReadOptions } from "../corpus-read-cancellation.js";
21
23
  import { isValidTranscriptDate } from "../wearables/day-store.js";
22
24
  import {
23
25
  isValidBufferSurpriseEvent,
@@ -147,7 +149,8 @@ export class MemoryReadStore {
147
149
  return days;
148
150
  }
149
151
 
150
- async readAllArtifactsCached(): Promise<MemoryFile[]> {
152
+ async readAllArtifactsCached(options?: CorpusReadOptions): Promise<MemoryFile[]> {
153
+ checkCorpusReadAbort(options);
151
154
  if (
152
155
  this.deps.artifactIndexCache &&
153
156
  Date.now() - this.deps.artifactIndexCache.loadedAtMs <= this.deps.storageManagerClass.ARTIFACT_INDEX_CACHE_TTL_MS &&
@@ -162,6 +165,10 @@ export class MemoryReadStore {
162
165
  try {
163
166
  const entries = await readdir(dir, { withFileTypes: true });
164
167
  for (const entry of entries) {
168
+ // Per-entry, so a tree of any shape has a checkpoint: this scan has no
169
+ // in-flight dedup, so every abandoned recall used to start another
170
+ // full walk of it (issue #2307).
171
+ checkCorpusReadAbort(options);
165
172
  const fullPath = path.join(dir, entry.name);
166
173
  if (entry.isDirectory()) {
167
174
  await readDir(fullPath);
@@ -172,7 +179,10 @@ export class MemoryReadStore {
172
179
  if (!memory) continue;
173
180
  artifacts.push(memory);
174
181
  }
175
- } catch {
182
+ } catch (err) {
183
+ // A cancelled scan must surface, not be swallowed as "directory doesn't
184
+ // exist yet" — otherwise a partial walk would be cached as complete.
185
+ if (isAbortError(err)) throw err;
176
186
  // Directory doesn't exist yet
177
187
  }
178
188
  };
@@ -183,10 +193,14 @@ export class MemoryReadStore {
183
193
  const MAX_REBUILD_RETRIES = 2;
184
194
  let latestArtifacts: MemoryFile[] = [];
185
195
  for (let attempt = 0; attempt <= MAX_REBUILD_RETRIES; attempt += 1) {
196
+ checkCorpusReadAbort(options);
186
197
  const versionBefore = this.deps.getArtifactWriteVersion();
187
198
  const artifacts = await scanArtifacts();
188
199
  const versionAfter = this.deps.getArtifactWriteVersion();
189
200
  latestArtifacts = artifacts;
201
+ // A signal that fired during the last file read of the walk would otherwise
202
+ // publish this scan and return it as if uncancelled (issue #2307 review).
203
+ checkCorpusReadAbort(options);
190
204
  if (versionAfter === versionBefore) {
191
205
  this.deps.artifactIndexCache = { memories: artifacts, loadedAtMs: Date.now(), writeVersion: versionAfter };
192
206
  return artifacts;
@@ -210,6 +224,7 @@ export class MemoryReadStore {
210
224
  private async collectContainedMarkdownPaths(
211
225
  startDirs: readonly string[],
212
226
  propagateReadErrors = false,
227
+ options?: CorpusReadOptions,
213
228
  ): Promise<string[]> {
214
229
  const filePaths: string[] = [];
215
230
 
@@ -226,6 +241,7 @@ export class MemoryReadStore {
226
241
  }
227
242
 
228
243
  const collectPaths = async (dir: string): Promise<void> => {
244
+ checkCorpusReadAbort(options);
229
245
  // Directory-level guard, isolated from per-entry handling: skip symlinked
230
246
  // or non-directory category dirs and assert the resolved dir stays inside
231
247
  // the memory root before reading. A failure here means the whole subtree
@@ -264,6 +280,10 @@ export class MemoryReadStore {
264
280
 
265
281
  const subdirs: string[] = [];
266
282
  for (const entry of entries) {
283
+ // Outside the per-entry try/catch below, which deliberately swallows
284
+ // non-propagatable errors — a cancellation must never be swallowed with
285
+ // them (issue #2307).
286
+ checkCorpusReadAbort(options);
267
287
  // Never follow symlinked entries out of the store.
268
288
  if (entry.isSymbolicLink()) continue;
269
289
  const fullPath = path.join(dir, entry.name);
@@ -297,10 +317,15 @@ export class MemoryReadStore {
297
317
  for (const dir of startDirs) {
298
318
  await collectPaths(dir);
299
319
  }
320
+ // A signal that fired during the last directory read would otherwise return a
321
+ // truncated path list as if it were the whole corpus (issue #2307 review).
322
+ checkCorpusReadAbort(options);
300
323
  return filePaths;
301
324
  }
302
325
 
303
- async collectActiveMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
326
+ async collectActiveMemoryPaths(
327
+ options?: { propagateReadErrors?: boolean } & CorpusReadOptions,
328
+ ): Promise<string[]> {
304
329
  // Scan EVERY supported memory category directory, not just the legacy four
305
330
  // (facts/procedures/reasoning-traces/corrections). Issue #1497: the QMD
306
331
  // filesystem-fallback recall path (orchestrator `recent_scan` ->
@@ -330,6 +355,7 @@ export class MemoryReadStore {
330
355
  return this.collectContainedMarkdownPaths(
331
356
  RECALL_FALLBACK_DIRS.map((dir) => path.join(this.deps.baseDir, dir)),
332
357
  options?.propagateReadErrors ?? false,
358
+ options,
333
359
  );
334
360
  }
335
361
 
@@ -342,10 +368,13 @@ export class MemoryReadStore {
342
368
  * of the recall fallback corpus (collectActiveMemoryPaths), whose scan is
343
369
  * intentionally unchanged.
344
370
  */
345
- async collectColdMemoryPaths(options?: { propagateReadErrors?: boolean }): Promise<string[]> {
371
+ async collectColdMemoryPaths(
372
+ options?: { propagateReadErrors?: boolean } & CorpusReadOptions,
373
+ ): Promise<string[]> {
346
374
  return this.collectContainedMarkdownPaths(
347
375
  [this.deps.resolveTierRootDir("cold")],
348
376
  options?.propagateReadErrors ?? false,
377
+ options,
349
378
  );
350
379
  }
351
380
 
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Issue #2307 — the shared full-corpus read primitives honour cancellation.
3
+ *
4
+ * Scenario matrix, applied to every primitive that walks a whole memory tree:
5
+ * 1. no signal → unchanged behaviour
6
+ * 2. signal that never fires → unchanged behaviour
7
+ * 3. signal already aborted → throws, nothing read
8
+ * 4. signal fires mid-scan → throws, and NO partial corpus is cached
9
+ * 5. coalesced read with a joiner → the starter's abort must not cancel it
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import test from "node:test";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ import { StorageManager } from "../storage.js";
19
+ import { resetStaticCaches } from "./harness.js";
20
+
21
+ const isAbort = (err: unknown) => err instanceof Error && err.name === "AbortError";
22
+
23
+ async function withStorage(
24
+ prefix: string,
25
+ body: (sm: StorageManager, dir: string) => Promise<void>,
26
+ ): Promise<void> {
27
+ const dir = await mkdtemp(path.join(os.tmpdir(), prefix));
28
+ try {
29
+ resetStaticCaches();
30
+ const sm = new StorageManager(dir);
31
+ await sm.ensureDirectories();
32
+ await body(sm, dir);
33
+ } finally {
34
+ resetStaticCaches();
35
+ await rm(dir, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ /** Counts disk scans so "nothing was cached" is observable rather than assumed. */
40
+ function installScanSpy(sm: StorageManager): { scans: () => number } {
41
+ const spy = sm as unknown as { _readAllMemoriesFromDisk: (...args: unknown[]) => Promise<unknown> };
42
+ const orig = spy._readAllMemoriesFromDisk.bind(sm);
43
+ let count = 0;
44
+ spy._readAllMemoriesFromDisk = async (...args: unknown[]) => {
45
+ count += 1;
46
+ return orig(...args);
47
+ };
48
+ return { scans: () => count };
49
+ }
50
+
51
+ /**
52
+ * Hold the first disk scan open so a joiner provably attaches while it is in
53
+ * flight, and guarantee teardown: a failing assertion must not leave the patched
54
+ * method installed or a scan blocked for the rest of the run.
55
+ */
56
+ function holdFirstScan(sm: StorageManager): {
57
+ reached: Promise<void>;
58
+ release: () => void;
59
+ restore: () => void;
60
+ } {
61
+ const scanReached = Promise.withResolvers<void>();
62
+ const releaseScan = Promise.withResolvers<void>();
63
+ const inner = sm as unknown as {
64
+ collectActiveMemoryPaths: (...args: unknown[]) => Promise<string[]>;
65
+ };
66
+ const orig = inner.collectActiveMemoryPaths.bind(sm);
67
+ let held = false;
68
+ inner.collectActiveMemoryPaths = async (...args: unknown[]) => {
69
+ if (!held) {
70
+ held = true;
71
+ scanReached.resolve();
72
+ await releaseScan.promise;
73
+ }
74
+ return orig(...args);
75
+ };
76
+ return {
77
+ reached: scanReached.promise,
78
+ release: () => releaseScan.resolve(),
79
+ restore: () => {
80
+ releaseScan.resolve();
81
+ inner.collectActiveMemoryPaths = orig;
82
+ },
83
+ };
84
+ }
85
+
86
+ test("readAllMemories: an unfired signal changes nothing", async () => {
87
+ await withStorage("remnic-2307-memories-inert-", async (sm) => {
88
+ await sm.writeMemory("fact", "inert signal memory");
89
+ const memories = await sm.readAllMemories({ abortSignal: new AbortController().signal });
90
+ assert.ok(memories.some((m) => m.content === "inert signal memory"));
91
+ });
92
+ });
93
+
94
+ test("readAllMemories: an already-aborted caller reads nothing at all", async () => {
95
+ await withStorage("remnic-2307-memories-preabort-", async (sm) => {
96
+ await sm.writeMemory("fact", "pre-abort memory");
97
+ sm.invalidateAllMemoriesCacheForDir();
98
+ const spy = installScanSpy(sm);
99
+ const aborted = new AbortController();
100
+ aborted.abort();
101
+
102
+ await assert.rejects(sm.readAllMemories({ abortSignal: aborted.signal }), isAbort);
103
+ assert.equal(spy.scans(), 0, "an abandoned caller must not start a disk scan");
104
+ });
105
+ });
106
+
107
+ test("readAllMemories: a mid-scan abort caches nothing", async () => {
108
+ await withStorage("remnic-2307-memories-midscan-", async (sm) => {
109
+ await sm.writeMemory("fact", "mid-scan memory");
110
+ sm.invalidateAllMemoriesCacheForDir();
111
+
112
+ // Abort once the walk has produced paths: the next per-batch checkpoint in the
113
+ // parse phase is a real boundary, not a synthetic one.
114
+ const aborted = new AbortController();
115
+ const inner = sm as unknown as {
116
+ collectActiveMemoryPaths: (...args: unknown[]) => Promise<string[]>;
117
+ };
118
+ const origCollect = inner.collectActiveMemoryPaths.bind(sm);
119
+ inner.collectActiveMemoryPaths = async (...args: unknown[]) => {
120
+ const paths = await origCollect(...args);
121
+ aborted.abort();
122
+ return paths;
123
+ };
124
+
125
+ await assert.rejects(sm.readAllMemories({ abortSignal: aborted.signal }), isAbort);
126
+
127
+ // The publish step is downstream of the checkpoint, so a truncated corpus must
128
+ // never have reached the cache: a fresh read has to scan again.
129
+ inner.collectActiveMemoryPaths = origCollect;
130
+ const spy = installScanSpy(sm);
131
+ const after = await sm.readAllMemories();
132
+ assert.equal(spy.scans(), 1, "the aborted read must not have published a cache entry");
133
+ assert.ok(after.some((m) => m.content === "mid-scan memory"), "the corpus is intact");
134
+ });
135
+ });
136
+
137
+ test("readAllMemories: a second reader protects the coalesced scan from the starter's abort", async () => {
138
+ await withStorage("remnic-2307-memories-joiner-", async (sm) => {
139
+ await sm.writeMemory("fact", "joined memory");
140
+ sm.invalidateAllMemoriesCacheForDir();
141
+
142
+ const hold = holdFirstScan(sm);
143
+ const spy = installScanSpy(sm);
144
+ const starterAbort = new AbortController();
145
+ const starter = sm.readAllMemories({ abortSignal: starterAbort.signal });
146
+ // Observe the starter now: its rejection lands the instant it aborts, and this
147
+ // test awaits the joiner in between, so an unobserved promise would surface as
148
+ // an unhandled rejection rather than the assertion below.
149
+ const starterOutcome = starter.then(
150
+ (value) => ({ ok: true as const, value }),
151
+ (error: unknown) => ({ ok: false as const, error }),
152
+ );
153
+ try {
154
+ await hold.reached;
155
+ const joiner = sm.readAllMemories();
156
+
157
+ starterAbort.abort();
158
+ hold.release();
159
+
160
+ const joined = await joiner;
161
+ assert.ok(
162
+ joined.some((m) => m.content === "joined memory"),
163
+ "a joiner must never be handed a cancelled or truncated read",
164
+ );
165
+ // The joiner keeps the scan alive, so it completes — but the starter still
166
+ // gets its own AbortError, because it asked to be cancelled.
167
+ const outcome = await starterOutcome;
168
+ assert.equal(outcome.ok, false, "the starter must not be handed data it cancelled");
169
+ assert.ok(!outcome.ok && isAbort(outcome.error));
170
+ assert.equal(spy.scans(), 1, "the joiner reused the in-flight scan rather than starting its own");
171
+ } finally {
172
+ hold.restore();
173
+ await starterOutcome;
174
+ }
175
+ });
176
+ });
177
+
178
+ test("readAllMemories: a joiner arriving after the starter aborted starts a fresh scan", async () => {
179
+ await withStorage("remnic-2307-memories-late-joiner-", async (sm) => {
180
+ await sm.writeMemory("fact", "late joiner memory");
181
+ sm.invalidateAllMemoriesCacheForDir();
182
+
183
+ // The starter's scan is doomed the instant its signal fires while it is the
184
+ // sole waiter. Withdrawing it from the registry first is what stops a late
185
+ // joiner inheriting that AbortError for a request it never cancelled.
186
+ const hold = holdFirstScan(sm);
187
+ const starterAbort = new AbortController();
188
+ const starter = sm.readAllMemories({ abortSignal: starterAbort.signal });
189
+ try {
190
+ await hold.reached;
191
+ starterAbort.abort();
192
+
193
+ // Joins only AFTER the abort — must not attach to the doomed scan.
194
+ const lateJoiner = sm.readAllMemories();
195
+ hold.release();
196
+
197
+ await assert.rejects(starter, isAbort);
198
+ const joined = await lateJoiner;
199
+ assert.ok(
200
+ joined.some((m) => m.content === "late joiner memory"),
201
+ "a late joiner must get its own scan, not the starter's AbortError",
202
+ );
203
+ } finally {
204
+ hold.restore();
205
+ await starter.catch(() => {});
206
+ }
207
+ });
208
+ });
209
+
210
+ test("readAllMemories: a joiner honours its own signal without cancelling the shared scan", async () => {
211
+ await withStorage("remnic-2307-memories-joiner-abort-", async (sm) => {
212
+ await sm.writeMemory("fact", "shared scan memory");
213
+ sm.invalidateAllMemoriesCacheForDir();
214
+
215
+ const hold = holdFirstScan(sm);
216
+ const starter = sm.readAllMemories();
217
+ try {
218
+ await hold.reached;
219
+ const joinerAbort = new AbortController();
220
+ const joiner = sm.readAllMemories({ abortSignal: joinerAbort.signal });
221
+ joinerAbort.abort();
222
+
223
+ // The joiner stops waiting immediately, before the scan is even released.
224
+ await assert.rejects(joiner, isAbort);
225
+
226
+ hold.release();
227
+ const starterResult = await starter;
228
+ assert.ok(
229
+ starterResult.some((m) => m.content === "shared scan memory"),
230
+ "a joiner's cancellation must never reach the shared scan",
231
+ );
232
+ } finally {
233
+ hold.restore();
234
+ await starter.catch(() => {});
235
+ }
236
+ });
237
+ });
238
+
239
+ test("readAllMemories: the scan is cancelled once its LAST waiter leaves", async () => {
240
+ await withStorage("remnic-2307-memories-last-waiter-", async (sm) => {
241
+ await sm.writeMemory("fact", "last waiter memory");
242
+ sm.invalidateAllMemoriesCacheForDir();
243
+
244
+ // Two cancellable readers overlap and both give up. Neither may cancel alone,
245
+ // but with nobody left the scan must not run on for no one — that is the
246
+ // abandoned I/O this whole change removes.
247
+ const hold = holdFirstScan(sm);
248
+ const starterAbort = new AbortController();
249
+ const joinerAbort = new AbortController();
250
+ const starter = sm.readAllMemories({ abortSignal: starterAbort.signal });
251
+ try {
252
+ await hold.reached;
253
+ const joiner = sm.readAllMemories({ abortSignal: joinerAbort.signal });
254
+
255
+ // First to leave must NOT cancel: the other reader still wants the result.
256
+ joinerAbort.abort();
257
+ await assert.rejects(joiner, isAbort);
258
+ starterAbort.abort();
259
+ hold.release();
260
+
261
+ await assert.rejects(starter, isAbort);
262
+
263
+ // With the scan cancelled rather than completed, nothing was published: a
264
+ // fresh read has to scan again.
265
+ hold.restore();
266
+ const spy = installScanSpy(sm);
267
+ const after = await sm.readAllMemories();
268
+ assert.equal(spy.scans(), 1, "the abandoned scan must not have published a cache entry");
269
+ assert.ok(after.some((m) => m.content === "last waiter memory"));
270
+ } finally {
271
+ hold.restore();
272
+ await starter.catch(() => {});
273
+ }
274
+ });
275
+ });
276
+
277
+ test("readAllMemories: a sole waiter's abort does stop the scan", async () => {
278
+ await withStorage("remnic-2307-memories-sole-", async (sm) => {
279
+ await sm.writeMemory("fact", "sole waiter memory");
280
+ sm.invalidateAllMemoriesCacheForDir();
281
+
282
+ const hold = holdFirstScan(sm);
283
+ const aborted = new AbortController();
284
+ const pending = sm.readAllMemories({ abortSignal: aborted.signal });
285
+ try {
286
+ await hold.reached;
287
+ aborted.abort();
288
+ hold.release();
289
+ await assert.rejects(pending, isAbort);
290
+ } finally {
291
+ hold.restore();
292
+ await pending.catch(() => {});
293
+ }
294
+ });
295
+ });
296
+
297
+ test("readAllEntityFiles: an already-aborted caller reads nothing and caches nothing", async () => {
298
+ await withStorage("remnic-2307-entities-", async (sm) => {
299
+ await sm.writeEntity("Cancelled Person", "person", ["Cancelled Person owns this test."]);
300
+
301
+ let entityFileReads = 0;
302
+ const inner = sm as unknown as {
303
+ readStorageSecureFile: (filePath: string) => Promise<string>;
304
+ };
305
+ const origRead = inner.readStorageSecureFile.bind(sm);
306
+ inner.readStorageSecureFile = async (filePath: string) => {
307
+ entityFileReads += 1;
308
+ return origRead(filePath);
309
+ };
310
+
311
+ const aborted = new AbortController();
312
+ aborted.abort();
313
+ await assert.rejects(sm.readAllEntityFiles({ abortSignal: aborted.signal }), isAbort);
314
+ assert.equal(entityFileReads, 0, "an abandoned caller must not read entity files");
315
+
316
+ // Nothing was published, so the corpus still reads correctly afterwards.
317
+ const entities = await sm.readAllEntityFiles();
318
+ assert.ok(entities.some((e) => e.name === "Cancelled Person"));
319
+ });
320
+ });
321
+
322
+ test("readAllEntityFiles: a mid-scan abort publishes no partial entity cache", async () => {
323
+ await withStorage("remnic-2307-entities-midscan-", async (sm) => {
324
+ await sm.writeEntity("First Person", "person", ["First Person owns this test."]);
325
+ await sm.writeEntity("Second Person", "person", ["Second Person owns this test too."]);
326
+
327
+ // Abort once the first entity file has been read: the post-loop checkpoint is
328
+ // what stops that partial batch reaching setCachedEntities.
329
+ const aborted = new AbortController();
330
+ const inner = sm as unknown as {
331
+ readStorageSecureFile: (filePath: string) => Promise<string>;
332
+ };
333
+ const origRead = inner.readStorageSecureFile.bind(sm);
334
+ let reads = 0;
335
+ inner.readStorageSecureFile = async (filePath: string) => {
336
+ reads += 1;
337
+ aborted.abort();
338
+ return origRead(filePath);
339
+ };
340
+
341
+ await assert.rejects(sm.readAllEntityFiles({ abortSignal: aborted.signal }), isAbort);
342
+ assert.ok(reads >= 1, "the scan started before it was cancelled");
343
+
344
+ inner.readStorageSecureFile = origRead;
345
+ const entities = await sm.readAllEntityFiles();
346
+ assert.deepEqual(
347
+ entities.map((e) => e.name).sort(),
348
+ ["First Person", "Second Person"],
349
+ "the aborted scan must not have cached a partial entity set",
350
+ );
351
+ });
352
+ });
353
+
354
+ test("searchArtifacts: an already-aborted caller never reads the artifact tier", async () => {
355
+ await withStorage("remnic-2307-artifacts-preabort-", async (sm) => {
356
+ await sm.writeArtifact("deploy runbook rollback quote", { sourceMemoryId: "src-1" });
357
+
358
+ let artifactReads = 0;
359
+ const inner = sm as unknown as {
360
+ readMemoryByPath: (filePath: string) => Promise<unknown>;
361
+ };
362
+ const origRead = inner.readMemoryByPath.bind(sm);
363
+ inner.readMemoryByPath = async (filePath: string) => {
364
+ artifactReads += 1;
365
+ return origRead(filePath);
366
+ };
367
+
368
+ const aborted = new AbortController();
369
+ aborted.abort();
370
+ await assert.rejects(
371
+ sm.searchArtifacts("deploy runbook", 5, { abortSignal: aborted.signal }),
372
+ isAbort,
373
+ );
374
+ assert.equal(artifactReads, 0, "an abandoned caller must not walk the artifact tier");
375
+ });
376
+ });
377
+
378
+ test("searchArtifacts: a mid-walk abort caches nothing", async () => {
379
+ await withStorage("remnic-2307-artifacts-midwalk-", async (sm) => {
380
+ await sm.writeArtifact("deploy runbook rollback quote", { sourceMemoryId: "src-1" });
381
+ await sm.writeArtifact("second deploy runbook quote", { sourceMemoryId: "src-2" });
382
+
383
+ // Abort from inside the walk, at the per-entry checkpoint.
384
+ const aborted = new AbortController();
385
+ const inner = sm as unknown as {
386
+ readMemoryByPath: (filePath: string) => Promise<unknown>;
387
+ };
388
+ const origRead = inner.readMemoryByPath.bind(sm);
389
+ let reads = 0;
390
+ inner.readMemoryByPath = async (filePath: string) => {
391
+ reads += 1;
392
+ aborted.abort();
393
+ return origRead(filePath);
394
+ };
395
+
396
+ await assert.rejects(
397
+ sm.searchArtifacts("deploy runbook", 5, { abortSignal: aborted.signal }),
398
+ isAbort,
399
+ );
400
+ assert.ok(reads >= 1, "the walk started before it was cancelled");
401
+
402
+ // A partial walk must not have been published as the artifact index: the next
403
+ // search sees every artifact.
404
+ inner.readMemoryByPath = origRead;
405
+ const matches = await sm.searchArtifacts("deploy runbook", 5);
406
+ assert.equal(matches.length, 2, "the aborted walk must not have cached a partial tier");
407
+ });
408
+ });