mem-os-python 1.6.27__py3-none-any.whl

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 (253) hide show
  1. mem_os_python-1.6.27.dist-info/METADATA +1032 -0
  2. mem_os_python-1.6.27.dist-info/RECORD +253 -0
  3. mem_os_python-1.6.27.dist-info/WHEEL +4 -0
  4. mem_os_python-1.6.27.dist-info/entry_points.txt +2 -0
  5. mem_os_python-1.6.27.dist-info/licenses/LICENSE +21 -0
  6. memos/__init__.py +16 -0
  7. memos/_js/adapters/anthropic.d.ts +279 -0
  8. memos/_js/adapters/anthropic.d.ts.map +1 -0
  9. memos/_js/adapters/anthropic.js +348 -0
  10. memos/_js/adapters/anthropic.js.map +1 -0
  11. memos/_js/adapters/base.d.ts +10 -0
  12. memos/_js/adapters/base.d.ts.map +1 -0
  13. memos/_js/adapters/base.js +2 -0
  14. memos/_js/adapters/base.js.map +1 -0
  15. memos/_js/adapters/openai.d.ts +264 -0
  16. memos/_js/adapters/openai.d.ts.map +1 -0
  17. memos/_js/adapters/openai.js +342 -0
  18. memos/_js/adapters/openai.js.map +1 -0
  19. memos/_js/apps/explorer.d.ts +244 -0
  20. memos/_js/apps/explorer.d.ts.map +1 -0
  21. memos/_js/apps/explorer.js +738 -0
  22. memos/_js/apps/explorer.js.map +1 -0
  23. memos/_js/citations.d.ts +65 -0
  24. memos/_js/citations.d.ts.map +1 -0
  25. memos/_js/citations.js +143 -0
  26. memos/_js/citations.js.map +1 -0
  27. memos/_js/cli-browse.d.ts +3 -0
  28. memos/_js/cli-browse.d.ts.map +1 -0
  29. memos/_js/cli-browse.js +129 -0
  30. memos/_js/cli-browse.js.map +1 -0
  31. memos/_js/cli-completion.d.ts +31 -0
  32. memos/_js/cli-completion.d.ts.map +1 -0
  33. memos/_js/cli-completion.js +340 -0
  34. memos/_js/cli-completion.js.map +1 -0
  35. memos/_js/cli-help.d.ts +18 -0
  36. memos/_js/cli-help.d.ts.map +1 -0
  37. memos/_js/cli-help.js +357 -0
  38. memos/_js/cli-help.js.map +1 -0
  39. memos/_js/cli.d.ts +28 -0
  40. memos/_js/cli.d.ts.map +1 -0
  41. memos/_js/cli.js +2880 -0
  42. memos/_js/cli.js.map +1 -0
  43. memos/_js/compression.d.ts +25 -0
  44. memos/_js/compression.d.ts.map +1 -0
  45. memos/_js/compression.js +319 -0
  46. memos/_js/compression.js.map +1 -0
  47. memos/_js/confidence-machine.d.ts +99 -0
  48. memos/_js/confidence-machine.d.ts.map +1 -0
  49. memos/_js/confidence-machine.js +222 -0
  50. memos/_js/confidence-machine.js.map +1 -0
  51. memos/_js/context-pack.d.ts +506 -0
  52. memos/_js/context-pack.d.ts.map +1 -0
  53. memos/_js/context-pack.js +1435 -0
  54. memos/_js/context-pack.js.map +1 -0
  55. memos/_js/contradictions.d.ts +160 -0
  56. memos/_js/contradictions.d.ts.map +1 -0
  57. memos/_js/contradictions.js +255 -0
  58. memos/_js/contradictions.js.map +1 -0
  59. memos/_js/dashboard.d.ts +30 -0
  60. memos/_js/dashboard.d.ts.map +1 -0
  61. memos/_js/dashboard.js +302 -0
  62. memos/_js/dashboard.js.map +1 -0
  63. memos/_js/ebbinghaus.d.ts +29 -0
  64. memos/_js/ebbinghaus.d.ts.map +1 -0
  65. memos/_js/ebbinghaus.js +32 -0
  66. memos/_js/ebbinghaus.js.map +1 -0
  67. memos/_js/embedding-queue.d.ts +190 -0
  68. memos/_js/embedding-queue.d.ts.map +1 -0
  69. memos/_js/embedding-queue.js +316 -0
  70. memos/_js/embedding-queue.js.map +1 -0
  71. memos/_js/embeddings.d.ts +187 -0
  72. memos/_js/embeddings.d.ts.map +1 -0
  73. memos/_js/embeddings.js +633 -0
  74. memos/_js/embeddings.js.map +1 -0
  75. memos/_js/entity-extraction.d.ts +54 -0
  76. memos/_js/entity-extraction.d.ts.map +1 -0
  77. memos/_js/entity-extraction.js +325 -0
  78. memos/_js/entity-extraction.js.map +1 -0
  79. memos/_js/entity-index.d.ts +93 -0
  80. memos/_js/entity-index.d.ts.map +1 -0
  81. memos/_js/entity-index.js +165 -0
  82. memos/_js/entity-index.js.map +1 -0
  83. memos/_js/event-memory.d.ts +147 -0
  84. memos/_js/event-memory.d.ts.map +1 -0
  85. memos/_js/event-memory.js +290 -0
  86. memos/_js/event-memory.js.map +1 -0
  87. memos/_js/external-import.d.ts +98 -0
  88. memos/_js/external-import.d.ts.map +1 -0
  89. memos/_js/external-import.js +669 -0
  90. memos/_js/external-import.js.map +1 -0
  91. memos/_js/fidelity.d.ts +367 -0
  92. memos/_js/fidelity.d.ts.map +1 -0
  93. memos/_js/fidelity.js +911 -0
  94. memos/_js/fidelity.js.map +1 -0
  95. memos/_js/graph-expansion.d.ts +119 -0
  96. memos/_js/graph-expansion.d.ts.map +1 -0
  97. memos/_js/graph-expansion.js +213 -0
  98. memos/_js/graph-expansion.js.map +1 -0
  99. memos/_js/graph-mermaid.d.ts +13 -0
  100. memos/_js/graph-mermaid.d.ts.map +1 -0
  101. memos/_js/graph-mermaid.js +32 -0
  102. memos/_js/graph-mermaid.js.map +1 -0
  103. memos/_js/graph.d.ts +170 -0
  104. memos/_js/graph.d.ts.map +1 -0
  105. memos/_js/graph.js +482 -0
  106. memos/_js/graph.js.map +1 -0
  107. memos/_js/harness.d.ts +63 -0
  108. memos/_js/harness.d.ts.map +1 -0
  109. memos/_js/harness.js +93 -0
  110. memos/_js/harness.js.map +1 -0
  111. memos/_js/importance.d.ts +118 -0
  112. memos/_js/importance.d.ts.map +1 -0
  113. memos/_js/importance.js +144 -0
  114. memos/_js/importance.js.map +1 -0
  115. memos/_js/index.d.ts +51 -0
  116. memos/_js/index.d.ts.map +1 -0
  117. memos/_js/index.js +53 -0
  118. memos/_js/index.js.map +1 -0
  119. memos/_js/mcp.d.ts +58 -0
  120. memos/_js/mcp.d.ts.map +1 -0
  121. memos/_js/mcp.js +1853 -0
  122. memos/_js/mcp.js.map +1 -0
  123. memos/_js/memory.d.ts +1762 -0
  124. memos/_js/memory.d.ts.map +1 -0
  125. memos/_js/memory.js +6283 -0
  126. memos/_js/memory.js.map +1 -0
  127. memos/_js/procedural.d.ts +74 -0
  128. memos/_js/procedural.d.ts.map +1 -0
  129. memos/_js/procedural.js +125 -0
  130. memos/_js/procedural.js.map +1 -0
  131. memos/_js/project-config.d.ts +68 -0
  132. memos/_js/project-config.d.ts.map +1 -0
  133. memos/_js/project-config.js +59 -0
  134. memos/_js/project-config.js.map +1 -0
  135. memos/_js/proof-of-forgetting.d.ts +57 -0
  136. memos/_js/proof-of-forgetting.d.ts.map +1 -0
  137. memos/_js/proof-of-forgetting.js +80 -0
  138. memos/_js/proof-of-forgetting.js.map +1 -0
  139. memos/_js/provenance.d.ts +122 -0
  140. memos/_js/provenance.d.ts.map +1 -0
  141. memos/_js/provenance.js +182 -0
  142. memos/_js/provenance.js.map +1 -0
  143. memos/_js/quarantine-commands.d.ts +23 -0
  144. memos/_js/quarantine-commands.d.ts.map +1 -0
  145. memos/_js/quarantine-commands.js +42 -0
  146. memos/_js/quarantine-commands.js.map +1 -0
  147. memos/_js/quarantine.d.ts +120 -0
  148. memos/_js/quarantine.d.ts.map +1 -0
  149. memos/_js/quarantine.js +873 -0
  150. memos/_js/quarantine.js.map +1 -0
  151. memos/_js/redact.d.ts +12 -0
  152. memos/_js/redact.d.ts.map +1 -0
  153. memos/_js/redact.js +26 -0
  154. memos/_js/redact.js.map +1 -0
  155. memos/_js/repl.d.ts +57 -0
  156. memos/_js/repl.d.ts.map +1 -0
  157. memos/_js/repl.js +295 -0
  158. memos/_js/repl.js.map +1 -0
  159. memos/_js/rerank-local.d.ts +61 -0
  160. memos/_js/rerank-local.d.ts.map +1 -0
  161. memos/_js/rerank-local.js +210 -0
  162. memos/_js/rerank-local.js.map +1 -0
  163. memos/_js/retain-filter.d.ts +98 -0
  164. memos/_js/retain-filter.d.ts.map +1 -0
  165. memos/_js/retain-filter.js +232 -0
  166. memos/_js/retain-filter.js.map +1 -0
  167. memos/_js/retrieval.d.ts +265 -0
  168. memos/_js/retrieval.d.ts.map +1 -0
  169. memos/_js/retrieval.js +1018 -0
  170. memos/_js/retrieval.js.map +1 -0
  171. memos/_js/revert.d.ts +77 -0
  172. memos/_js/revert.d.ts.map +1 -0
  173. memos/_js/revert.js +252 -0
  174. memos/_js/revert.js.map +1 -0
  175. memos/_js/scope.d.ts +30 -0
  176. memos/_js/scope.d.ts.map +1 -0
  177. memos/_js/scope.js +71 -0
  178. memos/_js/scope.js.map +1 -0
  179. memos/_js/semantic-screen.d.ts +152 -0
  180. memos/_js/semantic-screen.d.ts.map +1 -0
  181. memos/_js/semantic-screen.js +290 -0
  182. memos/_js/semantic-screen.js.map +1 -0
  183. memos/_js/session-hooks.d.ts +127 -0
  184. memos/_js/session-hooks.d.ts.map +1 -0
  185. memos/_js/session-hooks.js +482 -0
  186. memos/_js/session-hooks.js.map +1 -0
  187. memos/_js/storage/sqlite.d.ts +427 -0
  188. memos/_js/storage/sqlite.d.ts.map +1 -0
  189. memos/_js/storage/sqlite.js +2428 -0
  190. memos/_js/storage/sqlite.js.map +1 -0
  191. memos/_js/sync.d.ts +213 -0
  192. memos/_js/sync.d.ts.map +1 -0
  193. memos/_js/sync.js +389 -0
  194. memos/_js/sync.js.map +1 -0
  195. memos/_js/tamper-log.d.ts +98 -0
  196. memos/_js/tamper-log.d.ts.map +1 -0
  197. memos/_js/tamper-log.js +327 -0
  198. memos/_js/tamper-log.js.map +1 -0
  199. memos/_js/temporal.d.ts +92 -0
  200. memos/_js/temporal.d.ts.map +1 -0
  201. memos/_js/temporal.js +536 -0
  202. memos/_js/temporal.js.map +1 -0
  203. memos/_js/time-travel.d.ts +38 -0
  204. memos/_js/time-travel.d.ts.map +1 -0
  205. memos/_js/time-travel.js +46 -0
  206. memos/_js/time-travel.js.map +1 -0
  207. memos/_js/timestamp-format.d.ts +18 -0
  208. memos/_js/timestamp-format.d.ts.map +1 -0
  209. memos/_js/timestamp-format.js +52 -0
  210. memos/_js/timestamp-format.js.map +1 -0
  211. memos/_js/triple-extraction.d.ts +31 -0
  212. memos/_js/triple-extraction.d.ts.map +1 -0
  213. memos/_js/triple-extraction.js +116 -0
  214. memos/_js/triple-extraction.js.map +1 -0
  215. memos/_js/types.d.ts +2474 -0
  216. memos/_js/types.d.ts.map +1 -0
  217. memos/_js/types.js +20 -0
  218. memos/_js/types.js.map +1 -0
  219. memos/_js/updater.d.ts +95 -0
  220. memos/_js/updater.d.ts.map +1 -0
  221. memos/_js/updater.js +268 -0
  222. memos/_js/updater.js.map +1 -0
  223. memos/_js/version.d.ts +17 -0
  224. memos/_js/version.d.ts.map +1 -0
  225. memos/_js/version.js +34 -0
  226. memos/_js/version.js.map +1 -0
  227. memos/_js/vision/clip-query-cache.d.ts +58 -0
  228. memos/_js/vision/clip-query-cache.d.ts.map +1 -0
  229. memos/_js/vision/clip-query-cache.js +79 -0
  230. memos/_js/vision/clip-query-cache.js.map +1 -0
  231. memos/_js/vision/clip.d.ts +113 -0
  232. memos/_js/vision/clip.d.ts.map +1 -0
  233. memos/_js/vision/clip.js +224 -0
  234. memos/_js/vision/clip.js.map +1 -0
  235. memos/_js/vision/fake-clip.d.ts +55 -0
  236. memos/_js/vision/fake-clip.d.ts.map +1 -0
  237. memos/_js/vision/fake-clip.js +122 -0
  238. memos/_js/vision/fake-clip.js.map +1 -0
  239. memos/_js/vision/index.d.ts +15 -0
  240. memos/_js/vision/index.d.ts.map +1 -0
  241. memos/_js/vision/index.js +13 -0
  242. memos/_js/vision/index.js.map +1 -0
  243. memos/_js/vision/store.d.ts +137 -0
  244. memos/_js/vision/store.d.ts.map +1 -0
  245. memos/_js/vision/store.js +383 -0
  246. memos/_js/vision/store.js.map +1 -0
  247. memos/adapters/__init__.py +0 -0
  248. memos/adapters/crewai.py +385 -0
  249. memos/adapters/langchain.py +301 -0
  250. memos/adapters/ollama.py +396 -0
  251. memos/server/__init__.py +0 -0
  252. memos/server/main.py +547 -0
  253. memos/server/routes.py +360 -0
