@remnic/core 9.47.0 → 9.48.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 (236) hide show
  1. package/dist/access-admin-ops-surface.d.ts +6 -5
  2. package/dist/access-admin-ops-surface.js +27 -25
  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 -26
  6. package/dist/access-boundary.d.ts +6 -5
  7. package/dist/access-boundary.js +27 -25
  8. package/dist/access-cli.js +87 -85
  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 -25
  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 -25
  16. package/dist/access-http.d.ts +6 -5
  17. package/dist/access-http.js +36 -34
  18. package/dist/access-identity-continuity-surface.d.ts +5 -4
  19. package/dist/access-identity-continuity-surface.js +27 -25
  20. package/dist/access-lcm-surface.d.ts +6 -5
  21. package/dist/access-lcm-surface.js +27 -25
  22. package/dist/access-mcp.d.ts +6 -5
  23. package/dist/access-mcp.js +32 -30
  24. package/dist/access-namespace-preflight.js +27 -25
  25. package/dist/access-observe-write-surface.d.ts +6 -5
  26. package/dist/access-observe-write-surface.js +27 -25
  27. package/dist/access-offline-manifest.d.ts +5 -4
  28. package/dist/access-offline-manifest.js +4 -4
  29. package/dist/access-operations-batch.js +29 -27
  30. package/dist/access-operations.d.ts +9 -8
  31. package/dist/access-operations.js +31 -29
  32. package/dist/access-recall-concurrency.d.ts +6 -5
  33. package/dist/access-recall-concurrency.js +27 -25
  34. package/dist/access-recall-response.d.ts +6 -5
  35. package/dist/access-recall-response.js +27 -25
  36. package/dist/access-recall-surface.d.ts +11 -10
  37. package/dist/access-recall-surface.js +27 -25
  38. package/dist/access-schema.d.ts +86 -86
  39. package/dist/{access-service-DNBYHK33.d.ts → access-service-andNjwfD.d.ts} +3 -3
  40. package/dist/access-service-helpers.d.ts +6 -5
  41. package/dist/access-service.d.ts +10 -9
  42. package/dist/access-service.js +27 -25
  43. package/dist/access-surface-catalog.d.ts +6 -5
  44. package/dist/access-wearables-meetings-surface.d.ts +3 -2
  45. package/dist/adapters/index.js +4 -4
  46. package/dist/adapters/registry.js +2 -2
  47. package/dist/artifact-search.d.ts +37 -0
  48. package/dist/artifact-search.js +13 -0
  49. package/dist/artifact-search.js.map +1 -0
  50. package/dist/bootstrap.d.ts +5 -4
  51. package/dist/briefing.d.ts +2 -1
  52. package/dist/briefing.js +10 -7
  53. package/dist/buffer.d.ts +2 -1
  54. package/dist/buffer.js +4 -4
  55. package/dist/causal-consolidation.js +11 -8
  56. package/dist/causal-consolidation.js.map +1 -1
  57. package/dist/{chunk-T6JRMVOV.js → chunk-2EJNXE53.js} +2 -2
  58. package/dist/{chunk-SIP5DBDN.js → chunk-2P3GQVM2.js} +152 -130
  59. package/dist/chunk-2P3GQVM2.js.map +1 -0
  60. package/dist/{chunk-64BHFFKZ.js → chunk-2RPJ4NEE.js} +2 -2
  61. package/dist/{chunk-JFO5QZ7N.js → chunk-3JUYWPFJ.js} +2 -2
  62. package/dist/{chunk-L7G72WY4.js → chunk-3NDN5P55.js} +2 -2
  63. package/dist/{chunk-IXH2U2QT.js → chunk-45OTV7FZ.js} +19 -19
  64. package/dist/chunk-4O4TKIXQ.js +171 -0
  65. package/dist/chunk-4O4TKIXQ.js.map +1 -0
  66. package/dist/{chunk-BDNL34CT.js → chunk-4WBGZYZZ.js} +33 -8
  67. package/dist/chunk-4WBGZYZZ.js.map +1 -0
  68. package/dist/{chunk-U6JGQ37L.js → chunk-52I3YZZ6.js} +3 -3
  69. package/dist/{chunk-IZQ5TDFT.js → chunk-53AJILXF.js} +3 -3
  70. package/dist/chunk-5AUY5Z5E.js +34 -0
  71. package/dist/chunk-5AUY5Z5E.js.map +1 -0
  72. package/dist/{chunk-YWCOJOUI.js → chunk-5L7PTX2Q.js} +2 -2
  73. package/dist/{chunk-ELIL2A37.js → chunk-7FGL6CGA.js} +5 -5
  74. package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
  75. package/dist/{chunk-HXXTXLKA.js → chunk-A2TFK5PG.js} +2 -2
  76. package/dist/{chunk-LI7OE7G6.js → chunk-ANCC3JJH.js} +2 -2
  77. package/dist/{chunk-ILBLZ5Y5.js → chunk-D5NGH2EW.js} +2 -2
  78. package/dist/{chunk-ST5IEK3H.js → chunk-DUEEQCTT.js} +2 -2
  79. package/dist/{chunk-QISYTKG4.js → chunk-EZNXW27Q.js} +6 -6
  80. package/dist/{chunk-47J4EDGR.js → chunk-FJ2ND6CZ.js} +2 -2
  81. package/dist/{chunk-FQU3HTQM.js → chunk-FMCHFSZF.js} +6 -6
  82. package/dist/{chunk-FULKGK7P.js → chunk-G2RBWVFQ.js} +2 -2
  83. package/dist/{chunk-W2ZSNIBP.js → chunk-GD7UPIBG.js} +2 -2
  84. package/dist/{chunk-Z54EM5YZ.js → chunk-GF22HWS2.js} +4 -4
  85. package/dist/{chunk-ZDAYWJV6.js → chunk-GVX3NAUU.js} +2 -2
  86. package/dist/{chunk-M2INOSTA.js → chunk-GWHPQOUC.js} +2 -2
  87. package/dist/{chunk-N5XULCU3.js → chunk-HIDEBEAN.js} +9 -9
  88. package/dist/{chunk-74HN5QUH.js → chunk-HRMEYQRO.js} +2 -2
  89. package/dist/{chunk-VFZS2HAA.js → chunk-IP3DHDU2.js} +2 -2
  90. package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
  91. package/dist/{chunk-PFJXPOD4.js → chunk-JYPO7L3G.js} +2 -2
  92. package/dist/{chunk-G2L5IZV4.js → chunk-KSGEGHYA.js} +44 -44
  93. package/dist/{chunk-PKCLQ7KM.js → chunk-LZGCJSQR.js} +5 -5
  94. package/dist/{chunk-WOC6DXP5.js → chunk-M4DMO67I.js} +4 -4
  95. package/dist/{chunk-JCNJIXZN.js → chunk-NXUJRXSF.js} +2 -2
  96. package/dist/{chunk-5VQPZ4GV.js → chunk-PELT6ADC.js} +3 -3
  97. package/dist/{chunk-IDE5AR5W.js → chunk-PLCZD3LG.js} +28 -66
  98. package/dist/chunk-PLCZD3LG.js.map +1 -0
  99. package/dist/{chunk-KMWYZTIE.js → chunk-PRVJH7CY.js} +2 -2
  100. package/dist/{chunk-RVO6TCTU.js → chunk-QCECHYZL.js} +6 -6
  101. package/dist/{chunk-KA47C7BF.js → chunk-RSPSHRZA.js} +2 -2
  102. package/dist/{chunk-E4FOAENZ.js → chunk-S55OAS5P.js} +5 -5
  103. package/dist/{chunk-BIPBQQFI.js → chunk-UMOO7IY7.js} +2 -2
  104. package/dist/{chunk-5U7ID3EZ.js → chunk-UTVZKHJK.js} +2 -2
  105. package/dist/{chunk-V5FE3UAI.js → chunk-WOEVJKKQ.js} +2 -2
  106. package/dist/{chunk-N3LL5NEU.js → chunk-WQIHRA5F.js} +2 -2
  107. package/dist/chunk-Z7L72ZHL.js +75 -0
  108. package/dist/chunk-Z7L72ZHL.js.map +1 -0
  109. package/dist/{cli-BWXSPxB9.d.ts → cli-p_WmVo2h.d.ts} +3 -3
  110. package/dist/cli.d.ts +7 -6
  111. package/dist/cli.js +55 -53
  112. package/dist/compounding/engine.d.ts +2 -1
  113. package/dist/compounding/engine.js +10 -7
  114. package/dist/connectors/codex-materialize-runner.js +10 -7
  115. package/dist/connectors/index.js +10 -7
  116. package/dist/consolidation-provenance-check.d.ts +2 -1
  117. package/dist/consolidation-undo.d.ts +2 -1
  118. package/dist/contradiction/index.d.ts +7 -6
  119. package/dist/convergence-refresh.d.ts +2 -1
  120. package/dist/convergence-refresh.js +12 -9
  121. package/dist/corpus-watermark.js +15 -13
  122. package/dist/entity-recall-cancellation.d.ts +36 -0
  123. package/dist/entity-recall-cancellation.js +19 -0
  124. package/dist/entity-recall-cancellation.js.map +1 -0
  125. package/dist/entity-retrieval.d.ts +14 -6
  126. package/dist/entity-retrieval.js +11 -7
  127. package/dist/explicit-capture.d.ts +5 -4
  128. package/dist/external-wiki-access.d.ts +6 -5
  129. package/dist/external-wiki-access.js +28 -26
  130. package/dist/external-wiki-mcp-tools.d.ts +6 -5
  131. package/dist/extraction.js +15 -13
  132. package/dist/index.d.ts +687 -686
  133. package/dist/index.js +114 -112
  134. package/dist/lcm/engine.js +2 -2
  135. package/dist/lcm/index.js +7 -7
  136. package/dist/maintenance/memory-governance.js +10 -7
  137. package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +7 -6
  138. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -7
  139. package/dist/maintenance/rebuild-memory-projection.d.ts +2 -1
  140. package/dist/maintenance/rebuild-memory-projection.js +11 -8
  141. package/dist/{maintenance-DQrL93Uu.d.ts → maintenance-BfcuwfJS.d.ts} +1 -1
  142. package/dist/mcp-memory-inspector-app.d.ts +6 -5
  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 +14 -12
  146. package/dist/namespaces/search.js +4 -4
  147. package/dist/namespaces/storage.d.ts +7 -6
  148. package/dist/namespaces/storage.js +10 -7
  149. package/dist/offline-sync-impression-drain.js +3 -3
  150. package/dist/operator-doctor-corpus.js +16 -14
  151. package/dist/operator-toolkit.d.ts +2 -1
  152. package/dist/operator-toolkit.js +20 -18
  153. package/dist/orchestration/compression-guideline-coordinator.d.ts +2 -1
  154. package/dist/orchestration/maintenance.d.ts +3 -2
  155. package/dist/orchestration/maintenance.js +14 -12
  156. package/dist/{orchestrator-CRR-6sVB.d.ts → orchestrator-B7nLo4z1.d.ts} +3 -3
  157. package/dist/orchestrator.d.ts +5 -4
  158. package/dist/orchestrator.js +89 -87
  159. package/dist/qmd.js +4 -4
  160. package/dist/recall-qos.d.ts +109 -2
  161. package/dist/recall-qos.js +12 -3
  162. package/dist/schemas.d.ts +76 -76
  163. package/dist/search/factory.js +3 -3
  164. package/dist/search/index.js +3 -3
  165. package/dist/semantic-consolidation.js +11 -8
  166. package/dist/semantic-rule-promotion.js +10 -7
  167. package/dist/semantic-rule-verifier.js +10 -7
  168. package/dist/{service-DJ0LDyuN.d.ts → service-B5W5IYCx.d.ts} +1 -1
  169. package/dist/shared-context/manager.d.ts +8 -8
  170. package/dist/source-agent-qualifier.js +13 -11
  171. package/dist/{storage-y340tYKb.d.ts → storage-COL907QF.d.ts} +2 -1
  172. package/dist/storage.d.ts +2 -1
  173. package/dist/storage.js +9 -6
  174. package/dist/temporal-supersession.d.ts +2 -1
  175. package/dist/tier-migration.d.ts +2 -1
  176. package/dist/transfer/types.d.ts +66 -66
  177. package/dist/verified-recall.js +10 -7
  178. package/package.json +2 -2
  179. package/src/artifact-search.test.ts +110 -0
  180. package/src/artifact-search.ts +115 -0
  181. package/src/entity-recall-cancellation.ts +58 -0
  182. package/src/entity-retrieval.ts +58 -4
  183. package/src/orchestration/namespace-read-fanout.ts +4 -1
  184. package/src/orchestration/recall-internal-deps.ts +2 -0
  185. package/src/orchestration/recall-internal.ts +76 -55
  186. package/src/orchestration/recall-search-pipeline.ts +9 -1
  187. package/src/orchestration/recall-search-prefilter.ts +18 -1
  188. package/src/orchestrator.ts +5 -6
  189. package/src/recall-qos.test.ts +289 -0
  190. package/src/recall-qos.ts +238 -1
  191. package/src/storage.ts +12 -56
  192. package/dist/chunk-BDNL34CT.js.map +0 -1
  193. package/dist/chunk-C3AKM55W.js +0 -59
  194. package/dist/chunk-C3AKM55W.js.map +0 -1
  195. package/dist/chunk-IDE5AR5W.js.map +0 -1
  196. package/dist/chunk-SIP5DBDN.js.map +0 -1
  197. /package/dist/{chunk-T6JRMVOV.js.map → chunk-2EJNXE53.js.map} +0 -0
  198. /package/dist/{chunk-64BHFFKZ.js.map → chunk-2RPJ4NEE.js.map} +0 -0
  199. /package/dist/{chunk-JFO5QZ7N.js.map → chunk-3JUYWPFJ.js.map} +0 -0
  200. /package/dist/{chunk-L7G72WY4.js.map → chunk-3NDN5P55.js.map} +0 -0
  201. /package/dist/{chunk-IXH2U2QT.js.map → chunk-45OTV7FZ.js.map} +0 -0
  202. /package/dist/{chunk-U6JGQ37L.js.map → chunk-52I3YZZ6.js.map} +0 -0
  203. /package/dist/{chunk-IZQ5TDFT.js.map → chunk-53AJILXF.js.map} +0 -0
  204. /package/dist/{chunk-YWCOJOUI.js.map → chunk-5L7PTX2Q.js.map} +0 -0
  205. /package/dist/{chunk-ELIL2A37.js.map → chunk-7FGL6CGA.js.map} +0 -0
  206. /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
  207. /package/dist/{chunk-HXXTXLKA.js.map → chunk-A2TFK5PG.js.map} +0 -0
  208. /package/dist/{chunk-LI7OE7G6.js.map → chunk-ANCC3JJH.js.map} +0 -0
  209. /package/dist/{chunk-ILBLZ5Y5.js.map → chunk-D5NGH2EW.js.map} +0 -0
  210. /package/dist/{chunk-ST5IEK3H.js.map → chunk-DUEEQCTT.js.map} +0 -0
  211. /package/dist/{chunk-QISYTKG4.js.map → chunk-EZNXW27Q.js.map} +0 -0
  212. /package/dist/{chunk-47J4EDGR.js.map → chunk-FJ2ND6CZ.js.map} +0 -0
  213. /package/dist/{chunk-FQU3HTQM.js.map → chunk-FMCHFSZF.js.map} +0 -0
  214. /package/dist/{chunk-FULKGK7P.js.map → chunk-G2RBWVFQ.js.map} +0 -0
  215. /package/dist/{chunk-W2ZSNIBP.js.map → chunk-GD7UPIBG.js.map} +0 -0
  216. /package/dist/{chunk-Z54EM5YZ.js.map → chunk-GF22HWS2.js.map} +0 -0
  217. /package/dist/{chunk-ZDAYWJV6.js.map → chunk-GVX3NAUU.js.map} +0 -0
  218. /package/dist/{chunk-M2INOSTA.js.map → chunk-GWHPQOUC.js.map} +0 -0
  219. /package/dist/{chunk-N5XULCU3.js.map → chunk-HIDEBEAN.js.map} +0 -0
  220. /package/dist/{chunk-74HN5QUH.js.map → chunk-HRMEYQRO.js.map} +0 -0
  221. /package/dist/{chunk-VFZS2HAA.js.map → chunk-IP3DHDU2.js.map} +0 -0
  222. /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
  223. /package/dist/{chunk-PFJXPOD4.js.map → chunk-JYPO7L3G.js.map} +0 -0
  224. /package/dist/{chunk-G2L5IZV4.js.map → chunk-KSGEGHYA.js.map} +0 -0
  225. /package/dist/{chunk-PKCLQ7KM.js.map → chunk-LZGCJSQR.js.map} +0 -0
  226. /package/dist/{chunk-WOC6DXP5.js.map → chunk-M4DMO67I.js.map} +0 -0
  227. /package/dist/{chunk-JCNJIXZN.js.map → chunk-NXUJRXSF.js.map} +0 -0
  228. /package/dist/{chunk-5VQPZ4GV.js.map → chunk-PELT6ADC.js.map} +0 -0
  229. /package/dist/{chunk-KMWYZTIE.js.map → chunk-PRVJH7CY.js.map} +0 -0
  230. /package/dist/{chunk-RVO6TCTU.js.map → chunk-QCECHYZL.js.map} +0 -0
  231. /package/dist/{chunk-KA47C7BF.js.map → chunk-RSPSHRZA.js.map} +0 -0
  232. /package/dist/{chunk-E4FOAENZ.js.map → chunk-S55OAS5P.js.map} +0 -0
  233. /package/dist/{chunk-BIPBQQFI.js.map → chunk-UMOO7IY7.js.map} +0 -0
  234. /package/dist/{chunk-5U7ID3EZ.js.map → chunk-UTVZKHJK.js.map} +0 -0
  235. /package/dist/{chunk-V5FE3UAI.js.map → chunk-WOEVJKKQ.js.map} +0 -0
  236. /package/dist/{chunk-N3LL5NEU.js.map → chunk-WQIHRA5F.js.map} +0 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Verbatim-artifact keyword scan — extracted from `storage.ts` so the scan can
