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/fidelity.js ADDED
@@ -0,0 +1,911 @@
1
+ /**
2
+ * Fidelity-level compaction (L0–L3) + query-adaptive level-aware retrieval.
3
+ *
4
+ * Every memory exists at four fidelity levels:
5
+ * - L0: tags + entities only (~tens of tokens). Derived free from the
6
+ * existing entity index/tags — nothing is stored.
7
+ * - L1: typed facts — short structured statements extracted at write /
8
+ * consolidation time. Deterministic and template-based (no LLM):
9
+ * entity-bearing sentences from the existing extraction pipeline
10
+ * (`extractQueryEntities` + `canonicalizeEntities`), prefixed with
11
+ * the memory's type.
12
+ * - L2: extractive summary — sentence selection by centrality
13
+ * (TextRank-lite over sentence word-overlap, no LLM). A future
14
+ * sleep-time LLM pass may upgrade it to abstractive; the
15
+ * extractive path always works with zero LLM.
16
+ * - L3: verbatim full text (the existing `content`).
17
+ *
18
+ * The retriever picks the CHEAPEST level that satisfies the query instead
19
+ * of always returning verbatim: `routeFidelity()` chooses a starting level
20
+ * from cheap local signals (entity density, query shape, length), and
21
+ * `MemOS.recall()` escalates L0→L1→L2→L3 within the same call when the
22
+ * result set looks insufficient — up to a caller-specified `maxFidelity`.
23
+ *
24
+ * Why this is novel: OpenViking ships fixed tiered loading (L0→L1→L2
25
+ * ladder) but requires LLM/VLM + embeddings and is filesystem-based with
26
+ * a static ladder; MemPalace's L0–L3 is a fixed wake-up loader. This is
27
+ * zero-LLM, SQLite, per-query adaptive fidelity selection with in-call
28
+ * escalation.
29
+ *
30
+ * Level persistence: L1/L2 are generated at write time (`MemOS.store`)
31
+ * and cached in the reserved `metadata.fidelity` slot (`{ v, l1, l2 }`)
32
+ * so they travel with the node on every storage adapter with zero schema
33
+ * migration. `MemOS.compact()` backfills pre-existing memories eagerly;
34
+ * reads also generate lazily when the cache is missing (fail-soft, no
35
+ * writes on the hot path).
36
+ *
37
+ * Default-behavior contract: every public API defaults to L3-equivalent
38
+ * output (verbatim), so existing tests and consumers see byte-identical
39
+ * behavior unless they opt into `fidelity: "adaptive"` or a fixed lower
40
+ * level. Adaptive is opt-in because level selection changes *which text*
41
+ * is returned, and that must be a deliberate caller choice.
42
+ *
43
+ * @module @memos/fidelity
44
+ */
45
+ import { BUILTIN_ENTITY_ALIASES, canonicalizeEntities, extractQueryEntities, } from "./entity-extraction.js";
46
+ /** Ordered levels for escalation loops. */
47
+ export const FIDELITY_LEVELS = [
48
+ "L0",
49
+ "L1",
50
+ "L2",
51
+ "L3",
52
+ ];
53
+ /** Human-readable label per level (CLI/docs). */
54
+ export const FIDELITY_LEVEL_LABELS = {
55
+ L0: "tags + entities",
56
+ L1: "typed facts",
57
+ L2: "extractive summary",
58
+ L3: "verbatim",
59
+ };
60
+ /** One-line description per level (CLI/docs). */
61
+ export const FIDELITY_LEVEL_DESCRIPTIONS = {
62
+ L0: "Tags and extracted entities only — free, derived from the entity index.",
63
+ L1: "Short typed fact statements ([type] sentence), template-extracted at write time.",
64
+ L2: "Extractive summary: top sentences by TextRank-lite centrality.",
65
+ L3: "Full verbatim content — the pre-fidelity default.",
66
+ };
67
+ /** Numeric rank for ordering/clamping. */
68
+ const LEVEL_RANK = {
69
+ L0: 0,
70
+ L1: 1,
71
+ L2: 2,
72
+ L3: 3,
73
+ };
74
+ /** Next level up, or null at the top. */
75
+ export function nextFidelityLevel(level) {
76
+ const idx = LEVEL_RANK[level];
77
+ return idx < FIDELITY_LEVELS.length - 1 ? FIDELITY_LEVELS[idx + 1] : null;
78
+ }
79
+ /** Clamp a level to at most `max` (escalation ceiling). */
80
+ export function clampFidelityLevel(level, max) {
81
+ return LEVEL_RANK[level] <= LEVEL_RANK[max] ? level : max;
82
+ }
83
+ /** Parse a user-supplied level string; null when invalid. */
84
+ export function parseFidelityLevel(value) {
85
+ const upper = value.trim().toUpperCase();
86
+ return FIDELITY_LEVELS.includes(upper)
87
+ ? upper
88
+ : null;
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Sentence utilities
92
+ // ---------------------------------------------------------------------------
93
+ /** Small stopword set for sentence scoring (independent of other modules). */
94
+ const FIDELITY_STOPWORDS = new Set([
95
+ "a",
96
+ "an",
97
+ "the",
98
+ "is",
99
+ "are",
100
+ "was",
101
+ "were",
102
+ "be",
103
+ "been",
104
+ "being",
105
+ "have",
106
+ "has",
107
+ "had",
108
+ "do",
109
+ "does",
110
+ "did",
111
+ "will",
112
+ "would",
113
+ "shall",
114
+ "should",
115
+ "may",
116
+ "might",
117
+ "must",
118
+ "can",
119
+ "could",
120
+ "to",
121
+ "of",
122
+ "in",
123
+ "for",
124
+ "on",
125
+ "with",
126
+ "at",
127
+ "by",
128
+ "from",
129
+ "as",
130
+ "into",
131
+ "through",
132
+ "and",
133
+ "but",
134
+ "or",
135
+ "not",
136
+ "it",
137
+ "its",
138
+ "this",
139
+ "that",
140
+ "these",
141
+ "those",
142
+ "i",
143
+ "you",
144
+ "we",
145
+ "they",
146
+ "he",
147
+ "she",
148
+ ]);
149
+ /**
150
+ * Split text into sentences. Newlines become spaces; splits on `.`/`!`/`?`
151
+ * followed by whitespace (lookbehind keeps the terminator). Deterministic.
152
+ */
153
+ export function splitSentences(text) {
154
+ return text
155
+ .replace(/\n+/g, " ")
156
+ .split(/(?<=[.!?])\s+/)
157
+ .map((s) => s.trim())
158
+ .filter((s) => s.length > 0);
159
+ }
160
+ /** Lowercase word tokens for scoring (len >= 2, stopwords removed). */
161
+ function contentWords(sentence) {
162
+ return sentence
163
+ .toLowerCase()
164
+ .replace(/[^a-z0-9\s]/g, " ")
165
+ .split(/\s+/)
166
+ .filter((w) => w.length >= 2 && !FIDELITY_STOPWORDS.has(w));
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // L0 — tags + entities (free derivation)
170
+ // ---------------------------------------------------------------------------
171
+ /** Stored entities on a node (`metadata.entities`), lowercased + deduped. */
172
+ export function nodeEntities(node) {
173
+ const raw = node.metadata?.entities;
174
+ const out = [];
175
+ const seen = new Set();
176
+ if (Array.isArray(raw)) {
177
+ for (const e of raw) {
178
+ const s = String(e).toLowerCase().trim();
179
+ if (s && !seen.has(s)) {
180
+ seen.add(s);
181
+ out.push(s);
182
+ }
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ /**
188
+ * L0 text: tags and entities only. Derived free from the existing entity
189
+ * index/tags — nothing is generated or stored. Returns "" when the memory
190
+ * has neither (such memories can only be reached at L1+).
191
+ */
192
+ export function fidelityL0(node) {
193
+ const parts = [];
194
+ const tags = (node.tags ?? []).map((t) => String(t).trim()).filter(Boolean);
195
+ if (tags.length > 0)
196
+ parts.push(`tags: ${tags.join(", ")}`);
197
+ const entities = nodeEntities(node);
198
+ if (entities.length > 0)
199
+ parts.push(`entities: ${entities.join(", ")}`);
200
+ return parts.join(" | ");
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // L1 — typed facts (deterministic, template-based, no LLM)
204
+ // ---------------------------------------------------------------------------
205
+ /** Max facts per memory at L1. */
206
+ const L1_MAX_FACTS = 3;
207
+ /** Max chars per fact (hard truncate with ellipsis). */
208
+ const L1_MAX_FACT_CHARS = 220;
209
+ /** Short type label used as the `[type]` prefix on each fact. */
210
+ function typeLabel(type) {
211
+ return type === "custom" ? "note" : type;
212
+ }
213
+ function truncateFact(s) {
214
+ const t = s.trim();
215
+ return t.length > L1_MAX_FACT_CHARS
216
+ ? `${t.slice(0, L1_MAX_FACT_CHARS - 1).trimEnd()}…`
217
+ : t;
218
+ }
219
+ /**
220
+ * L1 text: typed fact statements. Entity-bearing sentences (matched
221
+ * against the memory's own tags + stored entities via the existing
222
+ * extraction pipeline) become `[type] sentence` facts; when no sentence
223
+ * carries an entity, the lead sentence stands in. Deterministic,
224
+ * template-based — no LLM.
225
+ *
226
+ * Falls back to the verbatim content when the facts would cost as much
227
+ * as the content itself, keeping token counts non-increasing L1 ≤ L3.
228
+ */
229
+ export function fidelityL1(node) {
230
+ const sentences = splitSentences(node.content).filter((s) => s.length > 10);
231
+ if (sentences.length === 0)
232
+ return node.content;
233
+ // Lookup vocabulary: the memory's own tags + stored entities
234
+ // (extracted at write time by the existing pipeline). Legacy nodes
235
+ // without stored entities get them extracted on the fly.
236
+ const vocab = new Set();
237
+ for (const t of node.tags ?? [])
238
+ vocab.add(String(t).toLowerCase());
239
+ let entities = nodeEntities(node);
240
+ if (entities.length === 0 && node.tags.length === 0) {
241
+ entities = canonicalizeEntities(extractQueryEntities(node.content));
242
+ }
243
+ for (const e of entities)
244
+ vocab.add(e);
245
+ const label = typeLabel(node.type);
246
+ const facts = [];
247
+ for (const sentence of sentences) {
248
+ if (facts.length >= L1_MAX_FACTS)
249
+ break;
250
+ const sentenceEntities = canonicalizeEntities(extractQueryEntities(sentence));
251
+ let matched = sentenceEntities.some((e) => vocab.has(e));
252
+ if (!matched) {
253
+ const lower = sentence.toLowerCase();
254
+ for (const v of vocab) {
255
+ if (v.length >= 3 && lower.includes(v)) {
256
+ matched = true;
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ if (matched)
262
+ facts.push(`[${label}] ${truncateFact(sentence)}`);
263
+ }
264
+ if (facts.length === 0) {
265
+ facts.push(`[${label}] ${truncateFact(sentences[0])}`);
266
+ }
267
+ const text = facts.join(" ");
268
+ // Never let the "compressed" level cost more than verbatim.
269
+ return text.length >= node.content.length ? node.content : text;
270
+ }
271
+ // ---------------------------------------------------------------------------
272
+ // L2 — extractive summary by sentence centrality (TextRank-lite, no LLM)
273
+ // ---------------------------------------------------------------------------
274
+ /** PageRank damping factor (TextRank default). */
275
+ const TEXTRANK_DAMPING = 0.85;
276
+ /** PageRank iterations — converges for summary-sized graphs. */
277
+ const TEXTRANK_ITERATIONS = 25;
278
+ /** Max chars for the L2 summary (sentence-boundary respected). */
279
+ const L2_MAX_CHARS = 600;
280
+ /** Content at or below this length is already summary-sized: L2 = L3. */
281
+ const L2_PASSTHROUGH_CHARS = 280;
282
+ /**
283
+ * Information-dense predicates: a sentence carrying one of these
284
+ * holds a value, a date, or a polarity constraint — the token the
285
+ * user actually asked about. Centrality (word overlap with *other*
286
+ * sentences) is structurally blind to them: a sentence that shares
287
+ * vocabulary with nothing scores LOW on centrality precisely
288
+ * BECAUSE it is unique, so TextRank-lite deletes the one sentence
289
+ * that carries the number. `fidelityL2` reserves take-slots for
290
+ * these sentences before backfilling by centrality.
291
+ */
292
+ const DENSE_PATTERNS = [
293
+ /\d/, // any digit: "120 connections", "March 3rd", "v2.1"
294
+ /\b(not|never|no|none|neither|nor|without|except)\b/i, // negation
295
+ /\b(only|must|required)\b/i, // necessity constraints
296
+ ];
297
+ /** True when the sentence carries a number, date, or negation. */
298
+ export function isInformationDense(sentence) {
299
+ return DENSE_PATTERNS.some((re) => re.test(sentence));
300
+ }
301
+ /**
302
+ * TextRank-lite sentence centrality: similarity(i, j) =
303
+ * |words_i ∩ words_j| / (log|words_i| + log|words_j|) (Mihalcea & Tarau
304
+ * 2004), then PageRank over the similarity graph. Pure function, zero
305
+ * LLM. Returns sentences ranked best-first (ties: original order).
306
+ */
307
+ export function rankSentencesByCentrality(sentences) {
308
+ const n = sentences.length;
309
+ if (n <= 2)
310
+ return [...sentences];
311
+ const wordSets = sentences.map((s) => new Set(contentWords(s)));
312
+ // Similarity matrix (symmetric).
313
+ const sim = Array.from({ length: n }, () => new Array(n).fill(0));
314
+ for (let i = 0; i < n; i += 1) {
315
+ for (let j = i + 1; j < n; j += 1) {
316
+ const a = wordSets[i];
317
+ const b = wordSets[j];
318
+ if (a.size === 0 || b.size === 0)
319
+ continue;
320
+ let inter = 0;
321
+ for (const w of a)
322
+ if (b.has(w))
323
+ inter += 1;
324
+ const denom = Math.log(a.size) + Math.log(b.size);
325
+ const s = denom > 0 ? inter / denom : 0;
326
+ sim[i][j] = s;
327
+ sim[j][i] = s;
328
+ }
329
+ }
330
+ const outWeight = sim.map((row) => row.reduce((x, y) => x + y, 0));
331
+ let scores = new Array(n).fill(1 / n);
332
+ for (let iter = 0; iter < TEXTRANK_ITERATIONS; iter += 1) {
333
+ const next = new Array(n).fill(1 - TEXTRANK_DAMPING);
334
+ for (let i = 0; i < n; i += 1) {
335
+ for (let j = 0; j < n; j += 1) {
336
+ if (i === j || outWeight[j] === 0)
337
+ continue;
338
+ next[i] +=
339
+ TEXTRANK_DAMPING * (sim[j][i] / outWeight[j]) * scores[j];
340
+ }
341
+ }
342
+ scores = next;
343
+ }
344
+ return sentences
345
+ .map((s, i) => ({ s, i, score: scores[i] }))
346
+ .sort((a, b) => b.score - a.score || a.i - b.i)
347
+ .map((e) => e.s);
348
+ }
349
+ /**
350
+ * L2 text: extractive summary — the top central sentences in original
351
+ * order, capped at ~600 chars. Short content passes through unchanged
352
+ * (nothing to compress). Never longer than verbatim, so L2 ≤ L3.
353
+ *
354
+ * A sleep-time LLM pass may later UPGRADE this slot to an abstractive
355
+ * summary; the extractive path here is the guaranteed zero-LLM floor.
356
+ */
357
+ export function fidelityL2(node) {
358
+ const content = node.content;
359
+ if (content.length <= L2_PASSTHROUGH_CHARS)
360
+ return content;
361
+ const sentences = splitSentences(content).filter((s) => s.length > 10);
362
+ if (sentences.length <= 2)
363
+ return content;
364
+ const ranked = rankSentencesByCentrality(sentences);
365
+ const take = content.length > 1500 ? 3 : 2;
366
+ // Reserve slots for sentences centrality is blind to.
367
+ //
368
+ // Centrality ranks a sentence by how much vocabulary it SHARES
369
+ // with the rest, so the sentence holding the number/date/negation —
370
+ // unique by construction — is structurally LAST on centrality and
371
+ // gets dropped by the take. Same for the note's final sentence,
372
+ // which usually carries the conclusion ("so what") and is the one
373
+ // the char-cap trim below drops first.
374
+ //
375
+ // So: fill reserved slots first (highest-ranked dense sentence,
376
+ // then the final sentence), then backfill by centrality.
377
+ const last = sentences[sentences.length - 1];
378
+ const dense = ranked.filter(isInformationDense);
379
+ const picked = new Set();
380
+ for (const d of dense) {
381
+ if (picked.size >= take)
382
+ break;
383
+ picked.add(d);
384
+ }
385
+ if (!picked.has(last) && picked.size < take) {
386
+ picked.add(last);
387
+ }
388
+ for (const s of ranked) {
389
+ if (picked.size >= take)
390
+ break;
391
+ picked.add(s);
392
+ }
393
+ // Emit in ORIGINAL order (narrative coherence beats rank order).
394
+ const ordered = sentences.filter((s) => picked.has(s));
395
+ let text = ordered.join(" ");
396
+ if (text.length > L2_MAX_CHARS) {
397
+ // Trim at a sentence boundary: drop the last-picked sentence
398
+ // first — but never a dense one, which carries the value the
399
+ // caller asked about.
400
+ const kept = ordered.slice(0, -1).filter((s) => !isInformationDense(s));
401
+ text =
402
+ kept.length > 0 && kept.length <= L2_MAX_CHARS
403
+ ? kept.join(" ")
404
+ : `${text.slice(0, L2_MAX_CHARS - 1).trimEnd()}…`;
405
+ }
406
+ return text.length >= content.length ? content : text;
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // Level cache (metadata.fidelity) + accessor
410
+ // ---------------------------------------------------------------------------
411
+ /**
412
+ * Reserved metadata slot for cached L1/L2 text: `{ v: 1, l1, l2 }`.
413
+ * L0 is always derived free; L3 is the content column.
414
+ */
415
+ const FIDELITY_META_KEY = "fidelity";
416
+ const FIDELITY_CACHE_VERSION = 1;
417
+ function readFidelityCache(node) {
418
+ const raw = node.metadata?.[FIDELITY_META_KEY];
419
+ if (typeof raw === "object" &&
420
+ raw !== null &&
421
+ raw.v === FIDELITY_CACHE_VERSION) {
422
+ return raw;
423
+ }
424
+ return null;
425
+ }
426
+ /**
427
+ * Stamp generated L1/L2 text into the node's reserved metadata slot
428
+ * (mutates the passed node object). Called at write time and by
429
+ * `MemOS.compact()` backfill.
430
+ */
431
+ export function stampFidelityCache(node) {
432
+ const l1 = fidelityL1(node);
433
+ const l2 = fidelityL2(node);
434
+ const metadata = (node.metadata ?? {});
435
+ metadata[FIDELITY_META_KEY] = {
436
+ v: FIDELITY_CACHE_VERSION,
437
+ l1,
438
+ l2,
439
+ };
440
+ node.metadata = metadata;
441
+ return { l1, l2 };
442
+ }
443
+ /** True when the node already carries cached L1/L2 text. */
444
+ export function hasFidelityCache(node) {
445
+ const cache = readFidelityCache(node);
446
+ return (!!cache && typeof cache.l1 === "string" && typeof cache.l2 === "string");
447
+ }
448
+ /**
449
+ * Text of a memory at a fidelity level. L1/L2 prefer the write-time
450
+ * cache and generate lazily (pure, no writes) when missing.
451
+ *
452
+ * Image memories append their `[img:<ref>]` token at EVERY level. The
453
+ * lower levels exist to save tokens (L0 is a tag/entity soup, L1 is
454
+ * typed facts), and none of them carries `node.content` — so without
455
+ * this append a memory findable at L0 would return prose with no way
456
+ * to reach the image it describes. A memory that can be found but whose
457
+ * image cannot be shown is a broken memory, so the token rides at every
458
+ * level. Text nodes are untouched: no token is appended and their
459
+ * rendered bytes are identical to before.
460
+ */
461
+ export function levelText(node, level) {
462
+ const rendered = renderLevel(node, level);
463
+ const imageRef = imageRefToken(node);
464
+ return imageRef ? `${rendered} ${imageRef}` : rendered;
465
+ }
466
+ /** The `[img:<ref>]` token for an image node, or "" for any other node. */
467
+ function imageRefToken(node) {
468
+ const raw = node.metadata?.image;
469
+ if (!raw || typeof raw !== "object")
470
+ return "";
471
+ const ref = raw.ref;
472
+ return typeof ref === "string" && ref.length > 0 ? `[img:${ref}]` : "";
473
+ }
474
+ /** The level renderer itself, before the image token is appended. */
475
+ function renderLevel(node, level) {
476
+ switch (level) {
477
+ case "L0":
478
+ return fidelityL0(node);
479
+ case "L1": {
480
+ const cached = readFidelityCache(node)?.l1;
481
+ return typeof cached === "string" ? cached : fidelityL1(node);
482
+ }
483
+ case "L2": {
484
+ const cached = readFidelityCache(node)?.l2;
485
+ return typeof cached === "string" ? cached : fidelityL2(node);
486
+ }
487
+ case "L3":
488
+ return node.content;
489
+ }
490
+ }
491
+ // ---------------------------------------------------------------------------
492
+ // Level-corpus scorer (local BM25-lite, [0,1] coverage)
493
+ // ---------------------------------------------------------------------------
494
+ /**
495
+ * Tokenize for the coverage scorer: lowercase, len ≥ 2, stopwords out,
496
+ * canonicalized through the entity alias table so "postgres" matches
497
+ * "postgresql" ("db" matches "database", …). Deterministic.
498
+ */
499
+ function scorerTerms(text) {
500
+ const terms = [];
501
+ const seen = new Set();
502
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
503
+ if (raw.length < 2 || FIDELITY_STOPWORDS.has(raw))
504
+ continue;
505
+ const canonical = BUILTIN_ENTITY_ALIASES[raw] ?? raw;
506
+ if (seen.has(canonical))
507
+ continue;
508
+ seen.add(canonical);
509
+ terms.push(canonical);
510
+ }
511
+ return terms;
512
+ }
513
+ /**
514
+ * Score each doc against the query as IDF-weighted term coverage in
515
+ * [0, 1]: the fraction of the query's IDF mass present in the doc.
516
+ * 1.0 = every query term appears; 0.0 = none do. Local, zero-dependency,
517
+ * deterministic — the hot-path scorer for level corpora.
518
+ */
519
+ export function scoreLevelCorpus(query, docs) {
520
+ const qTerms = scorerTerms(query);
521
+ if (qTerms.length === 0)
522
+ return docs.map(() => 0);
523
+ const docSets = docs.map((d) => new Set(scorerTerms(d)));
524
+ const n = docs.length;
525
+ const idf = new Map();
526
+ for (const t of qTerms) {
527
+ let df = 0;
528
+ for (const s of docSets)
529
+ if (s.has(t))
530
+ df += 1;
531
+ idf.set(t, Math.log(1 + n / (1 + df)));
532
+ }
533
+ const denom = qTerms.reduce((sum, t) => sum + idf.get(t), 0);
534
+ if (denom === 0)
535
+ return docs.map(() => 0);
536
+ return docSets.map((set) => {
537
+ let num = 0;
538
+ for (const t of qTerms)
539
+ if (set.has(t))
540
+ num += idf.get(t);
541
+ return num / denom;
542
+ });
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // Query-adaptive router (local, no LLM)
546
+ // ---------------------------------------------------------------------------
547
+ /** Query words that mark a question (vs. a lookup). */
548
+ const QUESTION_WORDS = new Set([
549
+ "what",
550
+ "which",
551
+ "who",
552
+ "whom",
553
+ "whose",
554
+ "when",
555
+ "where",
556
+ "tell",
557
+ ]);
558
+ /** Words marking an explanatory/conceptual query — needs full nuance. */
559
+ const EXPLAIN_WORDS = new Set([
560
+ "why",
561
+ "how",
562
+ "explain",
563
+ "describe",
564
+ "comparison",
565
+ "compare",
566
+ "philosophy",
567
+ "implications",
568
+ "meaning",
569
+ "reasoning",
570
+ "evolved",
571
+ "evolution",
572
+ ]);
573
+ /** Words marking a summarization-style query — summary level suffices. */
574
+ const SUMMARY_WORDS = new Set([
575
+ "summarize",
576
+ "summarise",
577
+ "summary",
578
+ "overview",
579
+ "recap",
580
+ "digest",
581
+ "tldr",
582
+ ]);
583
+ /**
584
+ * Extract router entities: the standard pipeline
585
+ * (`extractQueryEntities` + canonicalization) PLUS lowercase alias-table
586
+ * vocabulary — "postgres" never surfaces as a capitalized entity but is
587
+ * an unambiguous entity reference through the alias table.
588
+ */
589
+ export function routerEntities(query) {
590
+ const out = canonicalizeEntities(extractQueryEntities(query));
591
+ const seen = new Set(out);
592
+ for (const token of query.toLowerCase().split(/[^a-z0-9]+/)) {
593
+ const canonical = BUILTIN_ENTITY_ALIASES[token];
594
+ if (canonical && !seen.has(canonical)) {
595
+ seen.add(canonical);
596
+ out.push(canonical);
597
+ }
598
+ }
599
+ return out;
600
+ }
601
+ function wordList(query) {
602
+ return query.trim().split(/\s+/).filter(Boolean);
603
+ }
604
+ /** Negation words that must appear verbatim in the served text. */
605
+ const DECISIVE_NEGATIONS = new Set([
606
+ "not",
607
+ "never",
608
+ "no",
609
+ "none",
610
+ "neither",
611
+ "nor",
612
+ "without",
613
+ "except",
614
+ "only",
615
+ ]);
616
+ /**
617
+ * Quantity words. A query asking for one of these is a factoid whose
618
+ * answer is a VALUE, not a label — the same trap as a bare number.
619
+ */
620
+ const DECISIVE_QUANTITY_WORDS = new Set([
621
+ "size",
622
+ "limit",
623
+ "count",
624
+ "number",
625
+ "amount",
626
+ "max",
627
+ "maximum",
628
+ "min",
629
+ "minimum",
630
+ "timeout",
631
+ "rate",
632
+ "budget",
633
+ "threshold",
634
+ "version",
635
+ "ttl",
636
+ "latency",
637
+ "duration",
638
+ ]);
639
+ /**
640
+ * Decisive tokens in a query: numbers (with optional units) and
641
+ * negations. These are the tokens a bag-of-words coverage score
642
+ * cannot judge: "pool size = 20" and "pool size reduced from 40"
643
+ * score identically on term presence, and "not" is invisible to
644
+ * every set-membership scorer. When the query carries one and the
645
+ * served text lacks it, `recall()` must escalate regardless of the
646
+ * coverage score.
647
+ */
648
+ export function queryDecisiveTokens(query) {
649
+ const out = [];
650
+ const seen = new Set();
651
+ const push = (tok) => {
652
+ const t = tok.trim().toLowerCase();
653
+ if (t && !seen.has(t)) {
654
+ seen.add(t);
655
+ out.push(t);
656
+ }
657
+ };
658
+ // Numbers with optional unit: "20", "25 connections", "120ms",
659
+ // "3am", "2.5", "42%", "$30". The unit tail is capped so a number
660
+ // mid-sentence can't swallow the rest of the clause.
661
+ //
662
+ // Each numeric run is matched through an ATOMIC group — `(?=(…))\1` —
663
+ // instead of `\d+(?:[.,]\d+)*` directly. In the plain form the engine
664
+ // can split a long "0,0,0,…" run between the outer `\d+` and the inner
665
+ // `(?:[.,]\d+)*` in polynomially many ways (CodeQL js/polynomial-redos).
666
+ // The lookahead captures the maximal run and the backreference consumes
667
+ // it without giving the unit tail a chance to backtrack into it, so the
668
+ // scan is linear and the matched tokens are byte-identical.
669
+ const numberRe = /(?=(\d+(?:[.,]\d+)*))\1(?:\s*[a-z]{0,12})?|[$%](?=(\d*(?:[.,]\d+)*))\2/gi;
670
+ let m;
671
+ while ((m = numberRe.exec(query)) !== null) {
672
+ push(m[0]);
673
+ }
674
+ // Negations / exclusive quantifiers, and quantity words ("pool
675
+ // size" is a factoid whose answer is a value, same trap as a bare
676
+ // number).
677
+ for (const raw of query.toLowerCase().split(/[^a-z]+/)) {
678
+ if (DECISIVE_NEGATIONS.has(raw) || DECISIVE_QUANTITY_WORDS.has(raw)) {
679
+ push(raw);
680
+ }
681
+ }
682
+ return out;
683
+ }
684
+ /** Index of the first ".", "," or whitespace char, or -1. Linear scan. */
685
+ function searchFirstSeparator(s) {
686
+ for (let i = 0; i < s.length; i++) {
687
+ const c = s.charCodeAt(i);
688
+ // "." (46), "," (44)
689
+ if (c === 46 || c === 44)
690
+ return i;
691
+ // whitespace: space, \t, \n, \v, \f, \r
692
+ if (c === 32 || (c >= 9 && c <= 13))
693
+ return i;
694
+ }
695
+ return -1;
696
+ }
697
+ /** True when `text` contains the decisive token (number or negation). */
698
+ export function textHasDecisiveToken(text, token) {
699
+ const t = token.trim().toLowerCase();
700
+ if (!t)
701
+ return true;
702
+ if (/^\d/.test(t)) {
703
+ // Number + optional unit: require the number; unit match is a
704
+ // bonus, not a requirement (units are spelled inconsistently).
705
+ // Cut at the first separator by INDEX rather than by
706
+ // `/[.,\s].*$/`: `\s` and `.` overlap on tabs/newlines, so the
707
+ // pattern can split such a run two ways and backtrack
708
+ // quadratically (CodeQL js/polynomial-redos). `indexOf`-style
709
+ // scanning is linear and yields the same prefix.
710
+ const cut = searchFirstSeparator(t);
711
+ const num = cut === -1 ? t : t.slice(0, cut);
712
+ return text.toLowerCase().includes(num);
713
+ }
714
+ return new RegExp(`\\b${t}\\b`, "i").test(text);
715
+ }
716
+ /**
717
+ * Pick the STARTING fidelity level for a query. Local heuristics, no
718
+ * LLM. Rule: start low, escalate on demand (escalation happens in
719
+ * `MemOS.recall()` when the result set looks insufficient).
720
+ *
721
+ * Rules (first match wins):
722
+ * 1. Entity lookup (≥1 entity, ≤4 words, no question/explain
723
+ * words, NO decisive token) → L0. Tags+entities answer "which
724
+ * memory is about X" — a retrieval affordance, not an answer.
725
+ * 2. Entity-anchored factoid, OR any query carrying a decisive
726
+ * token (number/unit/negation) → L1. Typed facts carry the
727
+ * answer; the decisive-token check in `recall()` escalates if
728
+ * the token is still absent at L1.
729
+ * 3. Explanatory / conceptual (why/how/explain/…) → L3.
730
+ * Nuance and reasoning don't survive compression.
731
+ * 4. Summarization request or long narrative query (≥30 words) → L2.
732
+ * 5. Open question (what/which/who/when/where/tell, no entity) → L2.
733
+ * Summary first; escalate on miss.
734
+ * 6. Fallback → L1 (cheap, structured, usually sufficient).
735
+ *
736
+ * L0 is never the terminal level for a content question: `recall()`
737
+ * treats an L0-only hit as insufficient and escalates even when the
738
+ * caller passed `fidelity: "adaptive"`.
739
+ */
740
+ export function routeFidelity(query) {
741
+ const words = wordList(query);
742
+ const lowered = words.map((w) => w.toLowerCase().replace(/[^a-z']/g, ""));
743
+ const entities = routerEntities(query);
744
+ const questionWords = lowered.filter((w) => QUESTION_WORDS.has(w));
745
+ const explainWords = lowered.filter((w) => EXPLAIN_WORDS.has(w));
746
+ const summaryWords = lowered.filter((w) => SUMMARY_WORDS.has(w));
747
+ const quoted = /["'`]/.test(query);
748
+ const decisiveTokens = queryDecisiveTokens(query);
749
+ const signals = {
750
+ entities,
751
+ words: words.length,
752
+ questionWords,
753
+ explainWords,
754
+ summaryWords,
755
+ quoted,
756
+ decisiveTokens,
757
+ };
758
+ if (entities.length >= 1 &&
759
+ words.length <= 4 &&
760
+ questionWords.length === 0 &&
761
+ explainWords.length === 0 &&
762
+ decisiveTokens.length === 0) {
763
+ return {
764
+ level: "L0",
765
+ reason: `entity lookup (${entities.join(", ")}): tags+entities suffice`,
766
+ signals,
767
+ };
768
+ }
769
+ if (entities.length >= 1 && explainWords.length === 0) {
770
+ return {
771
+ level: "L1",
772
+ reason: decisiveTokens.length > 0
773
+ ? `decisive-token factoid (${decisiveTokens.join(", ")}): typed facts first, escalate if absent`
774
+ : `entity-anchored factoid (${entities.join(", ")}): typed facts first`,
775
+ signals,
776
+ };
777
+ }
778
+ if (explainWords.length > 0) {
779
+ return {
780
+ level: "L3",
781
+ reason: `explanatory query (${explainWords.join(", ")}): full nuance required`,
782
+ signals,
783
+ };
784
+ }
785
+ if (summaryWords.length > 0 || words.length >= 30) {
786
+ return {
787
+ level: "L2",
788
+ reason: summaryWords.length > 0
789
+ ? `summarization request (${summaryWords.join(", ")}): extractive summary first`
790
+ : `long narrative query (${words.length} words): summary-level scan first`,
791
+ signals,
792
+ };
793
+ }
794
+ if (questionWords.length > 0) {
795
+ return {
796
+ level: "L2",
797
+ reason: `open question (${questionWords.join(", ")}): summary first, escalate on miss`,
798
+ signals,
799
+ };
800
+ }
801
+ return {
802
+ level: "L1",
803
+ reason: "short keyword query: typed facts first",
804
+ signals,
805
+ };
806
+ }
807
+ /** Default escalation floor: L0 is a retrieval affordance, not an answer. */
808
+ export const DEFAULT_MIN_FIDELITY_LEVEL = "L1";
809
+ /**
810
+ * Composition guard for `fidelity` × `compression` (audit finding 7f).
811
+ *
812
+ * `compressText` is a stopword-class filter. Applied to an item that
813
+ * is ALREADY a compressed fidelity level (L1 typed facts, L2
814
+ * extractive summary), it strips the connective tissue that made the
815
+ * summary readable — on top of the sentence-selection loss that
816
+ * already happened. Two lossy passes composed: the result is
817
+ * terser but materially less answerable than either pass alone.
818
+ *
819
+ * So when an item was rendered below L3, budget compression is
820
+ * skipped (the item is admitted as-is, or dropped if it still
821
+ * doesn't fit — exactly as it was before compression existed).
822
+ * L3 verbatim text has its full prose structure to lose and is the
823
+ * only level where the filter is a net win.
824
+ */
825
+ export function shouldCompressFidelityItem(fidelity) {
826
+ return fidelity === undefined || fidelity === "L3";
827
+ }
828
+ /**
829
+ * Resolve the effective start level + ceiling for a recall call.
830
+ *
831
+ * The start level is additionally clamped UP to the escalation floor
832
+ * (`minLevel`, default L1) unless the caller explicitly requested L0:
833
+ * an adaptive route may pick L0 for "postgres pool size", but serving
834
+ * tag soup as the answer is a category error, so the floor lifts it
835
+ * to L1.
836
+ */
837
+ export function resolveRecallLevels(query, opts = {}) {
838
+ const max = opts.maxFidelity ?? "L3";
839
+ const requested = opts.fidelity ?? "L3";
840
+ // The floor lifts the start level UP (never serves below it); the
841
+ // ceiling clamps it DOWN. Two independent clamps — applying the
842
+ // floor as a downward clamp would silently downgrade every
843
+ // explicit L2/L3 request and break the behavior-preserving default.
844
+ const floor = opts.minLevel ?? DEFAULT_MIN_FIDELITY_LEVEL;
845
+ const route = requested === "adaptive" ? routeFidelity(query) : undefined;
846
+ const raw = requested === "adaptive" ? route.level : requested;
847
+ // An explicit `fidelity: "L0"` is a contract: the caller wants
848
+ // "which memory is about X", so it may sit below the floor.
849
+ const floored = requested === "L0"
850
+ ? raw
851
+ : LEVEL_RANK[raw] < LEVEL_RANK[floor]
852
+ ? floor
853
+ : raw;
854
+ return {
855
+ start: clampFidelityLevel(floored, max),
856
+ max,
857
+ floor,
858
+ ...(route ? { route } : {}),
859
+ };
860
+ }
861
+ /**
862
+ * Whether the hits at the current level are sufficient, or the call
863
+ * must escalate. Two conditions beyond raw coverage:
864
+ *
865
+ * 1. `floor` — never serve L0 unless the caller explicitly asked for
866
+ * it. Tags+entities answer "which memory", not "what is the value".
867
+ * 2. Decisive tokens — when the query carries a number/unit/negation
868
+ * and the served text lacks it, escalate even at full coverage:
869
+ * "pool size = 20" and "pool size reduced from 40" both score 1.0
870
+ * on term presence, and only one of them answers the question.
871
+ */
872
+ export function fidelityLevelSufficient(query, level, docs, opts) {
873
+ if (LEVEL_RANK[level] < LEVEL_RANK[opts.floor])
874
+ return false;
875
+ if (!opts.checkDecisiveTokens)
876
+ return true;
877
+ const decisive = queryDecisiveTokens(query);
878
+ if (decisive.length === 0)
879
+ return true;
880
+ const joined = docs.join("\n");
881
+ return decisive.every((tok) => textHasDecisiveToken(joined, tok));
882
+ }
883
+ /**
884
+ * Measure tokens per fidelity level over `nodes`. `tokenCounter` is
885
+ * injected (callers pass the pack-grade `estimateTokens`) so this module
886
+ * stays dependency-free.
887
+ */
888
+ export function fidelityStats(nodes, tokenCounter) {
889
+ const totals = new Map();
890
+ for (const level of FIDELITY_LEVELS)
891
+ totals.set(level, 0);
892
+ for (const node of nodes) {
893
+ for (const level of FIDELITY_LEVELS) {
894
+ totals.set(level, totals.get(level) + tokenCounter(levelText(node, level)));
895
+ }
896
+ }
897
+ const verbatim = Math.max(1, totals.get("L3"));
898
+ return FIDELITY_LEVELS.map((level) => {
899
+ const totalTokens = totals.get(level);
900
+ return {
901
+ level,
902
+ label: FIDELITY_LEVEL_LABELS[level],
903
+ description: FIDELITY_LEVEL_DESCRIPTIONS[level],
904
+ count: nodes.length,
905
+ avgTokens: nodes.length > 0 ? totalTokens / nodes.length : 0,
906
+ totalTokens,
907
+ savingsVsVerbatim: level === "L3" ? 0 : Math.max(0, 1 - totalTokens / verbatim),
908
+ };
909
+ });
910
+ }
911
+ //# sourceMappingURL=fidelity.js.map