@remnic/core 9.3.749 → 9.3.751

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 (271) hide show
  1. package/dist/access-boundary.d.ts +8 -8
  2. package/dist/access-boundary.js +15 -15
  3. package/dist/access-cli.js +39 -39
  4. package/dist/access-http.d.ts +8 -8
  5. package/dist/access-http.js +20 -20
  6. package/dist/access-mcp.d.ts +8 -8
  7. package/dist/access-mcp.js +19 -19
  8. package/dist/access-operations-batch.js +16 -16
  9. package/dist/access-operations.d.ts +8 -8
  10. package/dist/access-operations.js +18 -18
  11. package/dist/access-schema.d.ts +2 -2
  12. package/dist/access-schema.js +5 -5
  13. package/dist/{access-service-5-EVpt3v.d.ts → access-service-DmAEOJdJ.d.ts} +11 -3
  14. package/dist/access-service.d.ts +5 -5
  15. package/dist/access-service.js +14 -14
  16. package/dist/access-surface-catalog.d.ts +8 -8
  17. package/dist/action-confidence.d.ts +1 -1
  18. package/dist/active-memory-bridge.d.ts +1 -1
  19. package/dist/active-recall.d.ts +1 -1
  20. package/dist/active-recall.js +8 -1
  21. package/dist/active-recall.js.map +1 -1
  22. package/dist/behavior-learner.d.ts +1 -1
  23. package/dist/behavior-signals.d.ts +1 -1
  24. package/dist/bootstrap.d.ts +7 -7
  25. package/dist/briefing.d.ts +1 -1
  26. package/dist/briefing.js +7 -7
  27. package/dist/buffer-surprise-report.d.ts +1 -1
  28. package/dist/buffer.d.ts +1 -1
  29. package/dist/calibration.d.ts +1 -1
  30. package/dist/capabilities.d.ts +1 -1
  31. package/dist/{catalog-D_ZDxu8Y.d.ts → catalog-BpOcwHdE.d.ts} +1 -1
  32. package/dist/causal-behavior.d.ts +1 -1
  33. package/dist/causal-consolidation.d.ts +1 -1
  34. package/dist/causal-consolidation.js +8 -8
  35. package/dist/causal-trajectory-graph.d.ts +1 -1
  36. package/dist/{chunk-C5WN74HH.js → chunk-2HAG7JOI.js} +8 -2
  37. package/dist/chunk-2HAG7JOI.js.map +1 -0
  38. package/dist/{chunk-GKRLPHPU.js → chunk-2KK5WNQB.js} +2 -2
  39. package/dist/{chunk-JRDPT6DA.js → chunk-4NWIGAIC.js} +2 -2
  40. package/dist/{chunk-OBJUKYDO.js → chunk-4XL3CVII.js} +2 -2
  41. package/dist/{chunk-WWVXXSLF.js → chunk-5PZCDUJ6.js} +4 -4
  42. package/dist/{chunk-URQLO7BX.js → chunk-6FNZ3NMS.js} +2 -2
  43. package/dist/{chunk-DRUDC2UX.js → chunk-6MMONPHM.js} +2 -2
  44. package/dist/{chunk-RB6A7OUN.js → chunk-6SXNQFHB.js} +2 -2
  45. package/dist/{chunk-ZGZNLRKF.js → chunk-7TWA7DKP.js} +5 -5
  46. package/dist/{chunk-KMNYXLPL.js → chunk-B35L45HZ.js} +2 -2
  47. package/dist/{chunk-SIV3JZPP.js → chunk-CGZWGVQF.js} +5 -5
  48. package/dist/{chunk-CKFLLAIS.js → chunk-DANV2HTH.js} +3 -3
  49. package/dist/{chunk-NVYF5OB6.js → chunk-DBRVU5PR.js} +3 -3
  50. package/dist/{chunk-LMFS64GY.js → chunk-E4PFDD3N.js} +4 -4
  51. package/dist/{chunk-DIHCFJVV.js → chunk-HYI2GP3F.js} +4 -4
  52. package/dist/{chunk-UIKZZ242.js → chunk-IKHKHADN.js} +2 -2
  53. package/dist/{chunk-PUR2CKX2.js → chunk-JS2HKKOK.js} +1 -1
  54. package/dist/{chunk-PUR2CKX2.js.map → chunk-JS2HKKOK.js.map} +1 -1
  55. package/dist/{chunk-R2OAN6IP.js → chunk-K2JM4DZZ.js} +2 -2
  56. package/dist/{chunk-3IJGF6K2.js → chunk-K2R3DEEH.js} +2 -2
  57. package/dist/{chunk-HIHCB2S6.js → chunk-QRRBXD24.js} +2 -2
  58. package/dist/{chunk-PNUOQYRJ.js → chunk-RWZB6MAY.js} +1953 -2172
  59. package/dist/chunk-RWZB6MAY.js.map +1 -0
  60. package/dist/{chunk-ZPZ4VVLT.js → chunk-SD34EK4Z.js} +2 -2
  61. package/dist/{chunk-WK3NK6SP.js → chunk-SNEXVX3U.js} +6 -6
  62. package/dist/{chunk-FVZ2ISIX.js → chunk-TPLE6SNY.js} +7 -7
  63. package/dist/{chunk-24YDB5QP.js → chunk-UETBEHBA.js} +44 -24
  64. package/dist/chunk-UETBEHBA.js.map +1 -0
  65. package/dist/{chunk-5IN26V6W.js → chunk-UU32EAIY.js} +5 -5
  66. package/dist/{chunk-ZGAJB74V.js → chunk-W3BFT5OJ.js} +2 -2
  67. package/dist/{chunk-L4N7SVNN.js → chunk-WA7FECSG.js} +11 -11
  68. package/dist/{chunk-Q3X7GUAQ.js → chunk-WOX2UW4K.js} +2 -2
  69. package/dist/{chunk-XTEWVF3C.js → chunk-WWSXVOGY.js} +2 -2
  70. package/dist/{chunk-PWWWLD7D.js → chunk-X64YLC24.js} +153 -27
  71. package/dist/chunk-X64YLC24.js.map +1 -0
  72. package/dist/{chunk-P4FW3ZU5.js → chunk-YGGXUNS4.js} +20 -20
  73. package/dist/{chunk-JHQUIH3W.js → chunk-ZBVQ4ZM4.js} +19 -19
  74. package/dist/{cli-DuWWjaKT.d.ts → cli-lmf5RGyQ.d.ts} +3 -3
  75. package/dist/cli.d.ts +9 -9
  76. package/dist/cli.js +33 -33
  77. package/dist/compounding/engine.d.ts +1 -1
  78. package/dist/compounding/engine.js +7 -7
  79. package/dist/compounding/preference-consolidator.d.ts +1 -1
  80. package/dist/compression-optimizer.d.ts +1 -1
  81. package/dist/config.d.ts +1 -1
  82. package/dist/config.js +8 -1
  83. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  84. package/dist/connectors/codex-materialize-runner.js +7 -7
  85. package/dist/connectors/codex-materialize.d.ts +1 -1
  86. package/dist/connectors/index.d.ts +1 -1
  87. package/dist/connectors/index.js +7 -7
  88. package/dist/consolidation-provenance-check.d.ts +1 -1
  89. package/dist/consolidation-undo.d.ts +1 -1
  90. package/dist/contradiction/index.d.ts +1 -1
  91. package/dist/conversation-index/backend.d.ts +1 -1
  92. package/dist/conversation-index/chunker.d.ts +1 -1
  93. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  94. package/dist/conversation-index/indexer.d.ts +1 -1
  95. package/dist/conversation-index/search.d.ts +1 -1
  96. package/dist/day-summary.d.ts +1 -1
  97. package/dist/delinearize.d.ts +1 -1
  98. package/dist/direct-answer-wiring.d.ts +1 -1
  99. package/dist/direct-answer.d.ts +1 -1
  100. package/dist/embedding-fallback.d.ts +1 -1
  101. package/dist/enrichment/index.d.ts +1 -1
  102. package/dist/entity-retrieval.d.ts +1 -1
  103. package/dist/entity-retrieval.js +7 -7
  104. package/dist/entity-schema.d.ts +1 -1
  105. package/dist/explicit-capture.d.ts +7 -7
  106. package/dist/extraction-faithfulness.d.ts +1 -1
  107. package/dist/extraction-judge-telemetry.d.ts +1 -1
  108. package/dist/extraction-judge-training.d.ts +1 -1
  109. package/dist/extraction-judge.d.ts +1 -1
  110. package/dist/extraction.d.ts +1 -1
  111. package/dist/fallback-llm.d.ts +1 -1
  112. package/dist/graph-dashboard-diff.d.ts +1 -1
  113. package/dist/graph-dashboard-key.d.ts +1 -1
  114. package/dist/graph-dashboard-parser.d.ts +1 -1
  115. package/dist/graph-edge-reinforcement.d.ts +1 -1
  116. package/dist/graph-snapshot.d.ts +1 -1
  117. package/dist/graph.d.ts +1 -1
  118. package/dist/identity-continuity.d.ts +1 -1
  119. package/dist/importance.d.ts +1 -1
  120. package/dist/index.d.ts +10 -10
  121. package/dist/index.js +76 -72
  122. package/dist/intent.d.ts +1 -1
  123. package/dist/lcm/engine.d.ts +1 -1
  124. package/dist/lcm/index.d.ts +1 -1
  125. package/dist/lcm/tools.d.ts +1 -1
  126. package/dist/lifecycle.d.ts +1 -1
  127. package/dist/live-connectors-runner.d.ts +1 -1
  128. package/dist/local-llm.d.ts +1 -1
  129. package/dist/local-model-endpoint.d.ts +1 -1
  130. package/dist/maintenance/memory-governance.d.ts +1 -1
  131. package/dist/maintenance/memory-governance.js +7 -7
  132. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  133. package/dist/maintenance/rebuild-memory-projection.js +8 -8
  134. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  135. package/dist/memory-action-policy.d.ts +1 -1
  136. package/dist/memory-cache.d.ts +1 -1
  137. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  138. package/dist/memory-projection-store.d.ts +1 -1
  139. package/dist/memory-provenance.d.ts +1 -1
  140. package/dist/memory-worth-outcomes.d.ts +1 -1
  141. package/dist/models-json.d.ts +1 -1
  142. package/dist/namespaces/migrate.d.ts +2 -2
  143. package/dist/namespaces/migrate.js +8 -8
  144. package/dist/namespaces/principal.d.ts +1 -1
  145. package/dist/namespaces/search.d.ts +1 -1
  146. package/dist/namespaces/search.js +1 -1
  147. package/dist/namespaces/storage.d.ts +2 -2
  148. package/dist/namespaces/storage.js +7 -7
  149. package/dist/native-knowledge.d.ts +1 -1
  150. package/dist/offline-sync.d.ts +108 -1
  151. package/dist/offline-sync.js +7 -1
  152. package/dist/operator-toolkit.d.ts +1 -1
  153. package/dist/operator-toolkit.js +15 -12
  154. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  155. package/dist/orchestration/maintenance.d.ts +2 -2
  156. package/dist/orchestration/maintenance.js +9 -9
  157. package/dist/{orchestrator-CZI6hO_R.d.ts → orchestrator-D7bb8ZGp.d.ts} +193 -201
  158. package/dist/orchestrator.d.ts +5 -5
  159. package/dist/orchestrator.js +51 -41
  160. package/dist/patterns-cli.d.ts +1 -1
  161. package/dist/policy-runtime.d.ts +1 -1
  162. package/dist/provenance.d.ts +1 -1
  163. package/dist/qmd-recall-cache.d.ts +1 -1
  164. package/dist/qmd.d.ts +1 -1
  165. package/dist/recall-disclosure-escalation.d.ts +1 -1
  166. package/dist/recall-explain-renderer.d.ts +1 -1
  167. package/dist/recall-explain-renderer.js +3 -3
  168. package/dist/recall-planner-llm.d.ts +1 -1
  169. package/dist/recall-state.d.ts +1 -1
  170. package/dist/recall-tag-filter.d.ts +1 -1
  171. package/dist/recall-xray-cli.d.ts +1 -1
  172. package/dist/recall-xray-cli.js +4 -4
  173. package/dist/recall-xray-renderer.d.ts +1 -1
  174. package/dist/recall-xray-renderer.js +3 -3
  175. package/dist/recall-xray.d.ts +1 -1
  176. package/dist/recall-xray.js +2 -2
  177. package/dist/resolve-auth-token.d.ts +1 -1
  178. package/dist/resume-bundles.js +9 -2
  179. package/dist/retrieval-agents.d.ts +1 -1
  180. package/dist/retrieval-tiers.d.ts +1 -1
  181. package/dist/routing/engine.d.ts +1 -1
  182. package/dist/routing/store.d.ts +1 -1
  183. package/dist/schemas.d.ts +22 -22
  184. package/dist/search/embed-helper.d.ts +1 -1
  185. package/dist/search/factory.d.ts +1 -1
  186. package/dist/search/factory.js +1 -1
  187. package/dist/search/index.d.ts +1 -1
  188. package/dist/search/index.js +1 -1
  189. package/dist/search/lancedb-backend.d.ts +1 -1
  190. package/dist/search/lancedb-backend.js +1 -1
  191. package/dist/search/meilisearch-backend.d.ts +1 -1
  192. package/dist/search/meilisearch-backend.js +1 -1
  193. package/dist/search/noop-backend.d.ts +1 -1
  194. package/dist/search/orama-backend.d.ts +1 -1
  195. package/dist/search/orama-backend.js +1 -1
  196. package/dist/search/port.d.ts +1 -1
  197. package/dist/search/remote-backend.d.ts +1 -1
  198. package/dist/{semantic-consolidation-D3aSiu9t.d.ts → semantic-consolidation-CTfbVwCA.d.ts} +1 -1
  199. package/dist/semantic-consolidation.d.ts +2 -2
  200. package/dist/semantic-consolidation.js +8 -8
  201. package/dist/semantic-rule-promotion.js +7 -7
  202. package/dist/semantic-rule-verifier.d.ts +1 -1
  203. package/dist/semantic-rule-verifier.js +7 -7
  204. package/dist/session-observer-bands.d.ts +1 -1
  205. package/dist/session-observer-state.d.ts +1 -1
  206. package/dist/shared-context/manager.d.ts +1 -1
  207. package/dist/signal.d.ts +1 -1
  208. package/dist/storage.d.ts +1 -1
  209. package/dist/storage.js +6 -6
  210. package/dist/summarizer.d.ts +1 -1
  211. package/dist/summary-snapshot.d.ts +1 -1
  212. package/dist/temporal-supersession.d.ts +1 -1
  213. package/dist/temporal-validity.d.ts +1 -1
  214. package/dist/threading.d.ts +1 -1
  215. package/dist/tier-migration.d.ts +1 -1
  216. package/dist/tier-routing.d.ts +1 -1
  217. package/dist/topics.d.ts +1 -1
  218. package/dist/transcript.d.ts +1 -1
  219. package/dist/transfer/types.d.ts +12 -12
  220. package/dist/trust-score-stage.d.ts +1 -1
  221. package/dist/trust-score.d.ts +1 -1
  222. package/dist/{types-CZm6rx9-.d.ts → types-D4NYDtXI.d.ts} +7 -0
  223. package/dist/types.d.ts +1 -1
  224. package/dist/types.js +1 -1
  225. package/dist/utility-runtime.d.ts +1 -1
  226. package/dist/verified-recall.js +7 -7
  227. package/package.json +2 -2
  228. package/src/access-service.ts +22 -0
  229. package/src/config.ts +5 -0
  230. package/src/index.ts +3 -1
  231. package/src/offline-sync.test.ts +354 -52
  232. package/src/offline-sync.ts +332 -24
  233. package/src/orchestration/extraction-run.ts +4 -34
  234. package/src/orchestration/namespace-read-fanout.ts +484 -0
  235. package/src/orchestration/orchestrator-helpers.ts +1130 -0
  236. package/src/orchestration/self-deps.ts +50 -0
  237. package/src/orchestration/workspace-ops.ts +789 -0
  238. package/src/orchestrator.ts +177 -2296
  239. package/src/types.ts +9 -0
  240. package/dist/chunk-24YDB5QP.js.map +0 -1
  241. package/dist/chunk-C5WN74HH.js.map +0 -1
  242. package/dist/chunk-PNUOQYRJ.js.map +0 -1
  243. package/dist/chunk-PWWWLD7D.js.map +0 -1
  244. /package/dist/{chunk-GKRLPHPU.js.map → chunk-2KK5WNQB.js.map} +0 -0
  245. /package/dist/{chunk-JRDPT6DA.js.map → chunk-4NWIGAIC.js.map} +0 -0
  246. /package/dist/{chunk-OBJUKYDO.js.map → chunk-4XL3CVII.js.map} +0 -0
  247. /package/dist/{chunk-WWVXXSLF.js.map → chunk-5PZCDUJ6.js.map} +0 -0
  248. /package/dist/{chunk-URQLO7BX.js.map → chunk-6FNZ3NMS.js.map} +0 -0
  249. /package/dist/{chunk-DRUDC2UX.js.map → chunk-6MMONPHM.js.map} +0 -0
  250. /package/dist/{chunk-RB6A7OUN.js.map → chunk-6SXNQFHB.js.map} +0 -0
  251. /package/dist/{chunk-ZGZNLRKF.js.map → chunk-7TWA7DKP.js.map} +0 -0
  252. /package/dist/{chunk-KMNYXLPL.js.map → chunk-B35L45HZ.js.map} +0 -0
  253. /package/dist/{chunk-SIV3JZPP.js.map → chunk-CGZWGVQF.js.map} +0 -0
  254. /package/dist/{chunk-CKFLLAIS.js.map → chunk-DANV2HTH.js.map} +0 -0
  255. /package/dist/{chunk-NVYF5OB6.js.map → chunk-DBRVU5PR.js.map} +0 -0
  256. /package/dist/{chunk-LMFS64GY.js.map → chunk-E4PFDD3N.js.map} +0 -0
  257. /package/dist/{chunk-DIHCFJVV.js.map → chunk-HYI2GP3F.js.map} +0 -0
  258. /package/dist/{chunk-UIKZZ242.js.map → chunk-IKHKHADN.js.map} +0 -0
  259. /package/dist/{chunk-R2OAN6IP.js.map → chunk-K2JM4DZZ.js.map} +0 -0
  260. /package/dist/{chunk-3IJGF6K2.js.map → chunk-K2R3DEEH.js.map} +0 -0
  261. /package/dist/{chunk-HIHCB2S6.js.map → chunk-QRRBXD24.js.map} +0 -0
  262. /package/dist/{chunk-ZPZ4VVLT.js.map → chunk-SD34EK4Z.js.map} +0 -0
  263. /package/dist/{chunk-WK3NK6SP.js.map → chunk-SNEXVX3U.js.map} +0 -0
  264. /package/dist/{chunk-FVZ2ISIX.js.map → chunk-TPLE6SNY.js.map} +0 -0
  265. /package/dist/{chunk-5IN26V6W.js.map → chunk-UU32EAIY.js.map} +0 -0
  266. /package/dist/{chunk-ZGAJB74V.js.map → chunk-W3BFT5OJ.js.map} +0 -0
  267. /package/dist/{chunk-L4N7SVNN.js.map → chunk-WA7FECSG.js.map} +0 -0
  268. /package/dist/{chunk-Q3X7GUAQ.js.map → chunk-WOX2UW4K.js.map} +0 -0
  269. /package/dist/{chunk-XTEWVF3C.js.map → chunk-WWSXVOGY.js.map} +0 -0
  270. /package/dist/{chunk-P4FW3ZU5.js.map → chunk-YGGXUNS4.js.map} +0 -0
  271. /package/dist/{chunk-JHQUIH3W.js.map → chunk-ZBVQ4ZM4.js.map} +0 -0
