@graphorin/memory 0.6.1 → 0.7.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 (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -1
  3. package/dist/conflict/index.js +0 -1
  4. package/dist/conflict/pipeline.js +0 -1
  5. package/dist/conflict/pipeline.js.map +1 -1
  6. package/dist/conflict/stages/stage3-heuristic-regex.js +0 -1
  7. package/dist/conflict/stages/stage3-heuristic-regex.js.map +1 -1
  8. package/dist/conflict/types.d.ts +1 -1
  9. package/dist/conflict/types.js.map +1 -1
  10. package/dist/consolidator/index.js +2 -0
  11. package/dist/consolidator/index.js.map +1 -1
  12. package/dist/consolidator/lock.js +7 -1
  13. package/dist/consolidator/lock.js.map +1 -1
  14. package/dist/consolidator/phases/deep.js +19 -12
  15. package/dist/consolidator/phases/deep.js.map +1 -1
  16. package/dist/consolidator/phases/induce.d.ts.map +1 -1
  17. package/dist/consolidator/phases/induce.js +2 -3
  18. package/dist/consolidator/phases/induce.js.map +1 -1
  19. package/dist/consolidator/phases/reflect.js +35 -8
  20. package/dist/consolidator/phases/reflect.js.map +1 -1
  21. package/dist/consolidator/phases/standard.js +199 -28
  22. package/dist/consolidator/phases/standard.js.map +1 -1
  23. package/dist/consolidator/reconcile.js +16 -15
  24. package/dist/consolidator/reconcile.js.map +1 -1
  25. package/dist/consolidator/runtime.d.ts.map +1 -1
  26. package/dist/consolidator/runtime.js +129 -47
  27. package/dist/consolidator/runtime.js.map +1 -1
  28. package/dist/consolidator/scheduler.d.ts +17 -3
  29. package/dist/consolidator/scheduler.d.ts.map +1 -1
  30. package/dist/consolidator/scheduler.js.map +1 -1
  31. package/dist/consolidator/types.d.ts +30 -1
  32. package/dist/consolidator/types.d.ts.map +1 -1
  33. package/dist/consolidator/types.js +10 -0
  34. package/dist/consolidator/types.js.map +1 -1
  35. package/dist/context-engine/compaction/clear-tool-results.d.ts +1 -1
  36. package/dist/context-engine/compaction/clear-tool-results.js +1 -1
  37. package/dist/context-engine/compaction/clear-tool-results.js.map +1 -1
  38. package/dist/context-engine/compaction/compactor.d.ts +1 -0
  39. package/dist/context-engine/compaction/compactor.d.ts.map +1 -1
  40. package/dist/context-engine/compaction/compactor.js +32 -8
  41. package/dist/context-engine/compaction/compactor.js.map +1 -1
  42. package/dist/context-engine/compaction/hooks/types.d.ts +1 -1
  43. package/dist/context-engine/compaction/index.d.ts +1 -0
  44. package/dist/context-engine/compaction/templates/marker.d.ts +33 -0
  45. package/dist/context-engine/compaction/templates/marker.d.ts.map +1 -0
  46. package/dist/context-engine/compaction/templates/marker.js +34 -0
  47. package/dist/context-engine/compaction/templates/marker.js.map +1 -0
  48. package/dist/context-engine/compaction/templates/summary-9-section.d.ts +1 -1
  49. package/dist/context-engine/compaction/templates/summary-9-section.d.ts.map +1 -1
  50. package/dist/context-engine/compaction/templates/summary-9-section.js +4 -3
  51. package/dist/context-engine/compaction/templates/summary-9-section.js.map +1 -1
  52. package/dist/context-engine/engine.d.ts +7 -176
  53. package/dist/context-engine/engine.d.ts.map +1 -1
  54. package/dist/context-engine/engine.js +0 -2
  55. package/dist/context-engine/engine.js.map +1 -1
  56. package/dist/context-engine/index.d.ts +8 -49
  57. package/dist/context-engine/io-types.d.ts +144 -0
  58. package/dist/context-engine/io-types.d.ts.map +1 -0
  59. package/dist/context-engine/locale-packs/types.d.ts +1 -1
  60. package/dist/context-engine/locale-packs/types.js.map +1 -1
  61. package/dist/context-engine/privacy-filter.d.ts +1 -2
  62. package/dist/context-engine/privacy-filter.d.ts.map +1 -1
  63. package/dist/context-engine/privacy-filter.js +1 -2
  64. package/dist/context-engine/privacy-filter.js.map +1 -1
  65. package/dist/errors/index.d.ts +2 -2
  66. package/dist/errors/index.js +2 -2
  67. package/dist/errors/index.js.map +1 -1
  68. package/dist/facade.d.ts +43 -52
  69. package/dist/facade.d.ts.map +1 -1
  70. package/dist/facade.js +5 -3
  71. package/dist/facade.js.map +1 -1
  72. package/dist/graph/entity-resolver.js +1 -1
  73. package/dist/graph/entity-resolver.js.map +1 -1
  74. package/dist/index.d.ts +16 -13
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -5
  77. package/dist/index.js.map +1 -1
  78. package/dist/internal/envelope.js +57 -0
  79. package/dist/internal/envelope.js.map +1 -0
  80. package/dist/internal/injection-heuristics.js +0 -0
  81. package/dist/internal/injection-heuristics.js.map +1 -1
  82. package/dist/internal/llm-json.js +40 -0
  83. package/dist/internal/llm-json.js.map +1 -0
  84. package/dist/internal/storage-adapter.d.ts +23 -10
  85. package/dist/internal/storage-adapter.d.ts.map +1 -1
  86. package/dist/memory-interface.d.ts +131 -0
  87. package/dist/memory-interface.d.ts.map +1 -0
  88. package/dist/package.js +1 -1
  89. package/dist/package.js.map +1 -1
  90. package/dist/search/iterative.d.ts.map +1 -1
  91. package/dist/search/iterative.js +2 -3
  92. package/dist/search/iterative.js.map +1 -1
  93. package/dist/search/query-transform.d.ts.map +1 -1
  94. package/dist/search/query-transform.js +2 -3
  95. package/dist/search/query-transform.js.map +1 -1
  96. package/dist/tiers/episodic-memory.js +3 -3
  97. package/dist/tiers/episodic-memory.js.map +1 -1
  98. package/dist/tiers/index.d.ts +2 -2
  99. package/dist/tiers/insight-memory.js +1 -1
  100. package/dist/tiers/insight-memory.js.map +1 -1
  101. package/dist/tiers/procedural-memory.d.ts +1 -1
  102. package/dist/tiers/procedural-memory.js +2 -2
  103. package/dist/tiers/procedural-memory.js.map +1 -1
  104. package/dist/tiers/semantic-memory.d.ts +58 -4
  105. package/dist/tiers/semantic-memory.d.ts.map +1 -1
  106. package/dist/tiers/semantic-memory.js +103 -20
  107. package/dist/tiers/semantic-memory.js.map +1 -1
  108. package/dist/tools/block-tools.d.ts +19 -57
  109. package/dist/tools/block-tools.d.ts.map +1 -1
  110. package/dist/tools/block-tools.js.map +1 -1
  111. package/dist/tools/fact-tools.d.ts +53 -214
  112. package/dist/tools/fact-tools.d.ts.map +1 -1
  113. package/dist/tools/fact-tools.js +3 -2
  114. package/dist/tools/fact-tools.js.map +1 -1
  115. package/dist/tools/recall-tools.d.ts +34 -175
  116. package/dist/tools/recall-tools.d.ts.map +1 -1
  117. package/dist/tools/recall-tools.js +5 -4
  118. package/dist/tools/recall-tools.js.map +1 -1
  119. package/dist/tools/runbook-tools.d.ts +15 -58
  120. package/dist/tools/runbook-tools.d.ts.map +1 -1
  121. package/dist/tools/runbook-tools.js.map +1 -1
  122. package/dist/tools/types.d.ts +1 -1
  123. package/package.json +18 -17
  124. package/src/conflict/index.ts +29 -0
  125. package/src/conflict/locale-packs/en.ts +142 -0
  126. package/src/conflict/locale-packs/index.ts +17 -0
  127. package/src/conflict/locale-packs/types.ts +138 -0
  128. package/src/conflict/pipeline.ts +371 -0
  129. package/src/conflict/stages/helpers.ts +65 -0
  130. package/src/conflict/stages/stage1-exact-dedup.ts +40 -0
  131. package/src/conflict/stages/stage2-embedding-three-zone.ts +54 -0
  132. package/src/conflict/stages/stage3-heuristic-regex.ts +51 -0
  133. package/src/conflict/stages/stage4-subject-predicate.ts +46 -0
  134. package/src/conflict/stages/stage5-defer-to-deep.ts +34 -0
  135. package/src/conflict/types.ts +263 -0
  136. package/src/consolidator/budget.ts +312 -0
  137. package/src/consolidator/decay.ts +246 -0
  138. package/src/consolidator/dlq.ts +61 -0
  139. package/src/consolidator/errors.ts +87 -0
  140. package/src/consolidator/idempotency.ts +27 -0
  141. package/src/consolidator/index.ts +269 -0
  142. package/src/consolidator/lock.ts +141 -0
  143. package/src/consolidator/noise-filter.ts +235 -0
  144. package/src/consolidator/phases/deep.ts +308 -0
  145. package/src/consolidator/phases/induce.ts +423 -0
  146. package/src/consolidator/phases/learned-context.ts +233 -0
  147. package/src/consolidator/phases/light.ts +166 -0
  148. package/src/consolidator/phases/reflect.ts +504 -0
  149. package/src/consolidator/phases/standard.ts +1002 -0
  150. package/src/consolidator/reconcile.ts +273 -0
  151. package/src/consolidator/runtime.ts +1049 -0
  152. package/src/consolidator/scheduler.ts +182 -0
  153. package/src/consolidator/triggers.ts +134 -0
  154. package/src/consolidator/types.ts +622 -0
  155. package/src/context-engine/annotations.ts +161 -0
  156. package/src/context-engine/auto-recall.ts +92 -0
  157. package/src/context-engine/compaction/clear-tool-results.ts +232 -0
  158. package/src/context-engine/compaction/compactor.ts +403 -0
  159. package/src/context-engine/compaction/hooks/reanchor-persona-block.ts +55 -0
  160. package/src/context-engine/compaction/hooks/reanchor-pinned-facts.ts +66 -0
  161. package/src/context-engine/compaction/hooks/reanchor-project-rules.ts +57 -0
  162. package/src/context-engine/compaction/hooks/reanchor-recent-results.ts +116 -0
  163. package/src/context-engine/compaction/hooks/types.ts +68 -0
  164. package/src/context-engine/compaction/index.ts +60 -0
  165. package/src/context-engine/compaction/templates/marker.ts +33 -0
  166. package/src/context-engine/compaction/templates/summary-9-section.ts +223 -0
  167. package/src/context-engine/compaction/thresholds.ts +107 -0
  168. package/src/context-engine/compaction/types.ts +255 -0
  169. package/src/context-engine/engine.ts +970 -0
  170. package/src/context-engine/index.ts +118 -0
  171. package/src/context-engine/io-types.ts +162 -0
  172. package/src/context-engine/locale-packs/en.ts +107 -0
  173. package/src/context-engine/locale-packs/index.ts +19 -0
  174. package/src/context-engine/locale-packs/resolver.ts +197 -0
  175. package/src/context-engine/locale-packs/types.ts +177 -0
  176. package/src/context-engine/metadata.ts +114 -0
  177. package/src/context-engine/preambles/inbound-en.ts +19 -0
  178. package/src/context-engine/privacy-filter.ts +190 -0
  179. package/src/context-engine/templates/base-en.ts +19 -0
  180. package/src/context-engine/templates/composer.ts +91 -0
  181. package/src/context-engine/token-budget.ts +319 -0
  182. package/src/context-engine/token-counter.ts +157 -0
  183. package/src/errors/index.ts +273 -0
  184. package/src/facade.ts +750 -0
  185. package/src/graph/entity-resolver.ts +364 -0
  186. package/src/graph/index.ts +24 -0
  187. package/src/index.ts +259 -0
  188. package/src/internal/contextualize.ts +173 -0
  189. package/src/internal/embedder-binding.ts +53 -0
  190. package/src/internal/envelope.ts +70 -0
  191. package/src/internal/id.ts +23 -0
  192. package/src/internal/injection-heuristics.ts +0 -0
  193. package/src/internal/llm-json.ts +38 -0
  194. package/src/internal/spans.ts +35 -0
  195. package/src/internal/storage-adapter.ts +963 -0
  196. package/src/memory-interface.ts +158 -0
  197. package/src/migration/embedder-migration.ts +237 -0
  198. package/src/migration/index.ts +13 -0
  199. package/src/search/explain.ts +123 -0
  200. package/src/search/fit-weights.ts +109 -0
  201. package/src/search/graph-ppr.ts +38 -0
  202. package/src/search/index.ts +55 -0
  203. package/src/search/iterative.ts +658 -0
  204. package/src/search/query-transform.ts +285 -0
  205. package/src/search/rrf.ts +225 -0
  206. package/src/search/trust.ts +55 -0
  207. package/src/search/types.ts +50 -0
  208. package/src/tiers/episodic-memory.ts +463 -0
  209. package/src/tiers/index.ts +50 -0
  210. package/src/tiers/insight-memory.ts +208 -0
  211. package/src/tiers/procedural-memory.ts +482 -0
  212. package/src/tiers/semantic-memory.ts +1729 -0
  213. package/src/tiers/session-memory.ts +271 -0
  214. package/src/tiers/shared-memory.ts +62 -0
  215. package/src/tiers/working-memory.ts +368 -0
  216. package/src/tools/block-tools.ts +172 -0
  217. package/src/tools/fact-tools.ts +473 -0
  218. package/src/tools/index.ts +109 -0
  219. package/src/tools/recall-tools.ts +303 -0
  220. package/src/tools/runbook-tools.ts +107 -0
  221. package/src/tools/taint.ts +39 -0
  222. package/src/tools/types.ts +37 -0
  223. package/dist/conflict/locale-packs/index.js +0 -4
  224. package/dist/context-engine/compaction/index.js +0 -10
  225. package/dist/context-engine/index.d.ts.map +0 -1
  226. package/dist/context-engine/index.js +0 -25
  227. package/dist/context-engine/locale-packs/index.js +0 -4
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Agentic / iterative retrieval for hard queries (P2-4) - a **gated**,
3
+ * CRAG/Self-RAG-style grade-then-reformulate loop.
4
+ *
5
+ * Some multi-hop / temporal questions can't be answered from a single
6
+ * retrieval pass; the naive system returns a confident-but-wrong answer
7
+ * instead of retrieving again. This module adds a small loop: grade the
8
+ * retrieved memories for sufficiency and, when weak, reformulate the
9
+ * query and retrieve once more (optionally widening to the P2-1 graph) -
10
+ * up to a hard iteration cap, then **abstain** rather than confabulate.
11
+ *
12
+ * Both CRAG (arXiv:2401.15884) and Self-RAG (arXiv:2310.11511) add
13
+ * latency/cost, so the literature's advice - and this module's design -
14
+ * is to *gate* the loop, not make it default:
15
+ *
16
+ * 1. A cheap, **local** {@link assessQueryDifficulty} heuristic decides
17
+ * whether a query is even a candidate; simple lookups never enter
18
+ * the loop (no provider call) and stay single-shot.
19
+ * 2. The grade/reformulate step needs a {@link RetrievalGrader}; with
20
+ * none configured the loop degrades to a single retrieval pass. The
21
+ * only provider-backed grader, {@link createProviderRetrievalGrader},
22
+ * is **resilient**: any provider error or unparseable output degrades
23
+ * to a "stop" grade rather than throwing or looping forever.
24
+ * 3. A mandatory iteration cap bounds the worst case regardless of the
25
+ * grader.
26
+ *
27
+ * The module is provider-agnostic - it imports only `@graphorin/core`
28
+ * types and never performs I/O itself; {@link runIterativeRetrieval} is a
29
+ * pure orchestrator over an injected `retrieve` function + grader, so the
30
+ * loop is unit-testable without a store or a model.
31
+ *
32
+ * @packageDocumentation
33
+ */
34
+
35
+ import type { Provider, ProviderRequest } from '@graphorin/core';
36
+ import { stripFence } from '../internal/llm-json.js';
37
+
38
+ /** Default hard ceiling on total retrieval passes (gate + cap mandatory). */
39
+ export const DEFAULT_MAX_ITERATIONS = 3;
40
+
41
+ /**
42
+ * Absolute clamp on total retrieval passes, applied regardless of the
43
+ * caller's `maxIterations` - a latency guardrail so a mis-configured
44
+ * caller can never unbound the loop.
45
+ */
46
+ export const MAX_ITERATIONS_CEILING = 5;
47
+
48
+ /** Default score (0..1) at or above which a query is classified as hard. */
49
+ export const DEFAULT_DIFFICULTY_THRESHOLD = 0.5;
50
+
51
+ /** Default output-token ceiling for a single grade call. */
52
+ const DEFAULT_GRADE_MAX_TOKENS = 256;
53
+
54
+ /** Default number of retrieved snippets shown to the grader per pass. */
55
+ const DEFAULT_MAX_GRADE_SNIPPETS = 8;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Difficulty gate (pure, local - no I/O, no provider)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Outcome of the heuristic difficulty gate. `hard` is the gating
63
+ * decision (`score >= threshold`); `signals` lists which heuristic
64
+ * categories fired, for explainability / tracing.
65
+ *
66
+ * @stable
67
+ */
68
+ export interface DifficultyAssessment {
69
+ readonly hard: boolean;
70
+ /** Aggregate difficulty score, clamped to `[0, 1]`. */
71
+ readonly score: number;
72
+ /** Heuristic categories that fired (`'multi-hop'`, `'temporal'`, …). */
73
+ readonly signals: ReadonlyArray<string>;
74
+ }
75
+
76
+ /**
77
+ * Tuning for {@link assessQueryDifficulty}.
78
+ *
79
+ * @stable
80
+ */
81
+ export interface DifficultyGateOptions {
82
+ /** Minimum score (0..1) to classify the query as hard. Default 0.5. */
83
+ readonly threshold?: number;
84
+ }
85
+
86
+ /** Relational / referential markers implying a hop to another entity. */
87
+ const RE_REL_PRONOUN = /\b(?:who|whom|whose|which)\b/iu;
88
+ const RE_OF_CHAIN = /\bthe\s+\w+\s+of\b/iu;
89
+ const RE_POSSESSIVE = /\b[\w-]+'s\b/giu;
90
+ const RE_DESC_REF =
91
+ /\bthe\s+(?:person|people|place|thing|one|company|team|book|restaurant|project|guy|woman|man)\b/iu;
92
+ /** Temporal ordering / relative-time markers. */
93
+ const RE_TEMPORAL =
94
+ /\b(?:before|after|when|while|during|since|until|first|last|earliest|latest|previously|originally|ago|prior|then|recently|used\s+to)\b/iu;
95
+ /** Comparison / superlative markers. */
96
+ const RE_COMPARISON =
97
+ /\b(?:most|least|more|less|fewer|greater|biggest|largest|smallest|oldest|newest|best|worst|highest|lowest|compared?|versus|vs\.?|between)\b/iu;
98
+ /** Clause-joining conjunctions used to count clauses. */
99
+ const RE_CONJUNCTION = /\b(?:and|or|but|then)\b/giu;
100
+
101
+ const W_MULTI_HOP = 0.4;
102
+ const W_TEMPORAL = 0.3;
103
+ const W_COMPARISON = 0.3;
104
+ const W_MULTI_CLAUSE = 0.2;
105
+ const W_LONG = 0.15;
106
+ const LONG_WORD_COUNT = 12;
107
+
108
+ /**
109
+ * Score a query's retrieval difficulty with cheap, deterministic, fully
110
+ * **local** heuristics (no I/O). Conservative by design - it prefers to
111
+ * leave a query single-shot (`hard: false`) unless several
112
+ * multi-hop / temporal / comparison signals stack up, so the gate adds
113
+ * passes only where they are likely to help. Used by
114
+ * {@link runIterativeRetrieval} to decide whether to enter the loop.
115
+ *
116
+ * @stable
117
+ */
118
+ export function assessQueryDifficulty(
119
+ query: string,
120
+ options: DifficultyGateOptions = {},
121
+ ): DifficultyAssessment {
122
+ const threshold = options.threshold ?? DEFAULT_DIFFICULTY_THRESHOLD;
123
+ const signals: string[] = [];
124
+ let score = 0;
125
+
126
+ const possessiveCount = (query.match(RE_POSSESSIVE) ?? []).length;
127
+ const multiHop =
128
+ RE_REL_PRONOUN.test(query) ||
129
+ RE_OF_CHAIN.test(query) ||
130
+ RE_DESC_REF.test(query) ||
131
+ possessiveCount >= 2;
132
+ if (multiHop) {
133
+ signals.push('multi-hop');
134
+ score += W_MULTI_HOP;
135
+ }
136
+ if (RE_TEMPORAL.test(query)) {
137
+ signals.push('temporal');
138
+ score += W_TEMPORAL;
139
+ }
140
+ if (RE_COMPARISON.test(query)) {
141
+ signals.push('comparison');
142
+ score += W_COMPARISON;
143
+ }
144
+ const clauseSignals = (query.match(RE_CONJUNCTION) ?? []).length + countCommas(query);
145
+ if (clauseSignals >= 2 || questionMarkCount(query) >= 2) {
146
+ signals.push('multi-clause');
147
+ score += W_MULTI_CLAUSE;
148
+ }
149
+ if (wordCount(query) >= LONG_WORD_COUNT) {
150
+ signals.push('long');
151
+ score += W_LONG;
152
+ }
153
+
154
+ const clamped = Math.min(1, score);
155
+ return { hard: clamped >= threshold, score: clamped, signals };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Retrieval grader seam (provider-agnostic)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Verdict from grading a retrieved candidate set against a query.
164
+ *
165
+ * @stable
166
+ */
167
+ export interface RetrievalGrade {
168
+ /** Whether the retrieved memories suffice to answer the query. */
169
+ readonly sufficient: boolean;
170
+ /** Grader confidence, clamped to `[0, 1]`. */
171
+ readonly confidence: number;
172
+ /**
173
+ * A single better search query to try next, or `null` when the grader
174
+ * proposes none (the loop then stops / abstains).
175
+ */
176
+ readonly reformulation: string | null;
177
+ /** Optional short rationale (never surfaced in spans). */
178
+ readonly reason?: string;
179
+ }
180
+
181
+ /**
182
+ * Per-call options for a {@link RetrievalGrader}.
183
+ *
184
+ * @stable
185
+ */
186
+ export interface RetrievalGradeOptions {
187
+ /** Cancellation signal forwarded to the underlying provider call. */
188
+ readonly signal?: AbortSignal;
189
+ /**
190
+ * Reformulations already attempted (MRET-11). Surfaced to the grader
191
+ * as context so it can propose something genuinely new - the grade
192
+ * itself is ALWAYS judged against the original question, never a
193
+ * reformulation (a narrowed sub-query must not be declared
194
+ * "sufficient" while the original multi-hop question is not).
195
+ */
196
+ readonly triedQueries?: ReadonlyArray<string>;
197
+ }
198
+
199
+ /**
200
+ * Pluggable retrieval-grader seam consumed by the iterative loop. The
201
+ * built-in provider-backed implementation lives in
202
+ * {@link createProviderRetrievalGrader}; advanced callers can supply a
203
+ * bespoke grader (e.g. a deterministic heuristic).
204
+ *
205
+ * Implementations MUST degrade gracefully - return a "stop" grade rather
206
+ * than throw - so a grader failure never breaks recall.
207
+ *
208
+ * @stable
209
+ */
210
+ export interface RetrievalGrader {
211
+ grade(
212
+ query: string,
213
+ snippets: ReadonlyArray<string>,
214
+ options?: RetrievalGradeOptions,
215
+ ): Promise<RetrievalGrade>;
216
+ }
217
+
218
+ /**
219
+ * System prompt for the retrieval grader. Asks for a bare JSON verdict;
220
+ * {@link parseGrade} also tolerates a chatty model.
221
+ *
222
+ * @internal
223
+ */
224
+ export const RETRIEVAL_GRADE_SYSTEM_PROMPT =
225
+ 'You are a retrieval grader for a personal memory store. Given a question and a ' +
226
+ 'numbered list of retrieved memories, judge whether they are SUFFICIENT to answer ' +
227
+ 'the question correctly and completely. Respond with ONLY a JSON object: ' +
228
+ '{"sufficient": boolean, "confidence": number between 0 and 1, "reformulation": ' +
229
+ 'string or null, "reason": string}. Set "sufficient" to true only if the memories ' +
230
+ 'actually contain the answer - do not guess. When insufficient, set "reformulation" ' +
231
+ 'to a single alternative search query likely to retrieve the missing information ' +
232
+ '(vary entities, time frame, or specificity); otherwise null. Output only the JSON ' +
233
+ 'object - no prose, no markdown.';
234
+
235
+ /**
236
+ * Build the grade request. Pure - no I/O. Temperature 0 so the verdict
237
+ * is as stable as the model allows.
238
+ *
239
+ * @stable
240
+ */
241
+ export function buildGradeRequest(
242
+ query: string,
243
+ snippets: ReadonlyArray<string>,
244
+ options: {
245
+ readonly maxTokens?: number;
246
+ readonly signal?: AbortSignal;
247
+ readonly triedQueries?: ReadonlyArray<string>;
248
+ } = {},
249
+ ): ProviderRequest {
250
+ const body =
251
+ snippets.length === 0 ? '(none)' : snippets.map((s, i) => `[${i + 1}] ${s}`).join('\n');
252
+ const tried =
253
+ options.triedQueries !== undefined && options.triedQueries.length > 0
254
+ ? `\n\nQueries already tried (do not repeat them): ${options.triedQueries.join(' | ')}`
255
+ : '';
256
+ const content =
257
+ `Question: ${query}\n\nRetrieved memories:\n${body}${tried}\n\n` +
258
+ 'Grade whether these memories are sufficient to answer the question. If not, ' +
259
+ 'propose a single better search query.';
260
+ return {
261
+ messages: [{ role: 'user', content }],
262
+ systemMessage: RETRIEVAL_GRADE_SYSTEM_PROMPT,
263
+ temperature: 0,
264
+ maxTokens: options.maxTokens ?? DEFAULT_GRADE_MAX_TOKENS,
265
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
266
+ outputType: { kind: 'structured' },
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Parse a grader model output into a {@link RetrievalGrade}. Tolerates a
272
+ * bare JSON object, a fenced block, and chatty text around the object.
273
+ *
274
+ * **Fail-safe = stop.** When the verdict can't be read (undefined / empty
275
+ * / unparseable / missing `sufficient`), returns
276
+ * `{ sufficient: true, confidence: 0, reformulation: null }` so a broken
277
+ * grader degrades to a single pass rather than looping or falsely
278
+ * abstaining. A *parsed* `sufficient: false` (a real insufficiency
279
+ * verdict) is preserved.
280
+ *
281
+ * @stable
282
+ */
283
+ export function parseGrade(text: string | undefined): RetrievalGrade {
284
+ const stop: RetrievalGrade = { sufficient: true, confidence: 0, reformulation: null };
285
+ if (text === undefined) return stop;
286
+ const stripped = stripFence(text).trim();
287
+ if (stripped.length === 0) return stop;
288
+ const obj = tryParseObject(stripped);
289
+ if (obj === null) return stop;
290
+ const sufficient = coerceBool(obj.sufficient);
291
+ if (sufficient === null) return stop;
292
+ const confidence = coerceConfidence(obj.confidence, sufficient);
293
+ const reformulation = sufficient ? null : coerceReformulation(obj.reformulation);
294
+ const reason = coerceReason(obj.reason);
295
+ return {
296
+ sufficient,
297
+ confidence,
298
+ reformulation,
299
+ ...(reason !== undefined ? { reason } : {}),
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Wrap a {@link Provider} as a {@link RetrievalGrader}. **Resilient**: a
305
+ * provider error or unparseable response degrades to the
306
+ * {@link parseGrade} "stop" grade so grading never throws into the loop
307
+ * (and a flaky grader can't drive endless reformulation).
308
+ *
309
+ * @stable
310
+ */
311
+ export function createProviderRetrievalGrader(
312
+ provider: Provider,
313
+ options: { readonly maxTokens?: number } = {},
314
+ ): RetrievalGrader {
315
+ const maxTokens = options.maxTokens ?? DEFAULT_GRADE_MAX_TOKENS;
316
+ return {
317
+ async grade(query, snippets, opts = {}): Promise<RetrievalGrade> {
318
+ try {
319
+ const request = buildGradeRequest(query, snippets, {
320
+ maxTokens,
321
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
322
+ ...(opts.triedQueries !== undefined ? { triedQueries: opts.triedQueries } : {}),
323
+ });
324
+ const response = await provider.generate(request);
325
+ return parseGrade(response.text);
326
+ } catch {
327
+ return { sufficient: true, confidence: 0, reformulation: null };
328
+ }
329
+ },
330
+ };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // The bounded loop (pure orchestration over injected retrieve + grader)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Result of an iterative retrieval run.
339
+ *
340
+ * @stable
341
+ */
342
+ export interface IterativeRetrievalResult<H> {
343
+ /** Accumulated hits across all passes (deduped, in discovery order). */
344
+ readonly hits: ReadonlyArray<H>;
345
+ /** Number of retrieval passes performed (≥ 1). */
346
+ readonly iterations: number;
347
+ /** Difficulty-gate verdict (whether the loop was eligible to run). */
348
+ readonly gateHard: boolean;
349
+ /** Final sufficiency verdict. */
350
+ readonly sufficient: boolean;
351
+ /**
352
+ * `true` when the loop exhausted its cap / ran out of reformulations
353
+ * while still judged insufficient - the caller should abstain rather
354
+ * than answer from `hits`.
355
+ */
356
+ readonly abstained: boolean;
357
+ /**
358
+ * `true` when the grader actually judged the result at least once
359
+ * (memory-retrieval-02). `false` on the single-shot path (gate not
360
+ * hard, or no grader configured) - there `sufficient: true` is a
361
+ * DEFAULT, not a verdict, and consumers must not read it as one.
362
+ */
363
+ readonly graded: boolean;
364
+ /** The sequence of queries tried (original first). */
365
+ readonly queries: ReadonlyArray<string>;
366
+ }
367
+
368
+ /**
369
+ * Dependencies injected into {@link runIterativeRetrieval}. The loop does
370
+ * no I/O of its own - `retrieve` and `grader` own all side effects.
371
+ *
372
+ * @stable
373
+ */
374
+ export interface IterativeRetrievalDeps<H> {
375
+ /**
376
+ * Run one retrieval pass for `query`. `widen` is `true` on
377
+ * reformulation passes so the caller can broaden recall (e.g. enable
378
+ * P2-1 one-hop graph expansion).
379
+ */
380
+ retrieve(query: string, widen: boolean, signal?: AbortSignal): Promise<ReadonlyArray<H>>;
381
+ /** Snippet shown to the grader for a hit. */
382
+ snippetOf(hit: H): string;
383
+ /** Stable id used to dedup hits across passes. */
384
+ idOf(hit: H): string;
385
+ /** Grader; `null` ⇒ single-shot (no grading, no provider call). */
386
+ grader: RetrievalGrader | null;
387
+ /**
388
+ * Re-fuse the per-pass hit lists into one ranked list (MRET-2).
389
+ * Receives one list per pass in pass order; the result feeds the
390
+ * final `maxResults` cut so a pass-2 find can outrank pass-1 noise.
391
+ * Absent ⇒ the loop falls back to round-robin interleaving (still
392
+ * strictly better than the old discovery-order cut).
393
+ */
394
+ fuse?(lists: ReadonlyArray<ReadonlyArray<H>>): ReadonlyArray<H>;
395
+ }
396
+
397
+ /**
398
+ * Options for {@link runIterativeRetrieval}.
399
+ *
400
+ * @stable
401
+ */
402
+ export interface IterativeRetrievalOptions {
403
+ /** Total-pass cap; clamped to `[1, {@link MAX_ITERATIONS_CEILING}]`. */
404
+ readonly maxIterations?: number;
405
+ /** Difficulty-gate tuning. */
406
+ readonly difficulty?: DifficultyGateOptions;
407
+ /** Skip the heuristic gate and force the loop (still capped). */
408
+ readonly forceHard?: boolean;
409
+ /** Max snippets passed to the grader per pass. Default 8. */
410
+ readonly maxGradeSnippets?: number;
411
+ /** Cap on the returned hit count (omitted ⇒ all accumulated). */
412
+ readonly maxResults?: number;
413
+ readonly signal?: AbortSignal;
414
+ }
415
+
416
+ /**
417
+ * Run the gated grade-then-reformulate loop.
418
+ *
419
+ * Flow: assess difficulty → retrieve (pass 1) → if not hard *or* no
420
+ * grader, return single-shot → else grade; if sufficient, return; if
421
+ * weak and a reformulation is offered and the cap is not hit, retrieve
422
+ * again (widened) and re-grade; otherwise **abstain**.
423
+ *
424
+ * @stable
425
+ */
426
+ export async function runIterativeRetrieval<H>(
427
+ query: string,
428
+ deps: IterativeRetrievalDeps<H>,
429
+ options: IterativeRetrievalOptions = {},
430
+ ): Promise<IterativeRetrievalResult<H>> {
431
+ const gate =
432
+ options.forceHard === true
433
+ ? { hard: true, score: 1, signals: ['forced'] as ReadonlyArray<string> }
434
+ : assessQueryDifficulty(query, options.difficulty ?? {});
435
+ const cap = clampIterations(options.maxIterations);
436
+ const maxSnippets = Math.max(1, options.maxGradeSnippets ?? DEFAULT_MAX_GRADE_SNIPPETS);
437
+ const signal = options.signal;
438
+
439
+ // MRET-2: hits are tracked PER PASS - RAW, including cross-pass
440
+ // repeats, so rank fusion can reward consensus between passes. The
441
+ // grade window and the final list dedup by id at read time. The old
442
+ // flat append meant pass 1 saturated the first `maxSnippets` forever
443
+ // and the final cut ran in discovery order.
444
+ const passLists: Array<ReadonlyArray<H>> = [];
445
+ const accumulate = (hits: ReadonlyArray<H>): void => {
446
+ passLists.push([...hits]);
447
+ };
448
+ const queries: string[] = [query];
449
+ const tried = new Set<string>([query.trim().toLowerCase()]);
450
+
451
+ // Pass 1 - always single-shot, no widening.
452
+ accumulate(await deps.retrieve(query, false, signal));
453
+
454
+ // Gate: the loop is eligible only when the query is judged hard *and* a
455
+ // grader exists. Otherwise stay single-shot - we did not grade, so we
456
+ // make no abstention claim.
457
+ const grader = deps.grader;
458
+ if (!gate.hard || grader === null) {
459
+ // Single-shot: nothing was graded, so `sufficient: true` is a
460
+ // default - `graded: false` tells consumers not to trust it as a
461
+ // verdict (memory-retrieval-02).
462
+ return finalize(deps, passLists, 1, gate.hard, true, false, false, queries, options.maxResults);
463
+ }
464
+
465
+ let passes = 1;
466
+ for (;;) {
467
+ // MRET-2: the grade window interleaves the TOP hits of every pass
468
+ // (latest pass included) instead of replaying pass 1's head.
469
+ // MRET-11: sufficiency is always judged against the ORIGINAL
470
+ // question; reformulations ride along as already-tried context.
471
+ const window = interleave(passLists, maxSnippets, deps.idOf).map(deps.snippetOf);
472
+ const grade = await grader.grade(query, window, {
473
+ ...(signal !== undefined ? { signal } : {}),
474
+ ...(queries.length > 1 ? { triedQueries: queries.slice(1) } : {}),
475
+ });
476
+ if (grade.sufficient) {
477
+ return finalize(
478
+ deps,
479
+ passLists,
480
+ passes,
481
+ true,
482
+ true,
483
+ false,
484
+ true,
485
+ queries,
486
+ options.maxResults,
487
+ );
488
+ }
489
+ if (passes >= cap) break;
490
+ const next = normalizeReformulation(grade.reformulation, tried);
491
+ if (next === null) break;
492
+ passes += 1;
493
+ queries.push(next);
494
+ tried.add(next.toLowerCase());
495
+ accumulate(await deps.retrieve(next, true, signal));
496
+ }
497
+ // Cap reached or no further reformulation while still insufficient ⇒ abstain.
498
+ return finalize(deps, passLists, passes, true, false, true, true, queries, options.maxResults);
499
+ }
500
+
501
+ /**
502
+ * Round-robin interleave of per-pass lists, deduped by id: hit 1 of
503
+ * every pass, then hit 2 of every pass, … - guarantees the latest
504
+ * pass's top hits enter a window of any size.
505
+ */
506
+ function interleave<H>(
507
+ lists: ReadonlyArray<ReadonlyArray<H>>,
508
+ limit: number,
509
+ idOf: (hit: H) => string,
510
+ ): H[] {
511
+ const out: H[] = [];
512
+ const seen = new Set<string>();
513
+ const longest = lists.reduce((max, l) => Math.max(max, l.length), 0);
514
+ for (let rank = 0; rank < longest && out.length < limit; rank += 1) {
515
+ for (const list of lists) {
516
+ const hit = list[rank];
517
+ if (hit === undefined) continue;
518
+ const id = idOf(hit);
519
+ if (seen.has(id)) continue;
520
+ seen.add(id);
521
+ out.push(hit);
522
+ if (out.length >= limit) break;
523
+ }
524
+ }
525
+ return out;
526
+ }
527
+
528
+ function finalize<H>(
529
+ deps: IterativeRetrievalDeps<H>,
530
+ passLists: ReadonlyArray<ReadonlyArray<H>>,
531
+ iterations: number,
532
+ gateHard: boolean,
533
+ sufficient: boolean,
534
+ abstained: boolean,
535
+ graded: boolean,
536
+ queries: ReadonlyArray<string>,
537
+ maxResults: number | undefined,
538
+ ): IterativeRetrievalResult<H> {
539
+ // MRET-2: re-rank ACROSS passes before the cut - discovery order let
540
+ // pass-1 noise crowd out the pass-2 hit that actually answers.
541
+ const ranked =
542
+ typeof deps.fuse === 'function'
543
+ ? dedupBy(deps.fuse(passLists), deps.idOf)
544
+ : interleave(passLists, Number.POSITIVE_INFINITY, deps.idOf);
545
+ const capped =
546
+ maxResults !== undefined && maxResults >= 0 ? ranked.slice(0, maxResults) : ranked.slice();
547
+ return {
548
+ hits: capped,
549
+ iterations,
550
+ gateHard,
551
+ sufficient,
552
+ abstained,
553
+ graded,
554
+ queries: [...queries],
555
+ };
556
+ }
557
+
558
+ function dedupBy<H>(hits: ReadonlyArray<H>, idOf: (hit: H) => string): H[] {
559
+ const seen = new Set<string>();
560
+ const out: H[] = [];
561
+ for (const hit of hits) {
562
+ const id = idOf(hit);
563
+ if (seen.has(id)) continue;
564
+ seen.add(id);
565
+ out.push(hit);
566
+ }
567
+ return out;
568
+ }
569
+
570
+ function clampIterations(value: number | undefined): number {
571
+ const n = value ?? DEFAULT_MAX_ITERATIONS;
572
+ if (!Number.isFinite(n)) return DEFAULT_MAX_ITERATIONS;
573
+ return Math.min(MAX_ITERATIONS_CEILING, Math.max(1, Math.floor(n)));
574
+ }
575
+
576
+ /**
577
+ * Normalise a proposed reformulation: trim, drop empties, and reject a
578
+ * query already tried (case-insensitive) so the loop never re-treads and
579
+ * cannot spin.
580
+ */
581
+ function normalizeReformulation(raw: string | null, tried: ReadonlySet<string>): string | null {
582
+ if (raw === null) return null;
583
+ const trimmed = raw.trim();
584
+ if (trimmed.length === 0) return null;
585
+ if (tried.has(trimmed.toLowerCase())) return null;
586
+ return trimmed;
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Helpers
591
+ // ---------------------------------------------------------------------------
592
+
593
+ function wordCount(text: string): number {
594
+ const trimmed = text.trim();
595
+ return trimmed.length === 0 ? 0 : trimmed.split(/\s+/u).length;
596
+ }
597
+
598
+ function countCommas(text: string): number {
599
+ return (text.match(/,/gu) ?? []).length;
600
+ }
601
+
602
+ function questionMarkCount(text: string): number {
603
+ return (text.match(/\?/gu) ?? []).length;
604
+ }
605
+
606
+ function coerceBool(value: unknown): boolean | null {
607
+ if (typeof value === 'boolean') return value;
608
+ if (typeof value === 'string') {
609
+ const v = value.trim().toLowerCase();
610
+ if (v === 'true' || v === 'yes' || v === 'sufficient') return true;
611
+ if (v === 'false' || v === 'no' || v === 'insufficient') return false;
612
+ }
613
+ return null;
614
+ }
615
+
616
+ function coerceConfidence(value: unknown, sufficient: boolean): number {
617
+ if (typeof value === 'number' && Number.isFinite(value)) {
618
+ return Math.min(1, Math.max(0, value));
619
+ }
620
+ return sufficient ? 0.8 : 0.3;
621
+ }
622
+
623
+ function coerceReformulation(value: unknown): string | null {
624
+ if (typeof value !== 'string') return null;
625
+ const trimmed = value.trim();
626
+ if (trimmed.length === 0) return null;
627
+ return trimmed.length > 1024 ? trimmed.slice(0, 1024) : trimmed;
628
+ }
629
+
630
+ function coerceReason(value: unknown): string | undefined {
631
+ if (typeof value !== 'string') return undefined;
632
+ const trimmed = value.trim();
633
+ return trimmed.length === 0 ? undefined : trimmed;
634
+ }
635
+
636
+ function tryParseObject(text: string): Record<string, unknown> | null {
637
+ const direct = tryJson(text);
638
+ if (isRecord(direct)) return direct;
639
+ const start = text.indexOf('{');
640
+ const end = text.lastIndexOf('}');
641
+ if (start >= 0 && end > start) {
642
+ const sliced = tryJson(text.slice(start, end + 1));
643
+ if (isRecord(sliced)) return sliced;
644
+ }
645
+ return null;
646
+ }
647
+
648
+ function tryJson(text: string): unknown {
649
+ try {
650
+ return JSON.parse(text);
651
+ } catch {
652
+ return undefined;
653
+ }
654
+ }
655
+
656
+ function isRecord(value: unknown): value is Record<string, unknown> {
657
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
658
+ }