3
+ * be made interruptible without growing that file (issue #2291).
4
+ *
5
+ * The scan re-tokenizes every stored artifact, and `fetchActiveArtifactsForNamespace`
6
+ * repeats it with a growing fetch limit. On a large artifact tier that is long
7
+ * enough to starve the event loop, which would stop a recall section deadline
8
+ * from ever firing — so the loop yields periodically and observes cancellation.
9
+ */
10
+
11
+ import { throwIfAborted } from "./abort-error.js";
12
+ import { yieldToEventLoop } from "./recall-qos.js";
13
+ import type { MemoryFile } from "./types.js";
14
+
15
+ const ARTIFACT_SEARCH_STOPWORDS = new Set([
16
+ "a",
17
+ "an",
18
+ "and",
19
+ "are",
20
+ "as",
21
+ "at",
22
+ "be",
23
+ "but",
24
+ "by",
25
+ "for",
26
+ "from",
27
+ "has",
28
+ "have",
29
+ "i",
30
+ "in",
31
+ "is",
32
+ "it",
33
+ "of",
34
+ "on",
35
+ "or",
36
+ "that",
37
+ "the",
38
+ "this",
39
+ "to",
40
+ "was",
41
+ "were",
42
+ "with",
43
+ ]);
44
+
45
+ /**
46
+ * Artifacts scored between two yields. Small enough that a deadline observes a
47
+ * sub-millisecond delay on a normal tier, large enough that the yields cost
48
+ * nothing measurable relative to tokenizing that many documents.
49
+ */
50
+ const ARTIFACT_SCAN_YIELD_INTERVAL = 256;
51
+
52
+ export function tokenizeArtifactSearchText(input: string): string[] {
53
+ return input
54
+ .toLowerCase()
55
+ .split(/[^a-z0-9]+/i)
56
+ .map((t) => t.trim())
57
+ .filter((t) => t.length >= 2)
58
+ .filter((t) => !ARTIFACT_SEARCH_STOPWORDS.has(t));
59
+ }
60
+
61
+ export interface ArtifactSearchOptions {
62
+ abortSignal?: AbortSignal;
63
+ }
64
+
65
+ /**
66
+ * Rank the artifact tier by how many query tokens each document contains, best
67
+ * first.
68
+ *
69
+ * `loadArtifacts` is a thunk rather than a value so the two cheap early exits —
70
+ * an already-cancelled caller, and a query with no searchable tokens — happen
71
+ * BEFORE the tier is read. Reading it can mean a full recursive filesystem scan
72
+ * on a cold cache, which a stopword-only query must never pay for.
73
+ *
74
+ * Yields to the event loop every `ARTIFACT_SCAN_YIELD_INTERVAL` documents and
75
+ * checks the caller's signal there, so a pending recall section deadline can fire
76
+ * mid-scan and an abandoned recall stops scanning instead of running to the end.
77
+ */
78
+ export async function selectArtifactMatches(
79
+ loadArtifacts: () => Promise<MemoryFile[]>,
80
+ query: string,
81
+ maxResults: number,
82
+ options: ArtifactSearchOptions = {},
83
+ ): Promise<MemoryFile[]> {
84
+ throwIfAborted(options.abortSignal, "artifact search aborted");
85
+ const tokens = tokenizeArtifactSearchText(query);
86
+ if (tokens.length === 0) return [];
87
+
88
+ const artifacts = await loadArtifacts();
89
+ // The scan below never awaits real I/O, so this is the last chance to observe a
90
+ // signal that fired while the tier was being read.
91
+ throwIfAborted(options.abortSignal, "artifact search aborted");
92
+ const hits: Array<{ score: number; memory: MemoryFile }> = [];
93
+ let scanned = 0;
94
+ for (const memory of artifacts) {
95
+ scanned += 1;
96
+ if (scanned % ARTIFACT_SCAN_YIELD_INTERVAL === 0) {
97
+ await yieldToEventLoop();
98
+ throwIfAborted(options.abortSignal, "artifact search aborted");
99
+ }
100
+ const indexedTokens = new Set(
101
+ tokenizeArtifactSearchText(`${memory.content} ${(memory.frontmatter.tags ?? []).join(" ")}`),
102
+ );
103
+ let score = 0;
104
+ for (const token of tokens) {
105
+ if (indexedTokens.has(token)) score += 1;
106
+ }
107
+ if (score > 0) {
108
+ hits.push({ score, memory });
109
+ }
110
+ }
111
+ // Ties keep corpus order: the comparator returns 0 for equal scores rather
112
+ // than an arbitrary sign, so repeated scans produce the same top-N.
113
+ hits.sort((a, b) => b.score - a.score);
114
+ return hits.slice(0, maxResults).map((h) => h.memory);
115
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cancellation checkpoints for entity recall (issue #2291).
3
+ *
4
+ * Entity recall reads every entity and memory file for the recalled namespaces and
5
+ * then makes several synchronous passes over the result. Both halves need the same
6
+ * two things at every boundary, in the same shape, with the same abort message —
7
+ * so they live here rather than being restated at each of a dozen call sites.
8
+ */
9
+
10
+ import { throwIfAborted } from "./abort-error.js";
11
+ import { yieldToEventLoop } from "./recall-qos.js";
12
+
13
+ const ABORT_MESSAGE = "entity recall aborted";
14
+
15
+ /**
16
+ * Entities/memories processed between two yields during an index build. Matches
17
+ * the artifact scan's interval: large enough that the yields are free relative to
18
+ * the work, small enough that a section deadline is observed promptly.
19
+ */
20
+ export const ENTITY_SCAN_YIELD_INTERVAL = 256;
21
+
22
+ /** Stop at the caller's signal without yielding. */
23
+ export function checkEntityRecallAbort(abortSignal?: AbortSignal): void {
24
+ throwIfAborted(abortSignal, ABORT_MESSAGE);
25
+ }
26
+
27
+ /**
28
+ * Hand the loop a macrotask, then stop if the caller gave up while it ran.
29
+ *
30
+ * A section deadline is a timer, so a synchronous pass over the corpus must be
31
+ * preceded by one of these or the deadline cannot fire during it.
32
+ */
33
+ export async function yieldEntityRecallScan(abortSignal?: AbortSignal): Promise<void> {
34
+ await yieldToEventLoop();
35
+ throwIfAborted(abortSignal, ABORT_MESSAGE);
36
+ }
37
+
38
+ /**
39
+ * The same yield/abort pair, but only every `ENTITY_SCAN_YIELD_INTERVAL` items —
40
+ * for loops where yielding per item would cost more than the work itself.
41
+ */
42
+ export async function yieldEntityRecallScanEvery(
43
+ processed: number,
44
+ abortSignal?: AbortSignal,
45
+ ): Promise<void> {
46
+ if (processed % ENTITY_SCAN_YIELD_INTERVAL !== 0) return;
47
+ await yieldEntityRecallScan(abortSignal);
48
+ }
49
+
50
+ /**
51
+ * Exit an entity-recall path with "no section", unless the caller has actually
52
+ * cancelled — in which case throw, so the bounded runner records a breach rather
53
+ * than a successful empty section.
54
+ */
55
+ export function entityRecallSectionAbsent(abortSignal?: AbortSignal): null {
56
+ throwIfAborted(abortSignal, ABORT_MESSAGE);
57
+ return null;
58
+ }
@@ -8,6 +8,12 @@ import { compareEntityTimestamps, normalizeEntityName, type StorageManager } fro
8
8
  import { normalizeEntityText, resolveRequestedEntitySectionKeys } from "./entity-schema.js";
9
9
  import type { EntityStructuredSection, MemoryFile, PluginConfig, TranscriptEntry } from "./types.js";
10
10
  import { containsPhrase } from "./entity-retrieval-boundaries.js";
11
+ import {
12
+ checkEntityRecallAbort,
13
+ entityRecallSectionAbsent,
14
+ yieldEntityRecallScan,
15
+ yieldEntityRecallScanEvery,
16
+ } from "./entity-recall-cancellation.js";
11
17
 
12
18
  const ENTITY_INDEX_VERSION = 3;
13
19
  const RECENT_TRANSCRIPT_LOOKBACK_HOURS = 24;
@@ -81,6 +87,13 @@ export interface BuildEntityRecallSectionOptions {
81
87
  maxRelatedEntities: number;
82
88
  maxChars: number;
83
89
  transcriptEntries: TranscriptEntry[];
90
+ /**
91
+ * Cancels the build at its next checkpoint (issue #2291). Entity recall reads
92
+ * every entity and memory file across the recalled namespaces, which is
93
+ * minutes of work on a large or slow store; without this the scan kept running
94
+ * after the recall that asked for it had been abandoned.
95
+ */
96
+ abortSignal?: AbortSignal;
84
97
  }
85
98
 
86
99
  function tokenize(value: string): string[] {
@@ -523,7 +536,9 @@ async function buildEntityMentionIndex(
523
536
  namespaceStorage?: (namespace: string) => Promise<StorageManager>,
524
537
  nativeChunksOverride?: NativeKnowledgeChunk[],
525
538
  resolvedStorages?: StorageManager[],
539
+ abortSignal?: AbortSignal,
526
540
  ): Promise<EntityMentionIndex> {
541
+ checkEntityRecallAbort(abortSignal);
527
542
  const storages = resolvedStorages ?? await resolveEntityIndexStorages(
528
543
  storage,
529
544
  config,
@@ -539,6 +554,9 @@ async function buildEntityMentionIndex(
539
554
  Promise.all(storages.map((scopedStorage) => scopedStorage.readAllMemories())),
540
555
  nativeChunksOverride ? Promise.resolve(nativeChunksOverride) : readNativeChunks(config, recallNamespaces),
541
556
  ]);
557
+ // The bulk reads above cannot be interrupted mid-flight; stop before spending
558
+ // the (larger) indexing pass over their results.
559
+ checkEntityRecallAbort(abortSignal);
542
560
  // Pair each entity with the storage that owns it (#1534): canonical ids must
543
561
  // reflect that store's aliases and historical migration mappings, never another
544
562
  // namespace's.
@@ -547,8 +565,14 @@ async function buildEntityMentionIndex(
547
565
  );
548
566
  const memories = memorySets.flat();
549
567
 
568
+ // The two passes over the corpus below never await real I/O, so without an
569
+ // explicit yield they hold the event loop and no recall section deadline can
570
+ // fire during them (issue #2291).
571
+ let scanned = 0;
550
572
  const entities = new Map<string, EntityMentionIndexEntry>();
551
573
  for (const { entity, storage: entityStorage } of entityRecords) {
574
+ scanned += 1;
575
+ await yieldEntityRecallScanEvery(scanned, abortSignal);
552
576
  const canonicalId =
553
577
  typeof entityStorage.normalizeEntityName === "function"
554
578
  ? entityStorage.normalizeEntityName(entity.name, entity.type)
@@ -589,7 +613,10 @@ async function buildEntityMentionIndex(
589
613
  });
590
614
  }
591
615
 
616
+ scanned = 0;
592
617
  for (const memory of memories) {
618
+ scanned += 1;
619
+ await yieldEntityRecallScanEvery(scanned, abortSignal);
593
620
  const entityRef = typeof memory.frontmatter.entityRef === "string" ? memory.frontmatter.entityRef : "";
594
621
  if (!entityRef) continue;
595
622
  const entry = entities.get(entityRef);
@@ -600,6 +627,11 @@ async function buildEntityMentionIndex(
600
627
  }
601
628
  }
602
629
 
630
+ // Each remaining phase is another synchronous pass over the whole index
631
+ // (alias map, native-chunk merge, sort, two full serializations). Yield between
632
+ // them so a section deadline can fire between passes instead of only after all
633
+ // of them (issue #2291).
634
+ await yieldEntityRecallScan(abortSignal);
603
635
  const aliasIndex = buildAliasIndex([...entities.values()]);
604
636
  for (const chunk of nativeChunks) {
605
637
  const existingPseudo = entities.get(nativePseudoCanonicalId(chunk));
@@ -620,8 +652,11 @@ async function buildEntityMentionIndex(
620
652
  entities.set(pseudoEntry.canonicalId, pseudoEntry);
621
653
  }
622
654
 
655
+ await yieldEntityRecallScan(abortSignal);
623
656
  const sortedEntities = [...entities.values()].sort((left, right) => left.name.localeCompare(right.name));
657
+ await yieldEntityRecallScan(abortSignal);
624
658
  const previousEntities = previousIndex ? JSON.stringify(previousIndex.entities) : "";
659
+ await yieldEntityRecallScan(abortSignal);
625
660
  const nextEntities = JSON.stringify(sortedEntities);
626
661
  const entityStatusVersionAfter = shouldPersistIndex ? storage.getMemoryStatusVersion() : undefined;
627
662
  const canPersistIndex = shouldPersistIndex && entityStatusVersionBefore === entityStatusVersionAfter;
@@ -635,6 +670,10 @@ async function buildEntityMentionIndex(
635
670
  entities: sortedEntities,
636
671
  };
637
672
  if (canPersistIndex) {
673
+ // A build the caller has abandoned must not leave a persisted index behind:
674
+ // the write is I/O the timed-out recall no longer needs, and the next recall
675
+ // is already competing for the same disk.
676
+ checkEntityRecallAbort(abortSignal);
638
677
  await writeEntityIndexState(storage, index);
639
678
  }
640
679
  return index;
@@ -1020,6 +1059,11 @@ function formatEntityHintSection(
1020
1059
  }
1021
1060
 
1022
1061
  export async function buildEntityRecallSection(options: BuildEntityRecallSectionOptions): Promise<string | null> {
1062
+ // Checkpoints sit at the real I/O boundaries below (persisted-index read,
1063
+ // native-chunk read, storage resolution, index build). The per-candidate
1064
+ // formatting that follows never awaits real I/O, so a deadline that fires
1065
+ // during a scan is observed here (issue #2291).
1066
+ checkEntityRecallAbort(options.abortSignal);
1023
1067
  const prefixedMode = detectEntityQueryMode(options.query);
1024
1068
  const persistedIndex = prefixedMode
1025
1069
  ? null
@@ -1029,6 +1073,7 @@ export async function buildEntityRecallSection(options: BuildEntityRecallSection
1029
1073
  options.recallNamespaces,
1030
1074
  options.namespaceStorage,
1031
1075
  );
1076
+ checkEntityRecallAbort(options.abortSignal);
1032
1077
  let nativeChunks: NativeKnowledgeChunk[] | undefined;
1033
1078
  if (
1034
1079
  !prefixedMode &&
@@ -1037,7 +1082,7 @@ export async function buildEntityRecallSection(options: BuildEntityRecallSection
1037
1082
  ) {
1038
1083
  nativeChunks = await readNativeChunks(options.config, options.recallNamespaces);
1039
1084
  if (!hasFreshNativeExplicitMention(persistedIndex, nativeChunks, options.query)) {
1040
- return null;
1085
+ return entityRecallSectionAbsent(options.abortSignal);
1041
1086
  }
1042
1087
  }
1043
1088
  const namespaceScoped =
@@ -1066,6 +1111,7 @@ export async function buildEntityRecallSection(options: BuildEntityRecallSection
1066
1111
  let index: EntityMentionIndex | undefined = namespaceCacheKey
1067
1112
  ? namespaceEntityIndexCache.get(namespaceCacheKey)
1068
1113
  : undefined;
1114
+ checkEntityRecallAbort(options.abortSignal);
1069
1115
  if (!index) {
1070
1116
  index = await buildEntityMentionIndex(
1071
1117
  options.storage,
@@ -1074,21 +1120,27 @@ export async function buildEntityRecallSection(options: BuildEntityRecallSection
1074
1120
  options.namespaceStorage,
1075
1121
  nativeChunks,
1076
1122
  resolvedStorages,
1123
+ options.abortSignal,
1077
1124
  );
1078
1125
  if (namespaceCacheKey) rememberNamespaceEntityIndex(namespaceCacheKey, index);
1079
1126
  }
1127
+ // Candidate resolution is another synchronous pass over every entity and alias,
1128
+ // reached even on a cache hit that did no I/O at all — so yield here too, or a
1129
+ // cached corpus could still hold the loop past the section budget (issue #2291).
1130
+ await yieldEntityRecallScan(options.abortSignal);
1080
1131
  const explicitCandidates = resolveExplicitCandidates(index, options.query);
1132
+ await yieldEntityRecallScan(options.abortSignal);
1081
1133
  const queryCandidates = prefixedMode
1082
1134
  ? explicitCandidates
1083
1135
  : resolveLanguageIndependentExplicitCandidates(index, options.query);
1084
1136
  const mode = prefixedMode ?? (queryCandidates.length > 0 ? "direct" : null);
1085
- if (!mode) return null;
1137
+ if (!mode) return entityRecallSectionAbsent(options.abortSignal);
1086
1138
 
1087
1139
  const candidates = queryCandidates.length > 0
1088
1140
  ? queryCandidates
1089
1141
  : resolveRecentTurnCandidates(index, options.transcriptEntries, options.recentTurns);
1090
1142
 
1091
- if (candidates.length === 0) return null;
1143
+ if (candidates.length === 0) return entityRecallSectionAbsent(options.abortSignal);
1092
1144
 
1093
1145
  const queryTokens = tokenize(options.query);
1094
1146
  const candidateLimit = queryCandidates.length === 0 && mode === "follow_up"
@@ -1121,7 +1173,9 @@ export async function buildEntityRecallSection(options: BuildEntityRecallSection
1121
1173
  );
1122
1174
 
1123
1175
  const section = formatEntityHintSection(enriched, queryTokens, mode, options.maxRelatedEntities, options.maxChars);
1124
- if (!section) return null;
1176
+ // `entityRecallSectionAbsent` so a cancelled build is not recorded as a
1177
+ // successful section that simply found no entities.
1178
+ if (!section) return entityRecallSectionAbsent(options.abortSignal);
1125
1179
  return section;
1126
1180
  }
1127
1181
 
@@ -27,6 +27,7 @@ import { mergeArtifactRecallCandidates, tokenizeRecallQuery } from "./orchestrat
27
27
  import { qmdCollectionPathParts } from "./qmd-result-resolver.js";
28
28
  import { qmdCollectionNamespaceFromPrefix as computeQmdCollectionNamespaceFromPrefix } from "./orchestrator-namespace-scope.js";
29
29
  import { resolveNamespaceFromStorageDir } from "../scopes/scope-plan.js";
30
+ import type { ArtifactRecallOptions } from "./recall-search-prefilter.js";
30
31
  import type { SearchBackend, SearchExecutionOptions, SearchQueryOptions } from "../search/port.js";
31
32
  import type { MemoryFile, PluginConfig, QmdSearchResult } from "../types.js";
32
33
  import {
@@ -41,6 +42,7 @@ export interface NamespaceReadFanoutDeps {
41
42
  namespace: string,
42
43
  prompt: string,
43
44
  targetCount: number,
45
+ options?: ArtifactRecallOptions,
44
46
  ): Promise<MemoryFile[]>;
45
47
  loadNamespaceStorageDirHintsFromCatalog(): void;
46
48
  readonly namespaceCatalog: NamespaceCatalog;
@@ -338,12 +340,13 @@ export class NamespaceReadFanoutCoordinator {
338
340
  prompt: string,
339
341
  recallNamespaces: string[],
340
342
  targetCount: number,
343
+ options: ArtifactRecallOptions = {},
341
344
  ): Promise<MemoryFile[]> {
342
345
  if (targetCount <= 0) return [];
343
346
  const namespaces = Array.from(new Set(recallNamespaces));
344
347
  const filteredByNamespace = await Promise.all(
345
348
  namespaces.map((namespace) =>
346
- this.deps.fetchActiveArtifactsForNamespace(namespace, prompt, targetCount),
349
+ this.deps.fetchActiveArtifactsForNamespace(namespace, prompt, targetCount, options),
347
350
  ),
348
351
  );
349
352
 
@@ -35,6 +35,7 @@ import type { VerifiedEpisodeResult } from "../verified-recall.js";
35
35
  import type { WorkProductLedgerSearchResult } from "../work-product-ledger.js";
36
36
  import type { GraphRecallRankedResult, GraphRecallShadowComparison } from "./graph-recall-coordinator.js";
37
37
  import type { RecallRerankCoordinator, RecallResultPartitionSink } from "./recall-rerank-coordinator.js";
38
+ import type { ArtifactRecallOptions } from "./recall-search-prefilter.js";
38
39
  import type { RecallSectionAppendOptions, RecallSectionBuckets } from "./recall-section-coordinator.js";
39
40
 
40
41
  export interface RecallInternalDeps {
@@ -339,6 +340,7 @@ export interface RecallInternalDeps {
339
340
  prompt: string,
340
341
  recallNamespaces: string[],
341
342
  targetCount: number,
343
+ options?: ArtifactRecallOptions,
342
344
  ): Promise<MemoryFile[]>;
343
345
  recallAssemblyClockMs(): number;
344
346
  recordLastGraphRecallSnapshot(options: {
@@ -46,7 +46,10 @@ import {
46
46
  boundRecallContextComposition, composeRecallContext, contextBudgetForFooter,
47
47
  formatCuriosityFooter, selectCuriosityQuestion,
48
48
  } from "../recall-context-composition.js";
49
- import { createRecallSectionMetricRecorder } from "../recall-qos.js";
49
+ import {
50
+ createBoundedCoreSectionRunner,
51
+ createRecallSectionMetricRecorder,
52
+ } from "../recall-qos.js";
50
53
  import { buildRecallQueryPolicy } from "../recall-query-policy.js";
51
54
  import { type GraphRecallExpandedEntry, type LastRecallSnapshot } from "../recall-state.js";
52
55
  import {
@@ -74,7 +77,7 @@ import { searchTrustZoneRecords } from "../trust-zones.js";
74
77
  import type { IdentityInjectionMode, MemoryFile, QmdSearchResult, RecallPlanMode } from "../types.js";
75
78
  import { type VerifiedEpisodeResult, compareVerifiedEpisodeResults, searchVerifiedEpisodes } from "../verified-recall.js";
76
79
  import { type WorkProductLedgerSearchResult, searchWorkProductLedgerEntries } from "../work-product-ledger.js";
77
- import { abortError } from "../abort-error.js";
80
+ import { abortError, isAbortError, raceAbort } from "../abort-error.js";
78
81
  import {
79
82
  applyQueryAwareCandidateFilter,
80
83
  filterRecallCandidates,
@@ -147,7 +150,13 @@ export class RecallInternalCoordinator {
147
150
  profileTraceClosed = true;
148
151
  this.deps.profiler.endTrace(profileTraceId); // persists to JSONL file
149
152
  };
153
+ // The CONFIGURED core budget, which is what the sections that run outside the
154
+ // bounded runner report in their metrics: nothing enforces a deadline for them,
155
+ // so reporting a derived figure would claim a bound that does not exist. The
156
+ // bounded runner below derives and reports its own effective budget per
157
+ // section, against the request budget still left when that section starts.
150
158
  const recallSectionDeadlineMs = this.deps.config.recallCoreDeadlineMs ?? 75_000;
159
+ const recallOuterTimeoutMs = this.deps.config.recallOuterTimeoutMs ?? 75_000;
151
160
  const enrichmentSectionDeadlineMs =
152
161
  this.deps.config.recallEnrichmentDeadlineMs ?? 25_000;
153
162
  // Wrap entire recall body in try/finally so profiling trace is always closed,
@@ -206,6 +215,14 @@ export class RecallInternalCoordinator {
206
215
  timings,
207
216
  logger: log,
208
217
  });
218
+ const runBoundedCoreSection = createBoundedCoreSectionRunner({
219
+ configuredDeadlineMs: recallSectionDeadlineMs,
220
+ outerDeadlineAtMs:
221
+ recallOuterTimeoutMs > 0 ? recallStart + recallOuterTimeoutMs : null,
222
+ parentSignal: options.abortSignal,
223
+ record: recordRecallSectionMetric,
224
+ logger: log,
225
+ });
209
226
  const promptHash = createHash("sha256").update(prompt).digest("hex");
210
227
  const traceId = createHash("sha256")
211
228
  .update(`${sessionKey ?? "default"}:${recallStart}:${promptHash}`)
@@ -985,41 +1002,48 @@ export class RecallInternalCoordinator {
985
1002
  });
986
1003
  return null;
987
1004
  }
988
- const t0 = Date.now();
989
- const transcriptEntries = sessionKey
990
- ? await readRecentEntityTranscriptEntries(
991
- this.deps.transcript.readRecent(
992
- entityRecentTranscriptLookbackHours,
993
- sessionKey,
994
- ),
1005
+ return await runBoundedCoreSection(
1006
+ "entityRetrieval",
1007
+ null as string | null,
1008
+ async (sectionSignal) => {
1009
+ // The transcript store is shared and takes no signal of its own, so the
1010
+ // section stops WAITING on a stalled read rather than pretending to
1011
+ // interrupt it — otherwise a cancelled section sits in this await.
1012
+ const transcriptEntries = sessionKey
1013
+ ? await raceAbort(
1014
+ readRecentEntityTranscriptEntries(
1015
+ this.deps.transcript.readRecent(
1016
+ entityRecentTranscriptLookbackHours,
1017
+ sessionKey,
1018
+ ),
1019
+ recentTurns,
1020
+ ),
1021
+ sectionSignal,
1022
+ "entity recall aborted",
1023
+ )
1024
+ : [];
1025
+ return await buildEntityRecallSection({
1026
+ config: this.deps.config,
1027
+ storage: profileStorage,
1028
+ namespaceStorage: (namespace) => this.deps.getStorage(namespace),
1029
+ query: retrievalQuery,
1030
+ recallNamespaces,
995
1031
  recentTurns,
996
- )
997
- : [];
998
- const section = await buildEntityRecallSection({
999
- config: this.deps.config,
1000
- storage: profileStorage,
1001
- namespaceStorage: (namespace) => this.deps.getStorage(namespace),
1002
- query: retrievalQuery,
1003
- recallNamespaces,
1004
- recentTurns,
1005
- maxHints,
1006
- maxSupportingFacts,
1007
- maxRelatedEntities,
1008
- maxChars,
1009
- transcriptEntries,
1010
- }).catch((err) => {
1011
- log.warn(`entity retrieval build failed: ${err}`);
1012
- return null;
1013
- });
1014
- recordRecallSectionMetric({
1015
- section: "entityRetrieval",
1016
- priority: "core",
1017
- durationMs: Date.now() - t0,
1018
- deadlineMs: recallSectionDeadlineMs,
1019
- source: "fresh",
1020
- success: true,
1021
- });
1022
- return section;
1032
+ maxHints,
1033
+ maxSupportingFacts,
1034
+ maxRelatedEntities,
1035
+ maxChars,
1036
+ transcriptEntries,
1037
+ abortSignal: sectionSignal,
1038
+ }).catch((err) => {
1039
+ // Cancellation is the deadline/abort contract working; let the bounded
1040
+ // runner record it as cancelled rather than as an empty section.
1041
+ if (isAbortError(err)) throw err;
1042
+ log.warn(`entity retrieval build failed: ${err}`);
1043
+ return null;
1044
+ });
1045
+ },
1046
+ );
1023
1047
  })();
1024
1048
 
1025
1049
  // 1b. Knowledge Index (v7.0)
@@ -1130,7 +1154,6 @@ export class RecallInternalCoordinator {
1130
1154
  )
1131
1155
  return [];
1132
1156
  if (!resolvePresentationCapabilities(this.deps.config).verbatimArtifacts) return [];
1133
- const t0 = Date.now();
1134
1157
  const targetCount = computeArtifactRecallLimit(
1135
1158
  recallMode,
1136
1159
  recallResultLimit,
@@ -1148,25 +1171,23 @@ export class RecallInternalCoordinator {
1148
1171
  });
1149
1172
  return [];
1150
1173
  }
1151
- const results = await this.deps.recallArtifactsAcrossNamespaces(
1152
- retrievalQuery,
1153
- recallNamespaces,
1154
- targetCount,
1174
+ return await runBoundedCoreSection(
1175
+ "artifacts",
1176
+ [] as MemoryFile[],
1177
+ async (sectionSignal) => {
1178
+ const results = await this.deps.recallArtifactsAcrossNamespaces(
1179
+ retrievalQuery,
1180
+ recallNamespaces,
1181
+ targetCount,
1182
+ { abortSignal: sectionSignal },
1183
+ );
1184
+ return lifecycleCaps.extractionScopeClassification
1185
+ ? results.filter((artifact) =>
1186
+ canRecallToolScopedMemory(artifact.frontmatter, options.sourceConnector),
1187
+ )
1188
+ : results;
1189
+ },
1155
1190
  );
1156
-
1157
- recordRecallSectionMetric({
1158
- section: "artifacts",
1159
- priority: "core",
1160
- durationMs: Date.now() - t0,
1161
- deadlineMs: recallSectionDeadlineMs,
1162
- source: "fresh",
1163
- success: true,
1164
- });
1165
- return lifecycleCaps.extractionScopeClassification
1166
- ? results.filter((artifact) =>
1167
- canRecallToolScopedMemory(artifact.frontmatter, options.sourceConnector),
1168
- )
1169
- : results;
1170
1191
  })();
1171
1192
 
1172
1193
  const objectiveStatePromise = (async (): Promise<string | null> => {
@@ -40,6 +40,7 @@ import {
40
40
  fetchActiveArtifactsForNamespace as fetchActiveArtifactsForNamespaceHelper,
41
41
  searchEmbeddingFallback as searchEmbeddingFallbackHelper,
42
42
  searchQueryAwareFallback as searchQueryAwareFallbackHelper,
43
+ type ArtifactRecallOptions,
43
44
  } from "./recall-search-prefilter.js";
44
45
  import { shouldFilterSupersededFromRecall } from "../temporal-supersession.js";
45
46
  import { isValidAsOf, isValidityExpiredNow } from "../temporal-validity.js";
@@ -251,8 +252,15 @@ export class RecallSearchPipelineCoordinator {
251
252
  namespace: string,
252
253
  prompt: string,
253
254
  targetCount: number,
255
+ options: ArtifactRecallOptions = {},
254
256
  ): Promise<MemoryFile[]> {
255
- return fetchActiveArtifactsForNamespaceHelper(this.deps, namespace, prompt, targetCount);
257
+ return fetchActiveArtifactsForNamespaceHelper(
258
+ this.deps,
259
+ namespace,
260
+ prompt,
261
+ targetCount,
262
+ options,
263
+ );
256
264
  }
257
265
 
258
266
  async buildQueryAwarePrefilter(
@@ -42,11 +42,22 @@ export interface PrefilterAndArtifactDeps {
42
42
  ): Promise<QmdSearchResult[]>;
43
43
  }
44
44
 
45
+ /**
46
+ * Cancellation for the artifact recall path (issue #2291). Artifact recall
47
+ * re-scans and re-tokenizes every stored artifact, up to `MAX_ATTEMPTS` times
48
+ * with a growing fetch limit; on a large or slow memory tree that is minutes of
49
+ * work that used to keep running after the caller had already given up.
50
+ */
51
+ export interface ArtifactRecallOptions {
52
+ abortSignal?: AbortSignal;
53
+ }
54
+
45
55
  export async function fetchActiveArtifactsForNamespace(
46
56
  deps: Pick<PrefilterAndArtifactDeps, "storageRouter" | "resolveArtifactSourceStatuses">,
47
57
  namespace: string,
48
58
  prompt: string,
49
59
  targetCount: number,
60
+ options: ArtifactRecallOptions = {},
50
61
  ): Promise<MemoryFile[]> {
51
62
  const storage = await deps.storageRouter.storageFor(namespace);
52
63
  let fetchLimit = computeArtifactCandidateFetchLimit(targetCount);
@@ -55,7 +66,10 @@ export async function fetchActiveArtifactsForNamespace(
55
66
  let bestFiltered: MemoryFile[] = [];
56
67
 
57
68
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
58
- const rawResults = await storage.searchArtifacts(prompt, fetchLimit);
69
+ throwIfRecallAborted(options.abortSignal);
70
+ const rawResults = await storage.searchArtifacts(prompt, fetchLimit, {
71
+ abortSignal: options.abortSignal,
72
+ });
59
73
  const sourceIds = Array.from(
60
74
  new Set(
61
75
  rawResults
@@ -65,6 +79,9 @@ export async function fetchActiveArtifactsForNamespace(
65
79
  ),
66
80
  ),
67
81
  );
82
+ // The status snapshot rebuild reads every memory in the namespace; do not
83
+ // start one for a recall that has already been abandoned.
84
+ throwIfRecallAborted(options.abortSignal);
68
85
  const sourceStatus =
69
86
  sourceIds.length > 0
70
87
  ? await deps.resolveArtifactSourceStatuses(storage, sourceIds)