@@ -0,0 +1,1130 @@
1
+ /**
2
+ * Orchestrator module-level helpers — relocated from orchestrator.ts
3
+ * (issue #1526, seam 25).
4
+ *
5
+ * Pure functions, types, and constants that previously lived at module
6
+ * level in orchestrator.ts: recall-mode planning, recall snapshots and
7
+ * parsers, abort/race helpers, artifact recall limits, replay
8
+ * source-time slicing, day-summary date utilities, and QMD startup
9
+ * checks. orchestrator.ts re-exports the previously-public names so
10
+ * every existing importer (coordinators, tests, root shims) keeps
11
+ * working unchanged.
12
+ *
13
+ * Behavior-preserving move — no logic changes.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { abortError as sharedAbortError, throwIfAborted as sharedThrowIfAborted } from "../abort-error.js";
20
+ import type { CapabilitySet } from "../capabilities.js";
21
+ import { buildCompressionGuidelinesMarkdown as buildCompressionGuidelinesMarkdownV2 } from "../compression-optimizer.js";
22
+ import { FallbackLlmClient } from "../fallback-llm.js";
23
+ import { hasBroadGraphIntent, planRecallMode } from "../intent.js";
24
+ import { resolveLifecycleState } from "../lifecycle.js";
25
+ import { log } from "../logger.js";
26
+ import type { GraphRecallRankedResult, GraphRecallShadowComparison } from "./graph-recall-coordinator.js";
27
+ import { parseQmdExplain } from "../qmd.js";
28
+ import type { GraphRecallExpandedEntry } from "../recall-state.js";
29
+ import type { RecallXrayServedBy } from "../recall-xray.js";
30
+ import { type BufferTurn, type MemoryActionEvent, type MemoryFile, type MemoryFrontmatter, type MemoryIntent, type PluginConfig, type QmdSearchResult, type RecallPlanMode, confidenceTier } from "../types.js";
31
+ import { categoryDirName } from "../utils/category-dir.js";
32
+ import { parseFlexibleIsoTimestamp } from "../utils/iso-timestamp.js";
33
+
34
+ export interface BulkImportBatchIngestResult {
35
+ attemptedTurnCount: number;
36
+ extractionCount: number;
37
+ persistedCount: number;
38
+ durableOutputCount: number;
39
+ skippedCount: number;
40
+ failedCount: number;
41
+ postPersistMetadataFailureCount: number;
42
+ processedTurnCount: number;
43
+ }
44
+
45
+ export class BulkImportBatchPartialFailureError extends Error {
46
+ readonly partialResult: BulkImportBatchIngestResult;
47
+
48
+ readonly originalError: unknown;
49
+
50
+ constructor(
51
+ message: string,
52
+ partialResult: BulkImportBatchIngestResult,
53
+ originalError: unknown,
54
+ ) {
55
+ super(message);
56
+ this.name = "BulkImportBatchPartialFailureError";
57
+ this.partialResult = partialResult;
58
+ this.originalError = originalError;
59
+ }
60
+ }
61
+
62
+ export interface GraphRecallSnapshot {
63
+ recordedAt: string;
64
+ mode: RecallPlanMode | string;
65
+ queryHash: string;
66
+ queryLength: number;
67
+ namespaces: string[];
68
+ seedCount: number;
69
+ expandedCount: number;
70
+ seeds: string[];
71
+ expanded: GraphRecallExpandedEntry[];
72
+ status?: "completed" | "skipped" | "aborted";
73
+ reason?: string;
74
+ shadowMode?: boolean;
75
+ queryIntent?: MemoryIntent;
76
+ seedResults?: GraphRecallRankedResult[];
77
+ finalResults?: GraphRecallRankedResult[];
78
+ shadowComparison?: GraphRecallShadowComparison;
79
+ }
80
+
81
+ export interface IntentDebugSnapshot {
82
+ recordedAt: string;
83
+ promptHash: string;
84
+ promptLength: number;
85
+ retrievalQueryHash: string;
86
+ retrievalQueryLength: number;
87
+ plannerEnabled: boolean;
88
+ plannedMode: RecallPlanMode;
89
+ effectiveMode: RecallPlanMode;
90
+ recallResultLimit: number;
91
+ queryIntent: MemoryIntent;
92
+ graphExpandedIntentDetected: boolean;
93
+ graphDecision: {
94
+ status: "not_requested" | "skipped" | "completed" | "aborted";
95
+ reason?: string;
96
+ shadowMode: boolean;
97
+ qmdAvailable: boolean;
98
+ graphRecallEnabled: boolean;
99
+ multiGraphMemoryEnabled: boolean;
100
+ };
101
+ }
102
+
103
+ export interface QmdRecallSnapshot {
104
+ recordedAt: string;
105
+ queryHash: string;
106
+ queryLength: number;
107
+ collection?: string;
108
+ namespaces: string[];
109
+ fetchLimit: number;
110
+ primaryResultCount: number;
111
+ hybridResultCount: number;
112
+ queryAwareSeedCount: number;
113
+ resultCount: number;
114
+ intentHint?: string;
115
+ explainEnabled: boolean;
116
+ hybridTopUpUsed: boolean;
117
+ hybridTopUpSkippedReason?: string;
118
+ results: QmdSearchResult[];
119
+ }
120
+
121
+ export interface RecallModeDecision {
122
+ plannedMode: RecallPlanMode;
123
+ effectiveMode: RecallPlanMode;
124
+ graphExpandedIntentDetected: boolean;
125
+ graphReason?: string;
126
+ /**
127
+ * Where `plannedMode` came from (issue #1367 / Option C). `"heuristic"` for
128
+ * the regex planner; `"llm"` when the LLM planner classified it; and
129
+ * `"heuristic-fallback"` when the LLM was enabled but errored/timed out and we
130
+ * fell back. Absent on the synchronous heuristic-only path.
131
+ */
132
+ plannerSource?: "heuristic" | "llm" | "heuristic-fallback";
133
+ /** Short rationale from the planner (for telemetry / x-ray). */
134
+ plannerReason?: string;
135
+ /** Wall-clock spent in the LLM planner call, when one was made. */
136
+ plannerLatencyMs?: number;
137
+ /** True when the LLM planner was enabled but fell back to the heuristic. */
138
+ plannerFallbackUsed?: boolean;
139
+ /** Model that served the LLM classification, when one was used. */
140
+ plannerModelUsed?: string;
141
+ /**
142
+ * The regex-heuristic baseline mode, captured whenever the LLM planner ran
143
+ * (any source). Lets operators compare planned-vs-heuristic during rollout —
144
+ * distinct from `plannedMode`, which on the LLM path is the LLM's choice.
145
+ */
146
+ plannerHeuristicMode?: RecallPlanMode;
147
+ /**
148
+ * In shadow mode, the mode the LLM *would* have chosen (recorded for
149
+ * comparison) while `effectiveMode` stays on the heuristic decision.
150
+ */
151
+ shadowLlmMode?: RecallPlanMode;
152
+ }
153
+
154
+ /**
155
+ * Map the orchestrator's internal `recallSource` strings to the
156
+ * X-ray `servedBy` vocabulary (issue #570 PR 1). The X-ray tier
157
+ * ladder intentionally flattens QMD / embedding / cold-fallback to
158
+ * the `hybrid` tier because they all materialize through the same
159
+ * hybrid BM25+vector pipeline from the caller's perspective. The
160
+ * `recent_scan` path gets its own dedicated tier because it bypasses
161
+ * the hybrid pipeline entirely. `none` is treated as `hybrid` on the
162
+ * theory that a query that returned nothing still routed through the
163
+ * hybrid pipeline — but callers should normally gate capture on
164
+ * `recalledMemoryIds.length > 0`.
165
+ */
166
+ export function mapRecallSourceToXrayServedBy(
167
+ source:
168
+ | "none"
169
+ | "hot_qmd"
170
+ | "hot_embedding"
171
+ | "cold_fallback"
172
+ | "recent_scan",
173
+ ): RecallXrayServedBy {
174
+ // Exhaustive switch: every current union member is explicitly
175
+ // listed so TypeScript surfaces a compile error if a new source is
176
+ // added without a deliberate mapping. The `never`-typed fallthrough
177
+ // keeps the function total at runtime — if the caller passes an
178
+ // unexpected value that slipped past the type system (e.g. a JSON
179
+ // deserialization), we still fall back to `hybrid`.
180
+ switch (source) {
181
+ case "recent_scan":
182
+ return "recent-scan";
183
+ case "hot_qmd":
184
+ case "hot_embedding":
185
+ case "cold_fallback":
186
+ case "none":
187
+ return "hybrid";
188
+ }
189
+ const _exhaustive: never = source;
190
+ void _exhaustive;
191
+ return "hybrid";
192
+ }
193
+
194
+ export interface RecallInvocationOptions {
195
+ namespace?: string;
196
+ topK?: number;
197
+ mode?: RecallPlanMode;
198
+ abortSignal?: AbortSignal;
199
+ /**
200
+ * Capture a `RecallXraySnapshot` for this recall (issue #570). When
201
+ * `true`, the orchestrator builds a snapshot from the data it has
202
+ * already gathered and stashes it in memory, accessible via
203
+ * `getLastXraySnapshot()`. When `false` or absent, nothing is
204
+ * captured and recall behavior is unchanged (schema-only slice).
205
+ */
206
+ xrayCapture?: boolean;
207
+ /**
208
+ * Per-invocation override for `recallBudgetChars` (issue #570 PR 3/4).
209
+ * Flows through `getRecallBudgetChars()` for this recall only — no
210
+ * shared config mutation, so concurrent recalls on the same
211
+ * orchestrator are not affected (CLAUDE.md rule 47: no shared
212
+ * mutable state across async boundaries). Must be a non-negative
213
+ * finite integer; non-conforming values are ignored and the
214
+ * configured budget is used.
215
+ */
216
+ budgetCharsOverride?: number;
217
+ /**
218
+ * Per-invocation principal override (issue #570 PR 4). When set,
219
+ * the orchestrator uses this principal for ACL / namespace checks
220
+ * instead of `resolvePrincipal(sessionKey, config)`. This is the
221
+ * escape hatch for access surfaces (HTTP / MCP) that have already
222
+ * authenticated the caller upstream — threading an unmapped
223
+ * principal through the session-key-based resolver would otherwise
224
+ * collapse it to `"default"` and produce false denials in
225
+ * namespace-enabled deployments (CLAUDE.md rule 42).
226
+ */
227
+ principalOverride?: string;
228
+ /**
229
+ * Historical recall point (issue #680). When set, the orchestrator
230
+ * filters out memories whose `valid_at` is after this timestamp OR
231
+ * whose `invalid_at` is at-or-before this timestamp, so callers see
232
+ * the corpus as it existed at `asOf`. ISO 8601 string; comparisons
233
+ * use `Date.parse()` so timezone-aware values round-trip correctly
234
+ * (CLAUDE.md gotcha — never compare ISO strings lexicographically).
235
+ * Invalid values must be rejected at input boundaries (CLAUDE.md
236
+ * rule 51); the orchestrator does NOT silently fall back here.
237
+ */
238
+ asOf?: string;
239
+ /**
240
+ * Issue #681 — when `true`, bypasses `graphTraversalConfidenceFloor`
241
+ * and includes edges below the floor in graph traversal. Useful for
242
+ * diagnostic recall queries that need to surface results that would
243
+ * normally be pruned by confidence decay. Default `false`.
244
+ */
245
+ includeLowConfidence?: boolean;
246
+ /**
247
+ * User-aware context scopes active for this recall. Used by X-ray
248
+ * provenance safety checks so boundary-scoped memories are evaluated
249
+ * against the caller's real context.
250
+ */
251
+ currentContextScopes?: readonly unknown[];
252
+ }
253
+
254
+ export type QueryAwarePrefilter = {
255
+ candidatePaths: Set<string> | null;
256
+ temporalFromDate: string | null;
257
+ matchedTags: string[];
258
+ expandedTags: string[];
259
+ combination: "none" | "temporal" | "tag" | "intersection" | "union";
260
+ filteredToFullSearch: boolean;
261
+ };
262
+
263
+ // Recall-specific abort helpers. Thin wrappers over the shared
264
+ // `abort-error.ts` module so every abort in the codebase shares the
265
+ // same `name === "AbortError"` classification contract (`isAbortError`
266
+ // works uniformly). We keep the "recall aborted" default message for
267
+ // back-compat with call-site logs; callers that pass an explicit
268
+ // message (e.g. "extraction aborted (before_extract)") are unaffected.
269
+ export const abortRecallError = sharedAbortError;
270
+
271
+ export function throwIfRecallAborted(
272
+ signal?: AbortSignal,
273
+ message = "recall aborted",
274
+ ): void {
275
+ sharedThrowIfAborted(signal, message);
276
+ }
277
+
278
+ export async function raceRecallAbort<T>(
279
+ promise: Promise<T>,
280
+ signal?: AbortSignal,
281
+ message = "recall aborted",
282
+ ): Promise<T> {
283
+ throwIfRecallAborted(signal, message);
284
+ if (!signal) return promise;
285
+
286
+ let onAbort: (() => void) | null = null;
287
+ const abortPromise = new Promise<T>((_resolve, reject) => {
288
+ onAbort = () => reject(abortRecallError(message));
289
+ signal.addEventListener("abort", onAbort, { once: true });
290
+ });
291
+
292
+ try {
293
+ return await Promise.race([promise, abortPromise]);
294
+ } finally {
295
+ if (onAbort) {
296
+ signal.removeEventListener("abort", onAbort);
297
+ }
298
+ }
299
+ }
300
+
301
+ /** Maximum age (ms) before a compaction-reset signal file is considered stale and removed. */
302
+ export const COMPACTION_SIGNAL_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
303
+
304
+ export const DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS = 10_000;
305
+
306
+ export type DaySummaryGatherOptions = {
307
+ timeZone?: string;
308
+ now?: Date;
309
+ };
310
+
311
+ export function normalizeIanaTimeZone(value: unknown): string | undefined {
312
+ if (typeof value !== "string") return undefined;
313
+ const trimmed = value.trim();
314
+ if (!trimmed) return undefined;
315
+ try {
316
+ Intl.DateTimeFormat(undefined, { timeZone: trimmed });
317
+ return trimmed;
318
+ } catch {
319
+ return undefined;
320
+ }
321
+ }
322
+
323
+ export function formatDateInTimeZone(date: Date, timeZone: string): string {
324
+ const parts = new Intl.DateTimeFormat("en-US", {
325
+ timeZone,
326
+ year: "numeric",
327
+ month: "2-digit",
328
+ day: "2-digit",
329
+ }).formatToParts(date);
330
+ const get = (type: string): string => parts.find((part) => part.type === type)?.value ?? "";
331
+ return `${get("year")}-${get("month")}-${get("day")}`;
332
+ }
333
+
334
+ export function utcDateKey(date: Date): string {
335
+ return date.toISOString().slice(0, 10);
336
+ }
337
+
338
+ export function utcDateKeysAround(date: Date): string[] {
339
+ const dayMs = 86_400_000;
340
+ const keys = [
341
+ utcDateKey(new Date(date.getTime() - dayMs)),
342
+ utcDateKey(date),
343
+ utcDateKey(new Date(date.getTime() + dayMs)),
344
+ ];
345
+ return keys.filter((value, index, array) => array.indexOf(value) === index);
346
+ }
347
+
348
+ export function utcDateKeysForLocalDay(date: Date, timeZone: string): string[] {
349
+ const targetLocalDate = formatDateInTimeZone(date, timeZone);
350
+ const keys = new Set<string>();
351
+ const hourMs = 3_600_000;
352
+ const scanStart = date.getTime() - 48 * hourMs;
353
+ const scanEnd = date.getTime() + 48 * hourMs;
354
+ for (let ms = scanStart; ms <= scanEnd; ms += hourMs) {
355
+ const candidate = new Date(ms);
356
+ if (formatDateInTimeZone(candidate, timeZone) === targetLocalDate) {
357
+ keys.add(utcDateKey(candidate));
358
+ }
359
+ }
360
+ return keys.size > 0 ? [...keys].sort() : utcDateKeysAround(date);
361
+ }
362
+
363
+ export function parseFiniteDate(value: unknown): Date | null {
364
+ if (typeof value !== "string" || value.trim().length === 0) return null;
365
+ const parsed = new Date(value);
366
+ return Number.isFinite(parsed.getTime()) ? parsed : null;
367
+ }
368
+
369
+ export function filterHourlySummaryMarkdownForLocalDay(
370
+ raw: string,
371
+ utcDate: string,
372
+ timeZone: string,
373
+ targetLocalDate: string,
374
+ ): string | null {
375
+ const hourHeaderPattern = /^## ([01]\d|2[0-3]):00[ \t]*$/gm;
376
+ const matches = Array.from(raw.matchAll(hourHeaderPattern));
377
+ if (matches.length === 0) return null;
378
+
379
+ const firstSectionStart = matches[0]?.index ?? 0;
380
+ const preamble = raw.slice(0, firstSectionStart).trim();
381
+ const sections: string[] = [];
382
+ for (let index = 0; index < matches.length; index += 1) {
383
+ const match = matches[index];
384
+ const hour = match[1];
385
+ if (!hour) continue;
386
+ const sectionTimestamp = parseFiniteDate(`${utcDate}T${hour}:00:00.000Z`);
387
+ if (
388
+ !sectionTimestamp ||
389
+ formatDateInTimeZone(sectionTimestamp, timeZone) !== targetLocalDate
390
+ ) {
391
+ continue;
392
+ }
393
+ const sectionStart = match.index ?? 0;
394
+ const sectionEnd = matches[index + 1]?.index ?? raw.length;
395
+ const section = raw.slice(sectionStart, sectionEnd).trim();
396
+ if (section.length > 0) sections.push(section);
397
+ }
398
+
399
+ if (sections.length === 0) return null;
400
+ return [preamble, ...sections]
401
+ .filter((section) => section.length > 0)
402
+ .join("\n\n");
403
+ }
404
+
405
+ export type SearchCollectionState = "present" | "missing" | "unknown" | "skipped";
406
+
407
+ export function qmdStartupCollectionCheckTimeoutMs(): number {
408
+ const raw =
409
+ process.env.REMNIC_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS ??
410
+ process.env.ENGRAM_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
411
+ if (raw === undefined) return DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
412
+ const parsed = Number(raw);
413
+ return Number.isFinite(parsed) && parsed >= 1_000
414
+ ? Math.floor(parsed)
415
+ : DEFAULT_QMD_STARTUP_COLLECTION_CHECK_TIMEOUT_MS;
416
+ }
417
+
418
+ export async function qmdStartupCollectionCheckWithTimeout(
419
+ promise: Promise<SearchCollectionState>,
420
+ controller: AbortController,
421
+ label: string,
422
+ ): Promise<SearchCollectionState> {
423
+ const timeoutMs = qmdStartupCollectionCheckTimeoutMs();
424
+ let timer: NodeJS.Timeout | undefined;
425
+ let settled = false;
426
+
427
+ const timeoutPromise = new Promise<SearchCollectionState>((resolve) => {
428
+ timer = setTimeout(() => {
429
+ if (settled) return;
430
+ controller.abort();
431
+ log.warn(
432
+ `QMD startup collection check for ${label} timed out after ${timeoutMs}ms; keeping search enabled fail-open`,
433
+ );
434
+ resolve("unknown");
435
+ }, timeoutMs);
436
+ timer.unref?.();
437
+ });
438
+
439
+ const checkedPromise = promise
440
+ .catch((err): SearchCollectionState => {
441
+ log.warn(
442
+ `QMD startup collection check for ${label} failed; keeping search enabled fail-open: ${err}`,
443
+ );
444
+ return "unknown";
445
+ })
446
+ .finally(() => {
447
+ settled = true;
448
+ if (timer) clearTimeout(timer);
449
+ });
450
+
451
+ return await Promise.race([checkedPromise, timeoutPromise]);
452
+ }
453
+
454
+ /** Default workspace directory when no per-agent or config workspace is available. */
455
+ export function defaultWorkspaceDir(): string {
456
+ return path.join(os.homedir(), ".openclaw", "workspace");
457
+ }
458
+
459
+ /**
460
+ * Produce a collision-resistant, filesystem-safe identifier from a session key.
461
+ *
462
+ * Session keys follow colon-delimited forms (e.g., `agent:gpucodebot:main`).
463
+ * A naive replace (`:` → `_`) is lossy: different keys like `agent:alpha` and
464
+ * `agent/alpha` would collide. Instead we append a short SHA-256 hash of the
465
+ * original key to the human-readable sanitized prefix, guaranteeing uniqueness
466
+ * while keeping filenames debuggable.
467
+ *
468
+ * Format: `<sanitized>-<12-char-hex-hash>`
469
+ * Example: `agent:gpucodebot:main` → `agent_gpucodebot_main-a1b2c3d4e5f6`
470
+ */
471
+ export function sanitizeSessionKeyForFilename(sessionKey: string): string {
472
+ const readable = sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
473
+ const hash = createHash("sha256")
474
+ .update(sessionKey)
475
+ .digest("hex")
476
+ .slice(0, 12);
477
+ return `${readable}-${hash}`;
478
+ }
479
+
480
+ export function sourceValidAtMs(turn: BufferTurn): number | null {
481
+ if (typeof turn.sourceValidAt !== "string") return null;
482
+ return parseFlexibleIsoTimestamp(turn.sourceValidAt.trim());
483
+ }
484
+
485
+ export const SOURCE_VALID_AT_CONTEXT_TURNS = 2;
486
+
487
+ export function sourceValidAtSliceKey(turn: BufferTurn, index: number): string {
488
+ const validAtMs = sourceValidAtMs(turn);
489
+ return validAtMs === null ? `unknown:${index}` : String(validAtMs);
490
+ }
491
+
492
+ export function asExtractionContextTurn(turn: BufferTurn): BufferTurn {
493
+ return { ...turn, extractionContextOnly: true };
494
+ }
495
+
496
+ export function asExtractionTargetTurn(turn: BufferTurn): BufferTurn {
497
+ const { extractionContextOnly: _contextOnly, ...targetTurn } = turn;
498
+ return targetTurn;
499
+ }
500
+
501
+ export function sourceValidAtContextTurns(
502
+ turns: readonly BufferTurn[],
503
+ targetStart: number,
504
+ targetEnd: number,
505
+ targetValidAtMs: number | null,
506
+ ): BufferTurn[] {
507
+ if (targetValidAtMs === null) return [];
508
+ return turns
509
+ .flatMap((turn, index) => {
510
+ if (index >= targetStart && index < targetEnd) return [];
511
+ const contextValidAtMs = sourceValidAtMs(turn);
512
+ if (contextValidAtMs === null || contextValidAtMs > targetValidAtMs) {
513
+ return [];
514
+ }
515
+ return [{ turn, index, validAtMs: contextValidAtMs }];
516
+ })
517
+ .sort((a, b) => {
518
+ if (a.validAtMs < b.validAtMs) return -1;
519
+ if (a.validAtMs > b.validAtMs) return 1;
520
+ if (a.index === b.index) return 0;
521
+ return a.index < b.index ? -1 : 1;
522
+ })
523
+ .slice(-SOURCE_VALID_AT_CONTEXT_TURNS)
524
+ .map(({ turn }) => asExtractionContextTurn(turn));
525
+ }
526
+
527
+ export function targetSourceValidAtSortMs(turns: readonly BufferTurn[]): number {
528
+ let latestMs: number | null = null;
529
+ for (const turn of turns) {
530
+ if (turn.extractionContextOnly === true) continue;
531
+ const validAtMs = sourceValidAtMs(turn);
532
+ if (validAtMs === null) continue;
533
+ if (latestMs === null || validAtMs > latestMs) {
534
+ latestMs = validAtMs;
535
+ }
536
+ }
537
+ return latestMs ?? Number.POSITIVE_INFINITY;
538
+ }
539
+
540
+ export function sortSourceValidAtSlicesChronologically(
541
+ slices: BufferTurn[][],
542
+ ): BufferTurn[][] {
543
+ return slices
544
+ .map((turns, order) => ({
545
+ turns,
546
+ order,
547
+ targetValidAtMs: targetSourceValidAtSortMs(turns),
548
+ }))
549
+ .sort((a, b) => {
550
+ if (a.targetValidAtMs < b.targetValidAtMs) return -1;
551
+ if (a.targetValidAtMs > b.targetValidAtMs) return 1;
552
+ if (a.order === b.order) return 0;
553
+ return a.order < b.order ? -1 : 1;
554
+ })
555
+ .map((slice) => slice.turns);
556
+ }
557
+
558
+ export function splitTurnsBySourceValidAt(
559
+ turns: readonly BufferTurn[],
560
+ options: { includeContext?: boolean } = {},
561
+ ): BufferTurn[][] {
562
+ if (turns.length === 0) return [];
563
+ if (!turns.some((turn) => sourceValidAtMs(turn) !== null)) {
564
+ return [[...turns]];
565
+ }
566
+
567
+ const slices: BufferTurn[][] = [];
568
+ let start = 0;
569
+ while (start < turns.length) {
570
+ const targetValidAtMs = sourceValidAtMs(turns[start]);
571
+ const activeKey = sourceValidAtSliceKey(turns[start], start);
572
+ let end = start + 1;
573
+ while (
574
+ end < turns.length &&
575
+ sourceValidAtSliceKey(turns[end], end) === activeKey
576
+ ) {
577
+ end += 1;
578
+ }
579
+
580
+ const contextTurns =
581
+ options.includeContext === false
582
+ ? []
583
+ : sourceValidAtContextTurns(turns, start, end, targetValidAtMs);
584
+ slices.push([
585
+ ...contextTurns,
586
+ ...turns.slice(start, end).map(asExtractionTargetTurn),
587
+ ]);
588
+ start = end;
589
+ }
590
+ return sortSourceValidAtSlicesChronologically(slices);
591
+ }
592
+
593
+ export function isArtifactMemoryPath(filePath: string): boolean {
594
+ return /(?:^|[\\/])artifacts(?:[\\/]|$)/i.test(filePath);
595
+ }
596
+
597
+ export function buildCompressionGuidelinesMarkdown(
598
+ events: MemoryActionEvent[],
599
+ generatedAtIso: string = new Date().toISOString(),
600
+ ): string {
601
+ return buildCompressionGuidelinesMarkdownV2(events, generatedAtIso);
602
+ }
603
+
604
+ export function filterRecallCandidates(
605
+ candidates: QmdSearchResult[],
606
+ options: {
607
+ namespacesEnabled: boolean;
608
+ recallNamespaces: string[];
609
+ resolveNamespace: (path: string) => string;
610
+ limit: number;
611
+ },
612
+ ): QmdSearchResult[] {
613
+ const scopedByNamespace = options.namespacesEnabled
614
+ ? candidates.filter((r) =>
615
+ options.recallNamespaces.includes(options.resolveNamespace(r.path)),
616
+ )
617
+ : candidates;
618
+ return scopedByNamespace
619
+ .filter((r) => !isArtifactMemoryPath(r.path))
620
+ .slice(0, Math.max(0, options.limit));
621
+ }
622
+
623
+ export function applyQueryAwareCandidateFilter(
624
+ candidates: QmdSearchResult[],
625
+ candidatePaths: Set<string> | null,
626
+ ): QmdSearchResult[] {
627
+ if (!candidatePaths) return candidates;
628
+ if (candidatePaths.size === 0) return [];
629
+ const filtered = candidates.filter((candidate) =>
630
+ candidatePaths.has(candidate.path),
631
+ );
632
+ return filtered.length > 0 ? filtered : candidates;
633
+ }
634
+
635
+ export function tokenizeRecallQuery(prompt: string): string[] {
636
+ return prompt
637
+ .toLowerCase()
638
+ .split(/[^a-z0-9]+/i)
639
+ .map((t) => t.trim())
640
+ .filter((t) => t.length >= 3);
641
+ }
642
+
643
+ export function hasLifecycleMetadata(frontmatter: MemoryFrontmatter): boolean {
644
+ return (
645
+ frontmatter.lifecycleState !== undefined ||
646
+ frontmatter.verificationState !== undefined ||
647
+ frontmatter.policyClass !== undefined ||
648
+ frontmatter.lastValidatedAt !== undefined ||
649
+ frontmatter.decayScore !== undefined ||
650
+ frontmatter.heatScore !== undefined
651
+ );
652
+ }
653
+
654
+ export function shouldFilterLifecycleRecallCandidate(
655
+ frontmatter: MemoryFrontmatter,
656
+ options: {
657
+ lifecyclePolicyEnabled: boolean;
658
+ lifecycleFilterStaleEnabled: boolean;
659
+ },
660
+ ): boolean {
661
+ if (!options.lifecyclePolicyEnabled || !options.lifecycleFilterStaleEnabled)
662
+ return false;
663
+ if (!hasLifecycleMetadata(frontmatter)) return false;
664
+ const lifecycleState = resolveLifecycleState(frontmatter);
665
+ return lifecycleState === "stale" || lifecycleState === "archived";
666
+ }
667
+
668
+ export function lifecycleRecallScoreAdjustment(
669
+ frontmatter: MemoryFrontmatter,
670
+ options: {
671
+ lifecyclePolicyEnabled: boolean;
672
+ },
673
+ ): number {
674
+ if (!options.lifecyclePolicyEnabled) return 0;
675
+ if (!hasLifecycleMetadata(frontmatter)) return 0;
676
+
677
+ let delta = 0;
678
+ const lifecycleState = resolveLifecycleState(frontmatter);
679
+ switch (lifecycleState) {
680
+ case "active":
681
+ delta += 0.05;
682
+ break;
683
+ case "validated":
684
+ delta += 0.03;
685
+ break;
686
+ case "candidate":
687
+ delta -= 0.01;
688
+ break;
689
+ case "stale":
690
+ delta -= 0.06;
691
+ break;
692
+ case "archived":
693
+ delta -= 0.08;
694
+ break;
695
+ }
696
+ if (frontmatter.verificationState === "disputed") {
697
+ delta -= 0.12;
698
+ }
699
+ return delta;
700
+ }
701
+
702
+ export function computeArtifactRecallLimit(
703
+ recallMode: RecallPlanMode,
704
+ recallResultLimit: number,
705
+ verbatimArtifactsMaxRecall: number,
706
+ ): number {
707
+ if (recallMode === "no_recall") return 0;
708
+ if (Math.max(0, recallResultLimit) === 0) return 0;
709
+ const base = Math.max(0, verbatimArtifactsMaxRecall);
710
+ if (recallMode === "minimal") {
711
+ return Math.min(base, Math.max(0, recallResultLimit));
712
+ }
713
+ return base;
714
+ }
715
+
716
+ export function resolveEffectiveRecallMode(options: {
717
+ plannerEnabled: boolean;
718
+ graphRecallEnabled: boolean;
719
+ multiGraphMemoryEnabled: boolean;
720
+ graphExpandedIntentEnabled?: boolean;
721
+ prompt: string;
722
+ }): RecallPlanMode {
723
+ return resolveRecallModeDecision(options).effectiveMode;
724
+ }
725
+
726
+ export interface RecallModeGraphOptions {
727
+ plannerEnabled: boolean;
728
+ graphRecallEnabled: boolean;
729
+ multiGraphMemoryEnabled: boolean;
730
+ graphExpandedIntentEnabled?: boolean;
731
+ prompt: string;
732
+ }
733
+
734
+ /**
735
+ * Apply the graph-mode overlay + gating to a planner-produced mode.
736
+ *
737
+ * Shared by the heuristic ({@link resolveRecallModeDecision}) and LLM
738
+ * ({@link resolveRecallModeDecisionAsync}) paths so graph promotion and the
739
+ * "graph disabled → fall back to full" gating behave identically regardless of
740
+ * which planner produced `plannedModeRaw` (gotcha #39).
741
+ */
742
+ export function finalizeRecallModeDecision(
743
+ plannedModeRaw: RecallPlanMode,
744
+ options: RecallModeGraphOptions,
745
+ ): RecallModeDecision {
746
+ let plannedMode: RecallPlanMode = plannedModeRaw;
747
+ const graphExpandedIntentDetected =
748
+ options.plannerEnabled &&
749
+ options.graphExpandedIntentEnabled === true &&
750
+ hasBroadGraphIntent(options.prompt);
751
+ if (plannedMode !== "graph_mode" && graphExpandedIntentDetected) {
752
+ plannedMode = "graph_mode";
753
+ }
754
+ if (
755
+ plannedMode === "graph_mode" &&
756
+ (!options.graphRecallEnabled || !options.multiGraphMemoryEnabled)
757
+ ) {
758
+ return {
759
+ plannedMode,
760
+ effectiveMode: "full",
761
+ graphExpandedIntentDetected,
762
+ graphReason: !options.graphRecallEnabled
763
+ ? "graph recall disabled by config"
764
+ : "multi-graph memory disabled by config",
765
+ };
766
+ }
767
+ return {
768
+ plannedMode,
769
+ effectiveMode: plannedMode,
770
+ graphExpandedIntentDetected,
771
+ };
772
+ }
773
+
774
+ export function resolveRecallModeDecision(options: RecallModeGraphOptions): RecallModeDecision {
775
+ const plannedMode: RecallPlanMode = options.plannerEnabled
776
+ ? planRecallMode(options.prompt)
777
+ : "full";
778
+ return finalizeRecallModeDecision(plannedMode, options);
779
+ }
780
+
781
+ /**
782
+ * Async recall-mode decision with optional LLM-based planning (issue #1367 /
783
+ * Option C). Falls back to the heuristic decision when the LLM planner is
784
+ * disabled, in shadow mode, or unavailable/failed — so this is always safe to
785
+ * await on the recall hot path. Provider-agnostic: the LLM call routes through
786
+ * the gateway/fallback chain.
787
+ *
788
+ * `recallPlannerEnabled === false` keeps the legacy "always full" behavior and
789
+ * skips the LLM entirely (the planner as a whole is off).
790
+ */
791
+ export async function resolveRecallModeDecisionAsync(
792
+ options: RecallModeGraphOptions & {
793
+ config: PluginConfig;
794
+ /**
795
+ * Recall-operation capability gates (issue #1523). REQUIRED: the recall
796
+ * orchestrator always passes a resolved set — the LLM planner gate reads
797
+ * `caps.recallPlannerLlm`, never re-derives from config.
798
+ */
799
+ caps: CapabilitySet;
800
+ hints?: string[];
801
+ llm?: FallbackLlmClient;
802
+ signal?: AbortSignal;
803
+ },
804
+ ): Promise<RecallModeDecision> {
805
+ const heuristicDecision = resolveRecallModeDecision(options);
806
+
807
+ // Planner globally off, or LLM planning not opted into → heuristic only.
808
+ // Read the resolved capability (issue #1523) — never re-derive from config.
809
+ const plannerLlmEnabled = options.caps.recallPlannerLlm;
810
+ if (!options.plannerEnabled || !plannerLlmEnabled) {
811
+ return heuristicDecision;
812
+ }
813
+
814
+ const { planRecallModeLLM } = await import("../recall-planner-llm.js");
815
+ const planned = await planRecallModeLLM(
816
+ options.prompt,
817
+ options.hints,
818
+ options.config,
819
+ options.caps,
820
+ options.llm,
821
+ options.signal,
822
+ );
823
+
824
+ // Shadow mode: record what the LLM would have chosen but keep the heuristic
825
+ // effective decision (safe rollout / comparison — gotcha #30).
826
+ if (options.config.recallPlannerShadowMode) {
827
+ return {
828
+ ...heuristicDecision,
829
+ plannerSource: planned.source,
830
+ plannerReason: `shadow:${planned.reason}`,
831
+ plannerLatencyMs: planned.latencyMs,
832
+ plannerFallbackUsed: planned.fallbackUsed,
833
+ plannerModelUsed: planned.modelUsed,
834
+ plannerHeuristicMode: planned.heuristicMode,
835
+ shadowLlmMode: planned.mode,
836
+ };
837
+ }
838
+
839
+ const llmDecision = finalizeRecallModeDecision(planned.mode, options);
840
+ return {
841
+ ...llmDecision,
842
+ plannerSource: planned.source,
843
+ plannerReason: planned.reason,
844
+ plannerLatencyMs: planned.latencyMs,
845
+ plannerFallbackUsed: planned.fallbackUsed,
846
+ plannerModelUsed: planned.modelUsed,
847
+ plannerHeuristicMode: planned.heuristicMode,
848
+ };
849
+ }
850
+
851
+ export function computeArtifactCandidateFetchLimit(
852
+ targetCount: number,
853
+ ): number {
854
+ const cappedTarget = Math.max(0, targetCount);
855
+ if (cappedTarget === 0) return 0;
856
+ const headroom = Math.max(8, cappedTarget * 4);
857
+ return Math.min(200, cappedTarget + headroom);
858
+ }
859
+
860
+ export function computeQmdHybridFetchLimit(
861
+ recallFetchLimit: number,
862
+ artifactsEnabled: boolean,
863
+ maxArtifactRecall: number,
864
+ ): number {
865
+ const cappedRecallLimit = Math.max(0, recallFetchLimit);
866
+ if (cappedRecallLimit === 0) return 0;
867
+ if (!artifactsEnabled) return cappedRecallLimit;
868
+ // Overscan when artifacts are enabled, then filter artifact paths before
869
+ // re-applying the recall cap to avoid artifact-dominated top-N starvation.
870
+ const artifactHeadroom = Math.max(20, Math.max(0, maxArtifactRecall) * 8);
871
+ return Math.min(400, cappedRecallLimit + artifactHeadroom);
872
+ }
873
+
874
+ export function summarizeGraphShadowComparison(
875
+ baseline: QmdSearchResult[],
876
+ merged: QmdSearchResult[],
877
+ topN: number,
878
+ ): {
879
+ baselineCount: number;
880
+ graphCount: number;
881
+ overlapCount: number;
882
+ overlapRatio: number;
883
+ averageOverlapDelta: number;
884
+ } {
885
+ const limit = Math.max(0, Math.floor(topN));
886
+ const baselineTop = limit > 0 ? baseline.slice(0, limit) : [];
887
+ const graphTop = limit > 0 ? merged.slice(0, limit) : [];
888
+ const baselineByPath = new Map(
889
+ baselineTop.map((item) => [item.path, item.score]),
890
+ );
891
+ const graphByPath = new Map(graphTop.map((item) => [item.path, item.score]));
892
+
893
+ let overlapCount = 0;
894
+ let overlapDeltaSum = 0;
895
+ for (const [p, baselineScore] of baselineByPath.entries()) {
896
+ const graphScore = graphByPath.get(p);
897
+ if (typeof graphScore !== "number") continue;
898
+ overlapCount += 1;
899
+ overlapDeltaSum += graphScore - baselineScore;
900
+ }
901
+
902
+ const baselineCount = baselineTop.length;
903
+ return {
904
+ baselineCount,
905
+ graphCount: graphTop.length,
906
+ overlapCount,
907
+ overlapRatio: baselineCount > 0 ? overlapCount / baselineCount : 0,
908
+ averageOverlapDelta: overlapCount > 0 ? overlapDeltaSum / overlapCount : 0,
909
+ };
910
+ }
911
+
912
+ export function parseGraphRecallRankedResults(
913
+ value: unknown,
914
+ ): GraphRecallRankedResult[] {
915
+ if (!Array.isArray(value)) return [];
916
+ const parsed: GraphRecallRankedResult[] = [];
917
+ for (const entry of value) {
918
+ if (!entry || typeof entry !== "object") continue;
919
+ const candidate = entry as Partial<GraphRecallRankedResult>;
920
+ if (
921
+ typeof candidate.path !== "string" ||
922
+ typeof candidate.score !== "number"
923
+ )
924
+ continue;
925
+ parsed.push({
926
+ path: candidate.path,
927
+ score: candidate.score,
928
+ docid: typeof candidate.docid === "string" ? candidate.docid : undefined,
929
+ sourceLabels: Array.isArray(candidate.sourceLabels)
930
+ ? candidate.sourceLabels.filter(
931
+ (item): item is string => typeof item === "string",
932
+ )
933
+ : [],
934
+ });
935
+ }
936
+ return parsed.slice(0, 64);
937
+ }
938
+
939
+ export function parseMemoryIntentSnapshot(value: unknown): MemoryIntent {
940
+ const candidate =
941
+ value && typeof value === "object" ? (value as Partial<MemoryIntent>) : {};
942
+ return {
943
+ goal: typeof candidate.goal === "string" ? candidate.goal : "unknown",
944
+ actionType:
945
+ typeof candidate.actionType === "string"
946
+ ? candidate.actionType
947
+ : "unknown",
948
+ entityTypes: Array.isArray(candidate.entityTypes)
949
+ ? candidate.entityTypes.filter(
950
+ (item): item is string => typeof item === "string",
951
+ )
952
+ : [],
953
+ taskInitiation: candidate.taskInitiation === true,
954
+ };
955
+ }
956
+
957
+ export function buildQmdIntentHint(intent: MemoryIntent): string | undefined {
958
+ const parts: string[] = [];
959
+ if (intent.goal !== "unknown") {
960
+ parts.push(`goal:${intent.goal.replace(/_/g, " ")}`);
961
+ }
962
+ if (intent.actionType !== "unknown") {
963
+ parts.push(`action:${intent.actionType.replace(/_/g, " ")}`);
964
+ }
965
+ if (intent.entityTypes.length > 0) {
966
+ parts.push(`entities:${intent.entityTypes.join(",")}`);
967
+ }
968
+ if (intent.taskInitiation === true) {
969
+ parts.push("task_initiation");
970
+ }
971
+ return parts.length > 0 ? parts.join(" ") : undefined;
972
+ }
973
+
974
+ export function parseQmdRecallResults(value: unknown): QmdSearchResult[] {
975
+ if (!Array.isArray(value)) return [];
976
+ const parsed: QmdSearchResult[] = [];
977
+ for (const entry of value) {
978
+ if (!entry || typeof entry !== "object") continue;
979
+ const candidate = entry as Partial<QmdSearchResult>;
980
+ if (
981
+ typeof candidate.path !== "string" ||
982
+ typeof candidate.score !== "number"
983
+ )
984
+ continue;
985
+ parsed.push({
986
+ docid: typeof candidate.docid === "string" ? candidate.docid : "",
987
+ path: candidate.path,
988
+ snippet: typeof candidate.snippet === "string" ? candidate.snippet : "",
989
+ score: candidate.score,
990
+ explain: parseQmdExplain(candidate.explain),
991
+ transport:
992
+ candidate.transport === "daemon" ||
993
+ candidate.transport === "subprocess" ||
994
+ candidate.transport === "hybrid" ||
995
+ candidate.transport === "scoped_prefilter"
996
+ ? candidate.transport
997
+ : undefined,
998
+ });
999
+ }
1000
+ return parsed.slice(0, 32);
1001
+ }
1002
+
1003
+ export function mergeArtifactRecallCandidates(
1004
+ candidatesByNamespace: MemoryFile[][],
1005
+ limit: number,
1006
+ ): MemoryFile[] {
1007
+ const cappedLimit = Math.max(0, limit);
1008
+ if (cappedLimit === 0) return [];
1009
+
1010
+ const out: MemoryFile[] = [];
1011
+ const seen = new Set<string>();
1012
+ let offset = 0;
1013
+ while (out.length < cappedLimit) {
1014
+ let hasAnyCandidateAtOffset = false;
1015
+ for (const list of candidatesByNamespace) {
1016
+ if (offset >= list.length) continue;
1017
+ hasAnyCandidateAtOffset = true;
1018
+ const item = list[offset];
1019
+ const dedupeKey = `${item.frontmatter.id}:${item.frontmatter.sourceMemoryId ?? ""}:${item.content}`;
1020
+ if (seen.has(dedupeKey)) continue;
1021
+ seen.add(dedupeKey);
1022
+ out.push(item);
1023
+ if (out.length >= cappedLimit) break;
1024
+ }
1025
+ if (!hasAnyCandidateAtOffset) break;
1026
+ offset += 1;
1027
+ }
1028
+ return out;
1029
+ }
1030
+
1031
+ export function resolveRecentThreadMemoryPaths(options: {
1032
+ threadEpisodeIds: string[];
1033
+ currentMemoryId: string;
1034
+ allMemsForGraph: MemoryFile[] | null | undefined;
1035
+ pathById?: Map<string, string>;
1036
+ storageDir: string;
1037
+ maxRecent: number;
1038
+ }): string[] {
1039
+ const maxRecent = Math.max(0, options.maxRecent);
1040
+ if (options.threadEpisodeIds.length === 0 || maxRecent === 0) return [];
1041
+ const pathById =
1042
+ options.pathById ??
1043
+ buildMemoryPathById(options.allMemsForGraph, options.storageDir);
1044
+ if (pathById.size === 0) return [];
1045
+
1046
+ // #1635 (defensive): skip pending_review ids from legacy episode sets.
1047
+ const pendingReviewIds = new Set<string>(
1048
+ (options.allMemsForGraph ?? [])
1049
+ .filter((m) => m.frontmatter.status === "pending_review" && m.frontmatter.id)
1050
+ .map((m) => m.frontmatter.id as string),
1051
+ );
1052
+
1053
+ return options.threadEpisodeIds
1054
+ .filter((id) => id !== options.currentMemoryId)
1055
+ .filter((id) => !pendingReviewIds.has(id))
1056
+ .slice(-maxRecent)
1057
+ .map((id) => pathById.get(id))
1058
+ .filter((p): p is string => typeof p === "string" && p.length > 0);
1059
+ }
1060
+
1061
+ export function buildMemoryPathById(
1062
+ allMemsForGraph: MemoryFile[] | null | undefined,
1063
+ storageDir: string,
1064
+ ): Map<string, string> {
1065
+ const pathById = new Map<string, string>();
1066
+ for (const mem of allMemsForGraph ?? []) {
1067
+ const id = mem.frontmatter.id;
1068
+ if (!id) continue;
1069
+ pathById.set(id, path.relative(storageDir, mem.path));
1070
+ }
1071
+ return pathById;
1072
+ }
1073
+
1074
+ export function appendMemoryToGraphContext(options: {
1075
+ allMemsForGraph: MemoryFile[] | null | undefined;
1076
+ storageDir: string;
1077
+ memoryRelPath: string;
1078
+ memoryId: string;
1079
+ category: MemoryFile["frontmatter"]["category"];
1080
+ content: string;
1081
+ entityRef: string | undefined;
1082
+ }): void {
1083
+ if (!Array.isArray(options.allMemsForGraph)) return;
1084
+
1085
+ const nowIso = new Date().toISOString();
1086
+ options.allMemsForGraph.push({
1087
+ path: path.join(options.storageDir, options.memoryRelPath),
1088
+ content: options.content,
1089
+ frontmatter: {
1090
+ id: options.memoryId,
1091
+ category: options.category,
1092
+ created: nowIso,
1093
+ updated: nowIso,
1094
+ source: "extraction",
1095
+ confidence: 0.8,
1096
+ confidenceTier: "implied",
1097
+ tags: [],
1098
+ entityRef: options.entityRef,
1099
+ status: "active",
1100
+ },
1101
+ });
1102
+ }
1103
+
1104
+ export function resolvePersistedMemoryRelativePath(options: {
1105
+ memoryId: string;
1106
+ pathById: Map<string, string>;
1107
+ category: string;
1108
+ }): string {
1109
+ const persisted = options.pathById.get(options.memoryId);
1110
+ if (persisted) return persisted;
1111
+ if (options.category === "correction") {
1112
+ return path.join("corrections", `${options.memoryId}.md`);
1113
+ }
1114
+ // Pick the subtree that matches the StorageManager.writeMemory routing
1115
+ // so fallback paths (used before memoryPathById has seen the fresh
1116
+ // write) agree with where the file actually lives. Routing goes through
1117
+ // the shared categoryDirName() chokepoint (utils/category-dir.ts) so
1118
+ // every category — decisions/, preferences/, reasoning-traces/, ... —
1119
+ // resolves to the same dir the writer used; otherwise graph edges point
1120
+ // at the wrong subtree and graph expansion silently drops those nodes
1121
+ // when readMemoryByPath cannot resolve them (issue #564 PR 3 / #1546).
1122
+ const subtree = categoryDirName(options.category);
1123
+ const idParts = options.memoryId.split("-");
1124
+ const maybeTimestamp = Number(idParts[1]);
1125
+ if (Number.isFinite(maybeTimestamp) && maybeTimestamp > 0) {
1126
+ const day = new Date(maybeTimestamp).toISOString().slice(0, 10);
1127
+ return path.join(subtree, day, `${options.memoryId}.md`);
1128
+ }
1129
+ return path.join(subtree, `${options.memoryId}.md`);
1130
+ }