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.
- mem_os_python-1.6.27.dist-info/METADATA +1032 -0
- mem_os_python-1.6.27.dist-info/RECORD +253 -0
- mem_os_python-1.6.27.dist-info/WHEEL +4 -0
- mem_os_python-1.6.27.dist-info/entry_points.txt +2 -0
- mem_os_python-1.6.27.dist-info/licenses/LICENSE +21 -0
- memos/__init__.py +16 -0
- memos/_js/adapters/anthropic.d.ts +279 -0
- memos/_js/adapters/anthropic.d.ts.map +1 -0
- memos/_js/adapters/anthropic.js +348 -0
- memos/_js/adapters/anthropic.js.map +1 -0
- memos/_js/adapters/base.d.ts +10 -0
- memos/_js/adapters/base.d.ts.map +1 -0
- memos/_js/adapters/base.js +2 -0
- memos/_js/adapters/base.js.map +1 -0
- memos/_js/adapters/openai.d.ts +264 -0
- memos/_js/adapters/openai.d.ts.map +1 -0
- memos/_js/adapters/openai.js +342 -0
- memos/_js/adapters/openai.js.map +1 -0
- memos/_js/apps/explorer.d.ts +244 -0
- memos/_js/apps/explorer.d.ts.map +1 -0
- memos/_js/apps/explorer.js +738 -0
- memos/_js/apps/explorer.js.map +1 -0
- memos/_js/citations.d.ts +65 -0
- memos/_js/citations.d.ts.map +1 -0
- memos/_js/citations.js +143 -0
- memos/_js/citations.js.map +1 -0
- memos/_js/cli-browse.d.ts +3 -0
- memos/_js/cli-browse.d.ts.map +1 -0
- memos/_js/cli-browse.js +129 -0
- memos/_js/cli-browse.js.map +1 -0
- memos/_js/cli-completion.d.ts +31 -0
- memos/_js/cli-completion.d.ts.map +1 -0
- memos/_js/cli-completion.js +340 -0
- memos/_js/cli-completion.js.map +1 -0
- memos/_js/cli-help.d.ts +18 -0
- memos/_js/cli-help.d.ts.map +1 -0
- memos/_js/cli-help.js +357 -0
- memos/_js/cli-help.js.map +1 -0
- memos/_js/cli.d.ts +28 -0
- memos/_js/cli.d.ts.map +1 -0
- memos/_js/cli.js +2880 -0
- memos/_js/cli.js.map +1 -0
- memos/_js/compression.d.ts +25 -0
- memos/_js/compression.d.ts.map +1 -0
- memos/_js/compression.js +319 -0
- memos/_js/compression.js.map +1 -0
- memos/_js/confidence-machine.d.ts +99 -0
- memos/_js/confidence-machine.d.ts.map +1 -0
- memos/_js/confidence-machine.js +222 -0
- memos/_js/confidence-machine.js.map +1 -0
- memos/_js/context-pack.d.ts +506 -0
- memos/_js/context-pack.d.ts.map +1 -0
- memos/_js/context-pack.js +1435 -0
- memos/_js/context-pack.js.map +1 -0
- memos/_js/contradictions.d.ts +160 -0
- memos/_js/contradictions.d.ts.map +1 -0
- memos/_js/contradictions.js +255 -0
- memos/_js/contradictions.js.map +1 -0
- memos/_js/dashboard.d.ts +30 -0
- memos/_js/dashboard.d.ts.map +1 -0
- memos/_js/dashboard.js +302 -0
- memos/_js/dashboard.js.map +1 -0
- memos/_js/ebbinghaus.d.ts +29 -0
- memos/_js/ebbinghaus.d.ts.map +1 -0
- memos/_js/ebbinghaus.js +32 -0
- memos/_js/ebbinghaus.js.map +1 -0
- memos/_js/embedding-queue.d.ts +190 -0
- memos/_js/embedding-queue.d.ts.map +1 -0
- memos/_js/embedding-queue.js +316 -0
- memos/_js/embedding-queue.js.map +1 -0
- memos/_js/embeddings.d.ts +187 -0
- memos/_js/embeddings.d.ts.map +1 -0
- memos/_js/embeddings.js +633 -0
- memos/_js/embeddings.js.map +1 -0
- memos/_js/entity-extraction.d.ts +54 -0
- memos/_js/entity-extraction.d.ts.map +1 -0
- memos/_js/entity-extraction.js +325 -0
- memos/_js/entity-extraction.js.map +1 -0
- memos/_js/entity-index.d.ts +93 -0
- memos/_js/entity-index.d.ts.map +1 -0
- memos/_js/entity-index.js +165 -0
- memos/_js/entity-index.js.map +1 -0
- memos/_js/event-memory.d.ts +147 -0
- memos/_js/event-memory.d.ts.map +1 -0
- memos/_js/event-memory.js +290 -0
- memos/_js/event-memory.js.map +1 -0
- memos/_js/external-import.d.ts +98 -0
- memos/_js/external-import.d.ts.map +1 -0
- memos/_js/external-import.js +669 -0
- memos/_js/external-import.js.map +1 -0
- memos/_js/fidelity.d.ts +367 -0
- memos/_js/fidelity.d.ts.map +1 -0
- memos/_js/fidelity.js +911 -0
- memos/_js/fidelity.js.map +1 -0
- memos/_js/graph-expansion.d.ts +119 -0
- memos/_js/graph-expansion.d.ts.map +1 -0
- memos/_js/graph-expansion.js +213 -0
- memos/_js/graph-expansion.js.map +1 -0
- memos/_js/graph-mermaid.d.ts +13 -0
- memos/_js/graph-mermaid.d.ts.map +1 -0
- memos/_js/graph-mermaid.js +32 -0
- memos/_js/graph-mermaid.js.map +1 -0
- memos/_js/graph.d.ts +170 -0
- memos/_js/graph.d.ts.map +1 -0
- memos/_js/graph.js +482 -0
- memos/_js/graph.js.map +1 -0
- memos/_js/harness.d.ts +63 -0
- memos/_js/harness.d.ts.map +1 -0
- memos/_js/harness.js +93 -0
- memos/_js/harness.js.map +1 -0
- memos/_js/importance.d.ts +118 -0
- memos/_js/importance.d.ts.map +1 -0
- memos/_js/importance.js +144 -0
- memos/_js/importance.js.map +1 -0
- memos/_js/index.d.ts +51 -0
- memos/_js/index.d.ts.map +1 -0
- memos/_js/index.js +53 -0
- memos/_js/index.js.map +1 -0
- memos/_js/mcp.d.ts +58 -0
- memos/_js/mcp.d.ts.map +1 -0
- memos/_js/mcp.js +1853 -0
- memos/_js/mcp.js.map +1 -0
- memos/_js/memory.d.ts +1762 -0
- memos/_js/memory.d.ts.map +1 -0
- memos/_js/memory.js +6283 -0
- memos/_js/memory.js.map +1 -0
- memos/_js/procedural.d.ts +74 -0
- memos/_js/procedural.d.ts.map +1 -0
- memos/_js/procedural.js +125 -0
- memos/_js/procedural.js.map +1 -0
- memos/_js/project-config.d.ts +68 -0
- memos/_js/project-config.d.ts.map +1 -0
- memos/_js/project-config.js +59 -0
- memos/_js/project-config.js.map +1 -0
- memos/_js/proof-of-forgetting.d.ts +57 -0
- memos/_js/proof-of-forgetting.d.ts.map +1 -0
- memos/_js/proof-of-forgetting.js +80 -0
- memos/_js/proof-of-forgetting.js.map +1 -0
- memos/_js/provenance.d.ts +122 -0
- memos/_js/provenance.d.ts.map +1 -0
- memos/_js/provenance.js +182 -0
- memos/_js/provenance.js.map +1 -0
- memos/_js/quarantine-commands.d.ts +23 -0
- memos/_js/quarantine-commands.d.ts.map +1 -0
- memos/_js/quarantine-commands.js +42 -0
- memos/_js/quarantine-commands.js.map +1 -0
- memos/_js/quarantine.d.ts +120 -0
- memos/_js/quarantine.d.ts.map +1 -0
- memos/_js/quarantine.js +873 -0
- memos/_js/quarantine.js.map +1 -0
- memos/_js/redact.d.ts +12 -0
- memos/_js/redact.d.ts.map +1 -0
- memos/_js/redact.js +26 -0
- memos/_js/redact.js.map +1 -0
- memos/_js/repl.d.ts +57 -0
- memos/_js/repl.d.ts.map +1 -0
- memos/_js/repl.js +295 -0
- memos/_js/repl.js.map +1 -0
- memos/_js/rerank-local.d.ts +61 -0
- memos/_js/rerank-local.d.ts.map +1 -0
- memos/_js/rerank-local.js +210 -0
- memos/_js/rerank-local.js.map +1 -0
- memos/_js/retain-filter.d.ts +98 -0
- memos/_js/retain-filter.d.ts.map +1 -0
- memos/_js/retain-filter.js +232 -0
- memos/_js/retain-filter.js.map +1 -0
- memos/_js/retrieval.d.ts +265 -0
- memos/_js/retrieval.d.ts.map +1 -0
- memos/_js/retrieval.js +1018 -0
- memos/_js/retrieval.js.map +1 -0
- memos/_js/revert.d.ts +77 -0
- memos/_js/revert.d.ts.map +1 -0
- memos/_js/revert.js +252 -0
- memos/_js/revert.js.map +1 -0
- memos/_js/scope.d.ts +30 -0
- memos/_js/scope.d.ts.map +1 -0
- memos/_js/scope.js +71 -0
- memos/_js/scope.js.map +1 -0
- memos/_js/semantic-screen.d.ts +152 -0
- memos/_js/semantic-screen.d.ts.map +1 -0
- memos/_js/semantic-screen.js +290 -0
- memos/_js/semantic-screen.js.map +1 -0
- memos/_js/session-hooks.d.ts +127 -0
- memos/_js/session-hooks.d.ts.map +1 -0
- memos/_js/session-hooks.js +482 -0
- memos/_js/session-hooks.js.map +1 -0
- memos/_js/storage/sqlite.d.ts +427 -0
- memos/_js/storage/sqlite.d.ts.map +1 -0
- memos/_js/storage/sqlite.js +2428 -0
- memos/_js/storage/sqlite.js.map +1 -0
- memos/_js/sync.d.ts +213 -0
- memos/_js/sync.d.ts.map +1 -0
- memos/_js/sync.js +389 -0
- memos/_js/sync.js.map +1 -0
- memos/_js/tamper-log.d.ts +98 -0
- memos/_js/tamper-log.d.ts.map +1 -0
- memos/_js/tamper-log.js +327 -0
- memos/_js/tamper-log.js.map +1 -0
- memos/_js/temporal.d.ts +92 -0
- memos/_js/temporal.d.ts.map +1 -0
- memos/_js/temporal.js +536 -0
- memos/_js/temporal.js.map +1 -0
- memos/_js/time-travel.d.ts +38 -0
- memos/_js/time-travel.d.ts.map +1 -0
- memos/_js/time-travel.js +46 -0
- memos/_js/time-travel.js.map +1 -0
- memos/_js/timestamp-format.d.ts +18 -0
- memos/_js/timestamp-format.d.ts.map +1 -0
- memos/_js/timestamp-format.js +52 -0
- memos/_js/timestamp-format.js.map +1 -0
- memos/_js/triple-extraction.d.ts +31 -0
- memos/_js/triple-extraction.d.ts.map +1 -0
- memos/_js/triple-extraction.js +116 -0
- memos/_js/triple-extraction.js.map +1 -0
- memos/_js/types.d.ts +2474 -0
- memos/_js/types.d.ts.map +1 -0
- memos/_js/types.js +20 -0
- memos/_js/types.js.map +1 -0
- memos/_js/updater.d.ts +95 -0
- memos/_js/updater.d.ts.map +1 -0
- memos/_js/updater.js +268 -0
- memos/_js/updater.js.map +1 -0
- memos/_js/version.d.ts +17 -0
- memos/_js/version.d.ts.map +1 -0
- memos/_js/version.js +34 -0
- memos/_js/version.js.map +1 -0
- memos/_js/vision/clip-query-cache.d.ts +58 -0
- memos/_js/vision/clip-query-cache.d.ts.map +1 -0
- memos/_js/vision/clip-query-cache.js +79 -0
- memos/_js/vision/clip-query-cache.js.map +1 -0
- memos/_js/vision/clip.d.ts +113 -0
- memos/_js/vision/clip.d.ts.map +1 -0
- memos/_js/vision/clip.js +224 -0
- memos/_js/vision/clip.js.map +1 -0
- memos/_js/vision/fake-clip.d.ts +55 -0
- memos/_js/vision/fake-clip.d.ts.map +1 -0
- memos/_js/vision/fake-clip.js +122 -0
- memos/_js/vision/fake-clip.js.map +1 -0
- memos/_js/vision/index.d.ts +15 -0
- memos/_js/vision/index.d.ts.map +1 -0
- memos/_js/vision/index.js +13 -0
- memos/_js/vision/index.js.map +1 -0
- memos/_js/vision/store.d.ts +137 -0
- memos/_js/vision/store.d.ts.map +1 -0
- memos/_js/vision/store.js +383 -0
- memos/_js/vision/store.js.map +1 -0
- memos/adapters/__init__.py +0 -0
- memos/adapters/crewai.py +385 -0
- memos/adapters/langchain.py +301 -0
- memos/adapters/ollama.py +396 -0
- memos/server/__init__.py +0 -0
- memos/server/main.py +547 -0
- 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
|