memos/_js/retrieval.js ADDED
@@ -0,0 +1,1018 @@
1
+ /**
2
+ * Retrieval fusion — hybrid search result merging.
3
+ *
4
+ * Two fusion algorithms (see `FusionOptions.fusionMode`):
5
+ *
6
+ * - RRF (weighted Reciprocal Rank Fusion): each leg votes by RANK
7
+ * (`weight / (K + rank)`), robust to score outliers that would
8
+ * otherwise dominate a weighted sum. K=60 is the standard constant
9
+ * from the RRF paper (Cormack et al., 2009).
10
+ * - Convex combination (default since round 4): min-max normalize each
11
+ * leg's native scores to [0,1], then take the weighted sum. A dominant
12
+ * absolute score (near-exact embedding match, runaway bm25 hit) keeps
13
+ * its magnitude instead of being flattened to a rank vote. A Sept 2026
14
+ * TOIS analysis found convex combination beats RRF in- and
15
+ * out-of-domain; Weaviate made the same switch.
16
+ *
17
+ * The keyword leg carries the larger weight by default (0.8 vs 0.2):
18
+ * exact term matches are highly reliable for factoid memory queries,
19
+ * while embeddings rescue paraphrases that share no vocabulary.
20
+ */
21
+ import { confidenceWeight } from "./confidence-machine.js";
22
+ import { entityOverlap } from "./entity-extraction.js";
23
+ import { DEFAULT_PROVENANCE_WEIGHT_STRENGTH, isProvenanceTier, provenanceMultiplier, } from "./provenance.js";
24
+ /** Default RRF constant (Cormack et al., 2009). */
25
+ export const DEFAULT_RRF_K = 60;
26
+ /**
27
+ * Default fusion algorithm (round 4). `"convex"` min-max normalizes each
28
+ * leg's native scores and takes the weighted sum; `"rrf"` restores the
29
+ * legacy rank-vote behavior.
30
+ */
31
+ export const DEFAULT_FUSION_MODE = "convex";
32
+ /**
33
+ * Temporal markers that switch on the recency multiplier for a query
34
+ * (round 4 adaptive weighting). Deliberately narrow: generic nouns like
35
+ * "week" alone don't trigger it, only explicit temporal constructions.
36
+ */
37
+ export const TEMPORAL_QUERY_RE = /\b(today|yesterday|tomorrow|tonight|recent|recently|latest|last\s+(night|week|month|year)|next\s+(week|month|year)|\d+\s+(days?|weeks?|months?)\s+ago|monday|tuesday|wednesday|thursday|friday|saturday|sunday|january|february|march|april|may|june|july|august|september|october|november|december)\b/i;
38
+ /** Default strength of the temporal-query recency multiplier (≤5%). */
39
+ export const DEFAULT_TEMPORAL_RECENCY_STRENGTH = 0.05;
40
+ /**
41
+ * Stopwords for RM3-lite term harvesting — small, generic, English.
42
+ * Kept local (not shared with the entity extractor) so the PRF
43
+ * vocabulary stays independent of entity-extraction tuning.
44
+ */
45
+ const PRF_STOPWORDS = new Set([
46
+ "the",
47
+ "a",
48
+ "an",
49
+ "and",
50
+ "or",
51
+ "but",
52
+ "of",
53
+ "to",
54
+ "in",
55
+ "on",
56
+ "for",
57
+ "with",
58
+ "is",
59
+ "are",
60
+ "was",
61
+ "were",
62
+ "be",
63
+ "been",
64
+ "being",
65
+ "it",
66
+ "its",
67
+ "this",
68
+ "that",
69
+ "these",
70
+ "those",
71
+ "i",
72
+ "you",
73
+ "he",
74
+ "she",
75
+ "we",
76
+ "they",
77
+ "me",
78
+ "him",
79
+ "her",
80
+ "us",
81
+ "them",
82
+ "my",
83
+ "your",
84
+ "his",
85
+ "our",
86
+ "their",
87
+ "as",
88
+ "at",
89
+ "by",
90
+ "from",
91
+ "into",
92
+ "about",
93
+ "what",
94
+ "which",
95
+ "who",
96
+ "whom",
97
+ "whose",
98
+ "when",
99
+ "where",
100
+ "why",
101
+ "how",
102
+ "does",
103
+ "did",
104
+ "do",
105
+ "have",
106
+ "has",
107
+ "had",
108
+ "not",
109
+ "no",
110
+ "yes",
111
+ "can",
112
+ "could",
113
+ "should",
114
+ "would",
115
+ "will",
116
+ "just",
117
+ "than",
118
+ "then",
119
+ "there",
120
+ "here",
121
+ "all",
122
+ "any",
123
+ ]);
124
+ /**
125
+ * Tokenize for PRF: lowercase alphanumeric tokens, stopwords and
126
+ * short tokens dropped.
127
+ */
128
+ export function prfTokenize(text) {
129
+ return text
130
+ .toLowerCase()
131
+ .split(/[^a-z0-9]+/)
132
+ .filter((t) => t.length >= 3 && !PRF_STOPWORDS.has(t));
133
+ }
134
+ /**
135
+ * RM3-lite pseudo-relevance feedback term harvesting (round 4).
136
+ *
137
+ * Pure function: given the top semantic-leg hits for a query, harvest
138
+ * up to `maxTerms` recurring content terms that are NOT already in the
139
+ * query, ranked by frequency across the hits. The caller re-probes FTS5
140
+ * with these terms as a second keyword pass whose results are UNIONED
141
+ * into the keyword leg — expansion can only add recall, never remove
142
+ * candidates.
143
+ *
144
+ * Deterministic: no model, no sampling — same hits always yield the
145
+ * same terms. Gated by the caller to short queries (≤3 terms), where
146
+ * vocabulary mismatch hurts most.
147
+ *
148
+ * @param hits — Top semantic-leg results (best first); callers pass ~5.
149
+ * @param queryTerms — Tokenized query terms (from `prfTokenize`).
150
+ * @param maxTerms — Cap on harvested terms. Default 3.
151
+ */
152
+ export function harvestPrfTerms(hits, queryTerms, maxTerms = 3) {
153
+ const querySet = new Set(queryTerms);
154
+ const freq = new Map();
155
+ for (const hit of hits) {
156
+ const text = `${hit.node.summary ?? ""} ${hit.node.content}`;
157
+ for (const tok of prfTokenize(text)) {
158
+ if (querySet.has(tok))
159
+ continue;
160
+ freq.set(tok, (freq.get(tok) ?? 0) + 1);
161
+ }
162
+ }
163
+ return [...freq.entries()]
164
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
165
+ .slice(0, Math.max(0, maxTerms))
166
+ .map(([term]) => term);
167
+ }
168
+ /**
169
+ * Merge an expansion keyword pass into the primary keyword leg (union
170
+ * semantics). Candidates already in the primary leg keep their original
171
+ * score; expansion-only candidates append after.
172
+ *
173
+ * The expansion query is a different query (harvested terms only), so its
174
+ * bm25 scores are NOT comparable to the primary query's: an expansion hit
175
+ * matching three harvested terms will outscore a primary hit on raw bm25
176
+ * even though the primary query is the user's actual intent. To keep PRF
177
+ * a pure recall booster (never a re-ranker), expansion-only candidates are
178
+ * demoted below every primary hit (FTS5 bm25: lower/more-negative is
179
+ * better, so they take worst-primary + 1); they tie among themselves and
180
+ * order deterministically by node id thereafter.
181
+ */
182
+ export function mergeKeywordLegs(primary, expansion) {
183
+ if (expansion.length === 0)
184
+ return primary;
185
+ const seen = new Set(primary.map((r) => r.node.id));
186
+ // FTS5 bm25 rank: lower (more negative) is better. Expansion-only hits
187
+ // take worst-primary + 1 so no expansion score can outrank a primary hit.
188
+ const worstPrimary = primary.length > 0
189
+ ? Math.max(...primary.map((r) => r.score))
190
+ : Number.NEGATIVE_INFINITY;
191
+ const merged = [...primary];
192
+ for (const r of expansion) {
193
+ if (!seen.has(r.node.id)) {
194
+ seen.add(r.node.id);
195
+ merged.push(primary.length > 0
196
+ ? { node: r.node, score: worstPrimary + 1.0, scores: r.scores }
197
+ : r);
198
+ }
199
+ }
200
+ return merged;
201
+ }
202
+ /**
203
+ * Stable ordering for scored memories: descending score, node id as the
204
+ * final tiebreak. The id tiebreak keeps serialized output byte-identical
205
+ * for identical result sets — without it, equal scores surface
206
+ * input/iteration-order noise, which breaks LLM prompt-cache prefixes
207
+ * (providers only discount cached input when the prefix is byte-stable).
208
+ */
209
+ export function compareScoredMemories(a, b) {
210
+ if (b.score !== a.score)
211
+ return b.score - a.score;
212
+ return a.node.id < b.node.id ? -1 : a.node.id > b.node.id ? 1 : 0;
213
+ }
214
+ /** Default weight for the keyword (FTS5) leg. */
215
+ export const DEFAULT_KEYWORD_WEIGHT = 0.8;
216
+ /** Default weight for the semantic (embedding) leg. */
217
+ export const DEFAULT_SEMANTIC_WEIGHT = 0.2;
218
+ /**
219
+ * Default strength of the entity-overlap boost — the third retrieval
220
+ * signal (semantic + keyword + entity match). Applied as a post-fusion
221
+ * multiplier `score *= 1 + entityWeight * overlap`, so it nudges rather
222
+ * than dominates; only active when `queryEntities` is non-empty.
223
+ */
224
+ export const DEFAULT_ENTITY_WEIGHT = 0.15;
225
+ /**
226
+ * ── item2: entity leg ── default weight of the entity-inverted-index RRF
227
+ * leg (`entityResults`). Deliberately subordinate to the semantic leg:
228
+ * rank-1 in the entity leg scores `0.1 / (rrfK + 1)`, below a
229
+ * semantic-only rank-1 (`0.2 / (rrfK + 1)`). The extractor is lexical,
230
+ * so on dialogue-heavy corpora a 0.5 weight let entity-matched
231
+ * near-duplicates outrank genuine semantic hits — measured as a LoCoMo
232
+ * nDCG regression at 0.5, neutral at 0.1. The leg's job is recall
233
+ * (surfacing entity-matched memories the other legs missed), not
234
+ * re-ranking.
235
+ */
236
+ export const DEFAULT_ENTITY_LEG_WEIGHT = 0.1;
237
+ /**
238
+ * Default weight of the CLIP image leg (Track B of image memory).
239
+ *
240
+ * Same role as the entity leg — a RECALL leg that surfaces candidates
241
+ * the text legs could not match, not a re-ranking signal — so it sits
242
+ * just below the semantic leg's 0.2, at the entity leg's level. The
243
+ * leg is inert whenever it has no results (no CLIP configured, no image
244
+ * embeddings stored), so this default never moves a text-only store's
245
+ * ranking by a single bit.
246
+ */
247
+ export const DEFAULT_IMAGE_LEG_WEIGHT = 0.1;
248
+ /**
249
+ * Lower bound of the trust multiplier. A memory with trustScore 0 is
250
+ * multiplied by `trustFloor`; trustScore 1 by 1.0. Kept gentle (0.7)
251
+ * so trust nudges ranking without dominating pure relevance.
252
+ */
253
+ export const DEFAULT_TRUST_FLOOR = 0.7;
254
+ /**
255
+ * Weight of the confidence multiplier applied AFTER fusion. The
256
+ * confidence state machine (`src/confidence-machine.ts`) tracks how
257
+ * consistently a memory has been reinforced vs contradicted over time;
258
+ * `confidenceWeight()` combines that with trust via a geometric mean
259
+ * and heavily suppresses memories below the confidence floor (e.g.
260
+ * superseded or contradicted facts). Applied as
261
+ * `score *= floor + weight * (combinedWeight - floor)` — gentle by
262
+ * default so relevance ordering dominates unless evidence says
263
+ * otherwise. Set to `0` to disable confidence-aware ranking.
264
+ */
265
+ export const DEFAULT_CONFIDENCE_WEIGHT = 0.35;
266
+ /**
267
+ * Half-life for the recency tie-break, in milliseconds. Among fused
268
+ * candidates whose scores are within `RECENCY_EPSILON` of each other,
269
+ * the newer memory wins; the boost decays with an exponential half-life
270
+ * (default 30 days) so it only ever re-orders near-ties, never overrides
271
+ * a clear relevance gap. This lifts "Knowledge Update" / "Temporal
272
+ * Reasoning" style queries where two versions of a fact both match but
273
+ * only the current one should surface first. Set to `0` to disable.
274
+ */
275
+ export const DEFAULT_RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
276
+ /**
277
+ * Score window in which candidates are considered TIED for recency
278
+ * tie-breaking. This is a float-noise window (~1e-9), NOT a fuzzy band:
279
+ * adjacent RRF ranks differ by ~w/(K+i)² ≈ 2e-4, three orders of
280
+ * magnitude above this threshold, so genuine rank separation always
281
+ * holds and recency only arbitrates candidates whose fused contributions
282
+ * are mathematically indistinguishable (e.g. rank-1 in the keyword leg
283
+ * vs rank-1 in the semantic leg under equal weights, or duplicate
284
+ * facts ingested twice).
285
+ */
286
+ export const RECENCY_EPSILON = 1e-9;
287
+ /**
288
+ * Legacy rank-vote fusion: weighted Reciprocal Rank Fusion over the
289
+ * three legs. Extracted verbatim from the pre-round-4 `fuseResults`
290
+ * body; selected via `fusionMode: "rrf"`.
291
+ */
292
+ function rrfFuse(keywordResults, semanticResults, entityResults, options, imageResults = [], imageLegWeight = 0) {
293
+ const rrfK = options.rrfK ?? DEFAULT_RRF_K;
294
+ const keywordWeight = options.keywordWeight ?? DEFAULT_KEYWORD_WEIGHT;
295
+ const semanticWeight = options.semanticWeight ?? DEFAULT_SEMANTIC_WEIGHT;
296
+ const merged = new Map();
297
+ for (const [index, result] of keywordResults.entries()) {
298
+ const keywordRrf = keywordWeight / (rrfK + index + 1);
299
+ merged.set(result.node.id, {
300
+ node: result.node,
301
+ score: keywordRrf,
302
+ scores: { keyword: 1 - index / Math.max(keywordResults.length, 1) },
303
+ });
304
+ }
305
+ for (const [index, result] of semanticResults.entries()) {
306
+ const semanticRrf = semanticWeight / (rrfK + index + 1);
307
+ const existing = merged.get(result.node.id);
308
+ if (existing) {
309
+ existing.score += semanticRrf;
310
+ existing.scores.semantic = Math.max(0, result.score);
311
+ // RRF stores the RAW cosine in `semantic` (ranks, not magnitudes,
312
+ // drive the vote here). Mirror it into `semanticRaw` so both fusion
313
+ // modes expose the same score shape and consumers need one branch.
314
+ existing.scores.semanticRaw = Math.max(0, result.score);
315
+ existing.scores.hybrid = existing.score;
316
+ }
317
+ else {
318
+ merged.set(result.node.id, {
319
+ node: result.node,
320
+ score: semanticRrf,
321
+ scores: {
322
+ keyword: 0,
323
+ semantic: Math.max(0, result.score),
324
+ semanticRaw: Math.max(0, result.score),
325
+ hybrid: semanticRrf,
326
+ },
327
+ });
328
+ }
329
+ }
330
+ // ── item2: entity leg ── third RRF leg from the entity→memories
331
+ // inverted index (`src/entity-index.ts`). Unlike the multiplicative
332
+ // entity-overlap boost below — which only re-scores candidates the
333
+ // keyword/semantic legs already retrieved — this leg ADDS RECALL:
334
+ // entity-matched memories that FTS and embeddings both missed enter
335
+ // the candidate pool here.
336
+ //
337
+ // Score-breakdown naming: this leg records `scores.entityLeg` (the
338
+ // per-candidate matched-entity count feeding the RRF vote), while the
339
+ // boost below records `scores.entity` (the [0,1] query/candidate
340
+ // entity-overlap multiplier). Same signal family, different roles —
341
+ // recall vs scoring.
342
+ const entityLegWeight = options.entityLegWeight ?? DEFAULT_ENTITY_LEG_WEIGHT;
343
+ if (entityLegWeight > 0 && entityResults.length > 0) {
344
+ for (const [index, result] of entityResults.entries()) {
345
+ const entityRrf = entityLegWeight / (rrfK + index + 1);
346
+ const existing = merged.get(result.node.id);
347
+ if (existing) {
348
+ existing.score += entityRrf;
349
+ existing.scores.entityLeg = Math.max(0, result.score);
350
+ existing.scores.hybrid = existing.score;
351
+ }
352
+ else {
353
+ merged.set(result.node.id, {
354
+ node: result.node,
355
+ score: entityRrf,
356
+ scores: {
357
+ keyword: 0,
358
+ semantic: 0,
359
+ entityLeg: Math.max(0, result.score),
360
+ hybrid: entityRrf,
361
+ },
362
+ });
363
+ }
364
+ }
365
+ }
366
+ // ── image leg (Track B, image memory) ── fourth RRF leg from the
367
+ // CLIP image→text space (`image_embeddings`, scanned by
368
+ // `querySimilarImages`). It ADDS RECALL exactly like the entity leg:
369
+ // an image whose caption shares no vocabulary with the query still
370
+ // enters the candidate pool because its PIXELS match. Records
371
+ // `scores.imageLeg` (the raw CLIP cosine) so a consumer can tell
372
+ // which leg surfaced an image.
373
+ //
374
+ // Inert by construction: an empty leg (no CLIP configured, no image
375
+ // embeddings stored, or Track B disabled) contributes nothing and
376
+ // leaves the merged map byte-identical to the pre-image pipeline.
377
+ if (imageLegWeight > 0 && imageResults.length > 0) {
378
+ for (const [index, result] of imageResults.entries()) {
379
+ const imageRrf = imageLegWeight / (rrfK + index + 1);
380
+ const existing = merged.get(result.node.id);
381
+ if (existing) {
382
+ existing.score += imageRrf;
383
+ existing.scores.imageLeg = Math.max(0, result.score);
384
+ existing.scores.hybrid = existing.score;
385
+ }
386
+ else {
387
+ merged.set(result.node.id, {
388
+ node: result.node,
389
+ score: imageRrf,
390
+ scores: {
391
+ keyword: 0,
392
+ semantic: 0,
393
+ imageLeg: Math.max(0, result.score),
394
+ hybrid: imageRrf,
395
+ },
396
+ });
397
+ }
398
+ }
399
+ }
400
+ return merged;
401
+ }
402
+ /**
403
+ * Min-max normalize one leg's native scores to [0,1].
404
+ *
405
+ * `higherIsBetter=false` inverts the scale (FTS5 bm25 rank: the more
406
+ * negative, the better the match); true for the semantic (cosine) and
407
+ * entity (match-count) legs. A degenerate leg (empty, or all scores
408
+ * identical) maps its candidates to 1.0 — the leg's top pick still
409
+ * counts as a full vote rather than vanishing. Duplicate ids within a
410
+ * leg keep the best normalized score.
411
+ */
412
+ function minMaxNormalizeLeg(results, higherIsBetter) {
413
+ const out = new Map();
414
+ if (results.length === 0)
415
+ return out;
416
+ let min = Infinity;
417
+ let max = -Infinity;
418
+ for (const r of results) {
419
+ if (r.score < min)
420
+ min = r.score;
421
+ if (r.score > max)
422
+ max = r.score;
423
+ }
424
+ for (const r of results) {
425
+ let n;
426
+ if (!(max > min)) {
427
+ n = 1;
428
+ }
429
+ else if (higherIsBetter) {
430
+ n = (r.score - min) / (max - min);
431
+ }
432
+ else {
433
+ n = (max - r.score) / (max - min);
434
+ }
435
+ const prev = out.get(r.node.id);
436
+ if (prev === undefined || n > prev)
437
+ out.set(r.node.id, n);
438
+ }
439
+ return out;
440
+ }
441
+ /**
442
+ * Deterministic query-type adaptive leg weighting (round 4).
443
+ *
444
+ * Tiny rule-based router — no model, no learned parameters, fully
445
+ * explainable:
446
+ * - Short queries (≤3 tokens): BM25 is the reliable leg (embeddings are
447
+ * noisier on keyword-style lookups), so the keyword weight rises.
448
+ * - Entity-dense queries (≥2 extracted entities): the entity leg is
449
+ * high-precision here, so its weight doubles (capped).
450
+ * - Temporal markers (today, yesterday, last week, …): flags the query
451
+ * so `convexFuse` applies the bounded recency multiplier.
452
+ *
453
+ * Weights are renormalized to sum to 1 afterwards. All adjustments are
454
+ * gentle (≤1.25× / ≤2×) so a misfire can only nudge, never dominate.
455
+ * Disabled wholesale via `adaptiveWeighting: false`.
456
+ */
457
+ function adaptLegWeights(query, queryEntities, base) {
458
+ let { keyword, semantic, entityLeg } = base;
459
+ const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
460
+ // Short queries (≤3 tokens) are keyword-style lookups; the keyword
461
+ // leg's magnitude gaps are meaningful here, so it gets more weight.
462
+ if (tokens.length > 0 && tokens.length <= 3) {
463
+ keyword *= 1.25;
464
+ }
465
+ if (queryEntities.length >= 2) {
466
+ entityLeg = Math.min(0.4, entityLeg * 2);
467
+ }
468
+ const temporal = TEMPORAL_QUERY_RE.test(query);
469
+ const sum = keyword + semantic + entityLeg;
470
+ if (sum > 0) {
471
+ keyword /= sum;
472
+ semantic /= sum;
473
+ entityLeg /= sum;
474
+ }
475
+ return { keyword, semantic, entityLeg, temporal };
476
+ }
477
+ /**
478
+ * Convex-combination fusion (round 4 default).
479
+ *
480
+ * Each leg's native scores are min-max normalized to [0,1], then
481
+ * combined as `Σ w_leg · norm_leg` with the (adaptively adjusted,
482
+ * renormalized) leg weights. Unlike RRF's rank votes, a dominant
483
+ * absolute score keeps its magnitude — a near-exact embedding match or
484
+ * a runaway bm25 hit outranks a candidate that merely placed well in a
485
+ * weak leg.
486
+ *
487
+ * The entity leg still ADDS RECALL exactly like the RRF path: normalized
488
+ * entity matches enter the pool even when FTS and embeddings missed
489
+ * them. Temporal queries additionally get the bounded recency
490
+ * multiplier (`1 + strength · 0.5^(age/halfLife)`).
491
+ */
492
+ function convexFuse(keywordResults, semanticResults, entityResults, options, imageResults = [], imageLegWeight = 0) {
493
+ const queryEntities = options.queryEntities ?? [];
494
+ const base = {
495
+ keyword: options.keywordWeight ?? DEFAULT_KEYWORD_WEIGHT,
496
+ semantic: options.semanticWeight ?? DEFAULT_SEMANTIC_WEIGHT,
497
+ entityLeg: options.entityLegWeight ?? DEFAULT_ENTITY_LEG_WEIGHT,
498
+ };
499
+ const adaptive = options.adaptiveWeighting ?? true;
500
+ const weights = adaptive && options.query
501
+ ? adaptLegWeights(options.query, queryEntities, base)
502
+ : {
503
+ keyword: base.keyword,
504
+ semantic: base.semantic,
505
+ entityLeg: base.entityLeg,
506
+ temporal: false,
507
+ };
508
+ // Renormalize even the non-adaptive path: convex combination needs
509
+ // weights on a common scale, unlike RRF's independent rank votes.
510
+ // The image leg joins the same renormalization ONLY when it is active,
511
+ // so a text-only store's wk/ws/we are unchanged to the bit.
512
+ const imageActive = imageResults.length > 0 && imageLegWeight > 0;
513
+ const wSum = weights.keyword +
514
+ weights.semantic +
515
+ weights.entityLeg +
516
+ (imageActive ? imageLegWeight : 0);
517
+ const wk = wSum > 0 ? weights.keyword / wSum : 0;
518
+ const ws = wSum > 0 ? weights.semantic / wSum : 0;
519
+ const we = wSum > 0 ? weights.entityLeg / wSum : 0;
520
+ const wi = wSum > 0 && imageActive ? imageLegWeight / wSum : 0;
521
+ // All legs: min-max on native scores. Proven on the deterministic eval
522
+ // (MRR 0.9688 vs 0.875 for rank-discount keyword): with weak local-hash
523
+ // embeddings the keyword leg's magnitude gaps carry the signal, and
524
+ // min-max preserves them. (bm25 magnitudes are query-dependent, but the
525
+ // eval shows the trade-off favors magnitude-awareness here.)
526
+ const keywordNorm = minMaxNormalizeLeg(keywordResults, false);
527
+ const semanticNorm = minMaxNormalizeLeg(semanticResults, true);
528
+ const entityNorm = minMaxNormalizeLeg(entityResults, true);
529
+ const imageNorm = imageActive
530
+ ? minMaxNormalizeLeg(imageResults, true)
531
+ : new Map();
532
+ const byId = new Map();
533
+ for (const r of keywordResults)
534
+ byId.set(r.node.id, r);
535
+ for (const r of semanticResults)
536
+ byId.set(r.node.id, r);
537
+ for (const r of entityResults)
538
+ byId.set(r.node.id, r);
539
+ for (const r of imageResults)
540
+ byId.set(r.node.id, r);
541
+ const merged = new Map();
542
+ const addLeg = (norm, weight, legName) => {
543
+ if (weight <= 0)
544
+ return;
545
+ for (const [id, n] of norm) {
546
+ const r = byId.get(id);
547
+ if (!r)
548
+ continue;
549
+ const contrib = weight * n;
550
+ const existing = merged.get(id);
551
+ if (existing) {
552
+ existing.score += contrib;
553
+ existing.scores[legName] = n;
554
+ // Preserve the raw leg score alongside the normalized fusion
555
+ // weight: `n` is min-max normalized (top candidate == 1), so it
556
+ // cannot answer "how similar was this actually?". The raw cosine
557
+ // is what `shouldAbstain`'s absolute floor needs.
558
+ if (legName === "semantic") {
559
+ existing.scores.semanticRaw = Math.max(0, r.score);
560
+ }
561
+ existing.scores.hybrid = existing.score;
562
+ }
563
+ else {
564
+ // Scores breakdown mirrors the pre-round-4 shape: a leg's key is
565
+ // present only when the candidate appeared in that leg (never a
566
+ // zero placeholder), so `scores.entityLeg === undefined` still
567
+ // means "the entity leg did not retrieve this candidate".
568
+ merged.set(id, {
569
+ node: r.node,
570
+ score: contrib,
571
+ scores: {
572
+ [legName]: n,
573
+ ...(legName === "semantic"
574
+ ? { semanticRaw: Math.max(0, r.score) }
575
+ : {}),
576
+ hybrid: contrib,
577
+ },
578
+ });
579
+ }
580
+ }
581
+ };
582
+ addLeg(keywordNorm, wk, "keyword");
583
+ addLeg(semanticNorm, ws, "semantic");
584
+ addLeg(entityNorm, we, "entityLeg");
585
+ // Image (CLIP) leg — added last, with its own renormalized weight.
586
+ // Inert when `wi` is 0 (no CLIP, no image rows), so the three-leg
587
+ // result is untouched.
588
+ addLeg(imageNorm, wi, "imageLeg");
589
+ // Temporal recency: bounded multiplicative nudge toward fresh memories,
590
+ // active only for temporal-marker queries. At the default strength
591
+ // (0.05) the maximum boost is 5% for a just-updated memory, decaying
592
+ // with the configured half-life — relevance still dominates.
593
+ const recencyHalfLifeMs = options.recencyHalfLifeMs ?? DEFAULT_RECENCY_HALF_LIFE_MS;
594
+ const temporalStrength = options.temporalRecencyStrength ?? DEFAULT_TEMPORAL_RECENCY_STRENGTH;
595
+ if (adaptive &&
596
+ weights.temporal &&
597
+ temporalStrength > 0 &&
598
+ recencyHalfLifeMs > 0) {
599
+ const nowMs = options.nowMs ?? Date.now();
600
+ for (const entry of merged.values()) {
601
+ const ageMs = Math.max(0, nowMs - entry.node.updatedAt);
602
+ const freshness = Math.pow(0.5, ageMs / recencyHalfLifeMs);
603
+ entry.score *= 1 + temporalStrength * freshness;
604
+ entry.scores.hybrid = entry.score;
605
+ }
606
+ }
607
+ return merged;
608
+ }
609
+ /**
610
+ * Fuse two (optionally three) ranked retrieval lists into one, then apply
611
+ * trust weighting.
612
+ *
613
+ * All legs are expected to be pre-ranked best-first (as returned by FTS5
614
+ * bm25, cosine-similarity search, and the entity-inverted-index lookup).
615
+ * Candidates appearing in multiple legs accumulate each leg's RRF
616
+ * contribution.
617
+ *
618
+ * @param keywordResults — Ranked keyword/FTS5 leg (best first).
619
+ * @param semanticResults — Ranked semantic/embedding leg (best first).
620
+ * @param options — Tunable weights; see {@link FusionOptions}.
621
+ * @param entityResults — Ranked entity-inverted-index leg (best first).
622
+ * Optional third RRF leg: memories sharing entities with the query that
623
+ * the keyword/semantic legs missed still enter the candidate pool.
624
+ * @returns Fused list sorted by descending hybrid score, with a `scores`
625
+ * breakdown (`keyword`, `semantic`, `entityLeg`, `hybrid`, plus `entity`
626
+ * when the entity-overlap boost fires) on each entry.
627
+ */
628
+ export function fuseResults(keywordResults, semanticResults, options = {}, entityResults = [], imageResults = []) {
629
+ const mode = options.fusionMode ?? DEFAULT_FUSION_MODE;
630
+ // The image (CLIP) leg is additive: when it is empty — which is the
631
+ // case for every text-only store, and whenever Track B is off — the
632
+ // fusion below is byte-identical to the pre-image pipeline.
633
+ const imageLegWeight = options.imageLegWeight ??
634
+ // When the caller has not pinned a weight, an ACTIVE leg gets a
635
+ // sane default so Track B actually contributes — otherwise the
636
+ // feature would be off for everyone who did not read the config
637
+ // docs. A store with no image results pays nothing (the weight is
638
+ // never consulted), so this cannot change a text-only install's
639
+ // output.
640
+ (imageResults.length > 0 ? DEFAULT_IMAGE_LEG_WEIGHT : 0);
641
+ const imageActive = imageResults.length > 0 && imageLegWeight > 0;
642
+ const merged = mode === "convex"
643
+ ? convexFuse(keywordResults, semanticResults, entityResults, options, imageActive ? imageResults : [], imageActive ? imageLegWeight : 0)
644
+ : rrfFuse(keywordResults, semanticResults, entityResults, options, imageActive ? imageResults : [], imageActive ? imageLegWeight : 0);
645
+ const trustFloor = options.trustFloor ?? DEFAULT_TRUST_FLOOR;
646
+ const confidenceStrength = options.confidenceWeightStrength ?? DEFAULT_CONFIDENCE_WEIGHT;
647
+ const recencyHalfLifeMs = options.recencyHalfLifeMs ?? DEFAULT_RECENCY_HALF_LIFE_MS;
648
+ const nowMs = options.nowMs ?? Date.now();
649
+ const queryEntities = options.queryEntities ?? [];
650
+ const entityWeight = options.entityWeight ??
651
+ (queryEntities.length > 0 ? DEFAULT_ENTITY_WEIGHT : 0);
652
+ // Entity-fused scoring: a third signal on top of keyword + semantic.
653
+ // Memories whose stored entities / tags overlap the query's entities get
654
+ // a gentle multiplicative boost. No-op unless the caller supplied query
655
+ // entities (hybridSearch extracts them per query). Records
656
+ // `scores.entity` — the overlap ratio — distinct from the
657
+ // `scores.entityLeg` RRF-leg vote above (see the item2 comment).
658
+ if (entityWeight > 0 && queryEntities.length > 0) {
659
+ for (const entry of merged.values()) {
660
+ const overlap = entityOverlap(queryEntities, {
661
+ tags: entry.node.tags,
662
+ metadata: entry.node.metadata,
663
+ });
664
+ if (overlap > 0) {
665
+ entry.score *= 1 + entityWeight * overlap;
666
+ entry.scores.entity = overlap;
667
+ entry.scores.hybrid = entry.score;
668
+ }
669
+ }
670
+ }
671
+ // Trust weighting: a gentle multiplier (trustFloor–1.0) so high-trust
672
+ // memories get a small boost without dominating pure relevance.
673
+ for (const entry of merged.values()) {
674
+ entry.score *=
675
+ trustFloor + (entry.node.trustScore ?? 1.0) * (1 - trustFloor);
676
+ entry.scores.hybrid = entry.score;
677
+ }
678
+ // Provenance-trust weighting: fold the write-time provenance tier into
679
+ // scoring alongside relevance. Low-trust channels (imported,
680
+ // tool-output, chat) are gently down-ranked via
681
+ // `score *= 1 - strength * (1 - tierTrust)` — the maximum penalty at
682
+ // the default strength (0.5) is ~14% for the imported tier, so
683
+ // relevance ordering dominates unless channels genuinely differ.
684
+ // Uniform across same-tier result sets, so single-channel corpora
685
+ // (including the retrieval eval) keep byte-identical ranking —
686
+ // neutral-or-better by construction. Records `scores.provenance`.
687
+ const provenanceStrength = options.provenanceWeightStrength ?? DEFAULT_PROVENANCE_WEIGHT_STRENGTH;
688
+ if (provenanceStrength > 0) {
689
+ for (const entry of merged.values()) {
690
+ // Nodes without a tier (legacy fixtures, custom adapters that
691
+ // predate the layer) are left exactly neutral — the weighting is
692
+ // additive and never rewrites historical scores.
693
+ const tier = entry.node.provenance;
694
+ if (!isProvenanceTier(tier))
695
+ continue;
696
+ const multiplier = provenanceMultiplier(tier, provenanceStrength);
697
+ entry.score *= multiplier;
698
+ entry.scores.provenance = multiplier;
699
+ entry.scores.hybrid = entry.score;
700
+ }
701
+ }
702
+ // Confidence-aware ranking: fold in the evidence state machine. A
703
+ // memory that has been repeatedly reinforced ranks slightly above an
704
+ // equally-relevant but contradicted/superseded one. `confidenceWeight`
705
+ // returns sqrt(trust * confidence) and crushes anything below the
706
+ // confidence floor to ~10%, which is exactly the behavior wanted for
707
+ // "Knowledge Update" / "Contradiction Resolution" queries: the stale
708
+ // version of a fact should not outrank its current version when both
709
+ // match. Blended with `confidenceStrength` so pure relevance still
710
+ // dominates unless evidence is strongly against.
711
+ if (confidenceStrength > 0) {
712
+ for (const entry of merged.values()) {
713
+ const combined = confidenceWeight(entry.node.trustScore ?? 1.0, entry.node.confidence);
714
+ const multiplier = 1 - confidenceStrength + confidenceStrength * (combined / 1.0);
715
+ entry.score *= multiplier;
716
+ entry.scores.hybrid = entry.score;
717
+ }
718
+ }
719
+ // Recency tie-break: among candidates whose fused scores are exactly
720
+ // tied (within float noise — see RECENCY_EPSILON), prefer the more
721
+ // recently updated memory. A genuine rank difference is three orders
722
+ // of magnitude above the epsilon window, so this can never reorder
723
+ // meaningfully-separated candidates; it only makes tie order
724
+ // deterministic and knowledge-update-friendly (the current version of
725
+ // a fact surfaces before its stale duplicate).
726
+ if (recencyHalfLifeMs > 0) {
727
+ const recencyBoost = (updatedAt) => {
728
+ const ageMs = Math.max(0, nowMs - updatedAt);
729
+ return Math.pow(0.5, ageMs / recencyHalfLifeMs); // 1.0 → fresh … → 0.0
730
+ };
731
+ const ranked = [...merged.values()].sort(compareScoredMemories);
732
+ for (let i = 1; i < ranked.length; i += 1) {
733
+ const current = ranked[i];
734
+ const prev = ranked[i - 1];
735
+ // `ranked` is sorted descending, so the difference is >= 0; the
736
+ // epsilon check admits both exact ties and float-noise trails.
737
+ if (prev.score - current.score <= RECENCY_EPSILON &&
738
+ // Only swap when the lower-scored candidate is actually FRESHER
739
+ // — an older candidate trailing within epsilon stays put.
740
+ current.node.updatedAt > prev.node.updatedAt) {
741
+ const boost = recencyBoost(current.node.updatedAt);
742
+ if (boost > 0) {
743
+ // Nudge by a fraction of the epsilon window proportional to
744
+ // freshness — enough to swap the tie, never enough to leapfrog
745
+ // a candidate outside the epsilon band.
746
+ current.score += RECENCY_EPSILON * boost * 0.5;
747
+ current.scores.hybrid = current.score;
748
+ }
749
+ }
750
+ }
751
+ }
752
+ return [...merged.values()].sort(compareScoredMemories).map((entry) => ({
753
+ node: entry.node,
754
+ score: entry.score,
755
+ scores: entry.scores,
756
+ }));
757
+ }
758
+ /* ------------------------------------------------------------------ *
759
+ * Relevance gating: abstention + tail trimming
760
+ *
761
+ * Two different problems, one knob each.
762
+ *
763
+ * 1. ABSTENTION. When nothing in the store is genuinely about the
764
+ * query, the honest answer is "no memories" — not "the ten least
765
+ * bad rows". A memory layer that always returns k results forces
766
+ * the caller to invent an answer. LongMemEval scores this directly
767
+ * through its `_abs` questions (events that never happened) and
768
+ * LoCoMo through its adversarial category, which is why this lives
769
+ * here rather than in the caller.
770
+ *
771
+ * 2. TAIL TRIMMING. Precision@10 measured 0.099 on LoCoMo: about nine
772
+ * of every ten injected items were noise. Dropping candidates that
773
+ * trail the top score by a large factor costs little recall and
774
+ * sharply improves the signal-to-noise of a context pack.
775
+ * ------------------------------------------------------------------ */
776
+ /**
777
+ * Default tail-trim threshold: a candidate is kept when its fused score
778
+ * is at least this fraction of the top score. Deliberately low — it
779
+ * exists to cut the long tail of "matched one stopword" rows, not to
780
+ * make fine-grained relevance decisions.
781
+ */
782
+ export const DEFAULT_RELEVANCE_RATIO = 0.15;
783
+ /**
784
+ * Default abstention floor on the semantic leg's cosine similarity.
785
+ *
786
+ * 0.3 is not arbitrary: `SEMANTIC_FUSION_THRESHOLD` and the fusion
787
+ * comments already treat ~0.3 as the point where a real embedder's
788
+ * paraphrase matches begin. Below it, a hit is coincidence.
789
+ *
790
+ * This is OPT-IN (`filter.abstainBelowSimilarity`) because the floor is
791
+ * embedder-dependent — a 256-d hash embedder and a 1024-d transformer
792
+ * do not share a cosine scale. Calibrate per embedder before enabling.
793
+ */
794
+ export const DEFAULT_ABSTAIN_SIMILARITY = 0.3;
795
+ /** Compact English function-word list for query-expansion filtering. */
796
+ const STOPWORDS = new Set([
797
+ "the",
798
+ "and",
799
+ "for",
800
+ "with",
801
+ "that",
802
+ "this",
803
+ "from",
804
+ "have",
805
+ "has",
806
+ "had",
807
+ "was",
808
+ "were",
809
+ "are",
810
+ "you",
811
+ "your",
812
+ "they",
813
+ "them",
814
+ "their",
815
+ "there",
816
+ "here",
817
+ "what",
818
+ "when",
819
+ "where",
820
+ "which",
821
+ "who",
822
+ "whom",
823
+ "how",
824
+ "why",
825
+ "did",
826
+ "does",
827
+ "doing",
828
+ "would",
829
+ "could",
830
+ "should",
831
+ "about",
832
+ "into",
833
+ "than",
834
+ "then",
835
+ "them",
836
+ "some",
837
+ "any",
838
+ "all",
839
+ "one",
840
+ "two",
841
+ "also",
842
+ "just",
843
+ "like",
844
+ "been",
845
+ "being",
846
+ "over",
847
+ "under",
848
+ "more",
849
+ "most",
850
+ "much",
851
+ "many",
852
+ "very",
853
+ "will",
854
+ "can",
855
+ "not",
856
+ "but",
857
+ "she",
858
+ "him",
859
+ "her",
860
+ "his",
861
+ "its",
862
+ "our",
863
+ "out",
864
+ "get",
865
+ "got",
866
+ "say",
867
+ "said",
868
+ ]);
869
+ /** Lowercase alphanumeric tokens — shared by gating and expansion. */
870
+ function tokenize(text) {
871
+ return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
872
+ }
873
+ /**
874
+ * True when any candidate literally contains a query term.
875
+ *
876
+ * Guards abstention against its main failure mode: a bullseye exact-term
877
+ * hit (a rare proper noun, a product name) can score a low cosine when
878
+ * the embedder barely knows the word. Abstaining there would throw away
879
+ * a perfect answer, so a literal match always wins over the cosine test.
880
+ */
881
+ export function hasExactTermMatch(query, results) {
882
+ const terms = tokenize(query).filter((term) => term.length >= 3);
883
+ if (terms.length === 0)
884
+ return false;
885
+ for (const result of results) {
886
+ const haystack = `${result.node.summary ?? ""} ${result.node.content ?? ""}`.toLowerCase();
887
+ for (const term of terms) {
888
+ if (haystack.includes(term))
889
+ return true;
890
+ }
891
+ }
892
+ return false;
893
+ }
894
+ /**
895
+ * Decide whether retrieval found anything worth returning at all.
896
+ *
897
+ * Conservative by construction — three independent escape hatches, each
898
+ * of which suppresses abstention:
899
+ *
900
+ * - no floor configured, or no results at all → never abstain;
901
+ * - no candidate carries a semantic score (keyword-only retrieval, or no
902
+ * embedding provider) → cannot judge similarity, so never abstain;
903
+ * - any candidate reaches the cosine floor → clearly relevant, return.
904
+ *
905
+ * Only when every candidate is below the floor do we abstain, and even
906
+ * then a literal term match rescues the result set.
907
+ *
908
+ * **Reads `scores.semanticRaw`, never `scores.semantic`.** Convex fusion
909
+ * (the default mode) min-max-normalizes each leg, so `scores.semantic`
910
+ * holds a fusion weight whose top candidate is always exactly `1` —
911
+ * comparing that against an absolute floor can never fail, which made
912
+ * abstention dead code on the default path. `semanticRaw` carries the
913
+ * unnormalized cosine on the embedder's own scale. The fallback to
914
+ * `scores.semantic` is for hand-built results and custom fusion paths
915
+ * that predate the raw field; those already store a raw value in
916
+ * `semantic` (see `rrfFuse`).
917
+ */
918
+ export function shouldAbstain(results, query, options = {}) {
919
+ const floor = options.abstainBelowSimilarity;
920
+ if (floor === undefined || results.length === 0)
921
+ return false;
922
+ const similarities = [];
923
+ for (const result of results) {
924
+ const similarity = result.scores?.semanticRaw ??
925
+ result.scores?.semantic ??
926
+ // The CLIP image leg is a genuine relevance signal too: an image
927
+ // matched by pixels carries a real cosine in ITS space. Without
928
+ // this, an image surfaced only by the image leg looks like it has
929
+ // no embedding evidence at all and the gate abstains on it — the
930
+ // one case where abstention is plainly wrong, because something
931
+ // DID match.
932
+ result.scores?.imageLeg;
933
+ if (typeof similarity === "number")
934
+ similarities.push(similarity);
935
+ }
936
+ // No embedding signal anywhere: cannot judge, so do not abstain.
937
+ if (similarities.length === 0)
938
+ return false;
939
+ if (Math.max(...similarities) >= floor)
940
+ return false;
941
+ return !hasExactTermMatch(query, results);
942
+ }
943
+ /**
944
+ * Apply abstention and tail trimming to a fused, best-first result list.
945
+ *
946
+ * Pure and dependency-free (like {@link fuseResults}) so it can be unit
947
+ * tested without storage, embeddings, or a MemOS instance.
948
+ */
949
+ export function applyRelevanceGate(results, query, options = {}) {
950
+ if (results.length === 0)
951
+ return results;
952
+ if (shouldAbstain(results, query, options))
953
+ return [];
954
+ const ratio = options.relevanceRatio;
955
+ if (ratio === undefined || ratio <= 0)
956
+ return results;
957
+ const top = results[0].score;
958
+ if (top <= 0)
959
+ return results;
960
+ const floor = top * ratio;
961
+ return results.filter((result) => result.score >= floor);
962
+ }
963
+ /* ------------------------------------------------------------------ *
964
+ * Pseudo-relevance feedback (PRF) query expansion
965
+ *
966
+ * Open-domain questions are memos' weakest LoCoMo category (hit@10
967
+ * 0.533). They are phrased with vocabulary the stored memories never
968
+ * use, so the keyword leg misses and a small embedding model has little
969
+ * to work with. PRF is the classic LLM-free fix: take the top few
970
+ * candidates from a first pass on faith, harvest their distinctive
971
+ * vocabulary, and re-run the query with those terms appended.
972
+ *
973
+ * Blindly trusting the first pass is what makes this risky — hence
974
+ * opt-in via `filter.queryExpansion`, and the two result lists are
975
+ * RRF-fused so a bad expansion can only dilute, never replace, the
976
+ * original ranking.
977
+ * ------------------------------------------------------------------ */
978
+ /**
979
+ * Build an expanded query by harvesting distinctive terms from the top
980
+ * `maxDocs` candidates of a first retrieval pass.
981
+ *
982
+ * Scores terms by DOCUMENT FREQUENCY across the top candidates rather
983
+ * than raw term frequency, so one long memory cannot dominate the
984
+ * expansion. Returns `""` when nothing was harvested, which callers
985
+ * treat as "skip the second pass".
986
+ */
987
+ export function expandQueryWithPseudoRelevance(query, results, options = {}) {
988
+ const maxDocs = options.maxDocs ?? 3;
989
+ const maxTerms = options.maxTerms ?? 8;
990
+ if (!query.trim() || results.length === 0)
991
+ return "";
992
+ const queryTerms = new Set(tokenize(query));
993
+ const docFrequency = new Map();
994
+ for (const result of results.slice(0, maxDocs)) {
995
+ const text = `${result.node.summary ?? ""} ${result.node.content ?? ""}`;
996
+ const seenInDoc = new Set();
997
+ for (const term of tokenize(text)) {
998
+ if (term.length < 3)
999
+ continue;
1000
+ if (STOPWORDS.has(term))
1001
+ continue;
1002
+ if (queryTerms.has(term))
1003
+ continue;
1004
+ if (seenInDoc.has(term))
1005
+ continue;
1006
+ seenInDoc.add(term);
1007
+ docFrequency.set(term, (docFrequency.get(term) ?? 0) + 1);
1008
+ }
1009
+ }
1010
+ const harvested = [...docFrequency.entries()]
1011
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1012
+ .slice(0, maxTerms)
1013
+ .map(([term]) => term);
1014
+ if (harvested.length === 0)
1015
+ return "";
1016
+ return `${query} ${harvested.join(" ")}`;
1017
+ }
1018
+ //# sourceMappingURL=retrieval.js.map