lean-memory 0.1.0__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.
- lean_memory/__init__.py +14 -0
- lean_memory/embed/__init__.py +0 -0
- lean_memory/embed/base.py +51 -0
- lean_memory/embed/fake.py +41 -0
- lean_memory/embed/sentence_transformer.py +65 -0
- lean_memory/extract/__init__.py +0 -0
- lean_memory/extract/contradiction.py +329 -0
- lean_memory/extract/gliner_extractor.py +483 -0
- lean_memory/extract/llm_typer.py +444 -0
- lean_memory/extract/router.py +418 -0
- lean_memory/extract/rules.py +130 -0
- lean_memory/extract/salience.py +123 -0
- lean_memory/extract/taxonomy.py +244 -0
- lean_memory/mcp_server.py +150 -0
- lean_memory/memory.py +203 -0
- lean_memory/retrieve/__init__.py +0 -0
- lean_memory/retrieve/rerank.py +59 -0
- lean_memory/retrieve/retriever.py +125 -0
- lean_memory/store/__init__.py +0 -0
- lean_memory/store/base.py +91 -0
- lean_memory/store/schema.py +83 -0
- lean_memory/store/sqlite_store.py +301 -0
- lean_memory/types.py +107 -0
- lean_memory-0.1.0.dist-info/METADATA +228 -0
- lean_memory-0.1.0.dist-info/RECORD +27 -0
- lean_memory-0.1.0.dist-info/WHEEL +4 -0
- lean_memory-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Relation taxonomy + the pre-typing extraction `Candidate` — shared by Pass 2-4.
|
|
2
|
+
|
|
3
|
+
This module is the small, dependency-light vocabulary that the Phase 1 hybrid
|
|
4
|
+
extractor speaks. It pins down two things the spec (section 5) leans on:
|
|
5
|
+
|
|
6
|
+
1. The **four-relation taxonomy** — `asserts | supersedes | extends | derives` —
|
|
7
|
+
our improvement over supermemory's `updates|extends|derives`. These are
|
|
8
|
+
*structural* edge types (how a new fact relates to an existing `(subject,
|
|
9
|
+
predicate)` slot), NOT domain predicates like `works_at`. The domain predicate
|
|
10
|
+
lives on `Fact.predicate`; the taxonomy relation governs *versioning behaviour*.
|
|
11
|
+
|
|
12
|
+
- `asserts` — a new fact about a slot (no prior fact, or unrelated). Default.
|
|
13
|
+
- `supersedes` — new fact contradicts/replaces the object of an existing slot.
|
|
14
|
+
Triggers versioning: old.valid_to / superseded_by / is_latest=0.
|
|
15
|
+
- `extends` — new fact adds detail to the same slot WITHOUT contradiction
|
|
16
|
+
(co-valid; both rows stay is_latest=1).
|
|
17
|
+
- `derives` — inferential, cross-utterance. `is_inference=1`, and per the
|
|
18
|
+
spec it is **LLM-only**: rules/GLiNER2 surface-form passes can
|
|
19
|
+
never emit it, only the Pass 4 constrained-typing step can.
|
|
20
|
+
|
|
21
|
+
2. The **`Candidate`** — the over-generated extraction unit BEFORE routing/typing.
|
|
22
|
+
It is the shared currency between Pass 2 (GLiNER2 generates many at high recall),
|
|
23
|
+
Pass 3 (the recall-biased router flags `needs_typing`), and Pass 4 (the LLM
|
|
24
|
+
assigns a taxonomy relation + `is_inference` + resolves coreference). It is
|
|
25
|
+
deliberately looser than `ExtractedFact`/`Fact`: predicate and object may be
|
|
26
|
+
missing/guessed, and `needs_typing` records the router's escalation decision.
|
|
27
|
+
|
|
28
|
+
Kept to stdlib + dataclasses on purpose: this is imported by the rules pass (no
|
|
29
|
+
heavy deps), the GLiNER2 pass, the router, and the LLM-typing pass, so it must stay
|
|
30
|
+
import-clean and offline-testable — no torch, no ollama, no model download here.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from enum import Enum
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Relation(str, Enum):
|
|
41
|
+
"""The four structural relations a candidate can hold against an existing slot.
|
|
42
|
+
|
|
43
|
+
Subclasses `str` so the value round-trips through SQLite / JSON as a plain
|
|
44
|
+
string (e.g. stored as a column, or emitted by the LLM-typing schema) while
|
|
45
|
+
still giving us a closed enum to validate against. `Relation("supersedes")`
|
|
46
|
+
and `Relation.SUPERSEDES == "supersedes"` both work.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
ASSERTS = "asserts"
|
|
50
|
+
SUPERSEDES = "supersedes"
|
|
51
|
+
EXTENDS = "extends"
|
|
52
|
+
DERIVES = "derives"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: The relation assigned when nothing else applies (new fact, fresh slot). Used as
|
|
56
|
+
#: the deterministic default so the rules/GLiNER2 passes can label candidates
|
|
57
|
+
#: without an LLM, and the router only escalates the genuinely ambiguous ones.
|
|
58
|
+
DEFAULT_RELATION = Relation.ASSERTS
|
|
59
|
+
|
|
60
|
+
#: Relations the surface-form (deterministic) passes are allowed to emit on their
|
|
61
|
+
#: own. `derives` is excluded: it is inferential and, per spec section 5, only the
|
|
62
|
+
#: LLM-typing pass may produce it. The router uses this to force escalation of any
|
|
63
|
+
#: candidate that looks like a possible `derives`.
|
|
64
|
+
DETERMINISTIC_RELATIONS: frozenset[Relation] = frozenset(
|
|
65
|
+
{Relation.ASSERTS, Relation.SUPERSEDES, Relation.EXTENDS}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: Relations that the LLM-typing pass (Pass 4) may assign. It is the full taxonomy:
|
|
69
|
+
#: the LLM both validates the cheap deterministic verdicts AND is the *only* source
|
|
70
|
+
#: of `derives`.
|
|
71
|
+
LLM_TYPED_RELATIONS: frozenset[Relation] = frozenset(Relation)
|
|
72
|
+
|
|
73
|
+
# ── compatibility aliases (single source of truth for sibling passes) ──
|
|
74
|
+
# llm_typer.py imports these names; export them here so there is ONE taxonomy and
|
|
75
|
+
# the relation list cannot drift between modules.
|
|
76
|
+
#: Tuple of relation string values, in taxonomy order.
|
|
77
|
+
RELATIONS: tuple[str, ...] = tuple(r.value for r in Relation)
|
|
78
|
+
|
|
79
|
+
#: Word-boundary inference cue tokens shared by the router (Pass 3) and the
|
|
80
|
+
#: StubTyper (Pass 4) so the two deterministic passes AGREE on what looks
|
|
81
|
+
#: inferential. Used with a \\b...\\b regex, never bare substring membership
|
|
82
|
+
#: (substring matching gave false positives like 'so' inside 'also').
|
|
83
|
+
INFERENCE_CUES: tuple[str, ...] = (
|
|
84
|
+
"so", "therefore", "thus", "hence", "because", "since", "must",
|
|
85
|
+
"probably", "likely", "means", "consequently", "implies", "suggests",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_inference_relation(value) -> bool:
|
|
90
|
+
"""True if the (string or Relation) names the inferential `derives` relation.
|
|
91
|
+
|
|
92
|
+
Compatibility helper imported by llm_typer.py; defers to `relation_from_str`
|
|
93
|
+
so messy LLM output ('Derives', ' derives ') still resolves correctly.
|
|
94
|
+
"""
|
|
95
|
+
return relation_from_str(value) is Relation.DERIVES
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def relation_from_str(value: Optional[str], *, default: Relation = DEFAULT_RELATION) -> Relation:
|
|
99
|
+
"""Coerce a (possibly None / LLM-emitted / messy) string into a `Relation`.
|
|
100
|
+
|
|
101
|
+
Forgiving on input so the LLM-typing pass can hand us whatever its constrained
|
|
102
|
+
schema produced without crashing the pipeline; falls back to `default` on
|
|
103
|
+
anything unrecognized. Case- and whitespace-insensitive.
|
|
104
|
+
"""
|
|
105
|
+
if isinstance(value, Relation):
|
|
106
|
+
return value
|
|
107
|
+
if value is None:
|
|
108
|
+
return default
|
|
109
|
+
key = value.strip().lower()
|
|
110
|
+
for rel in Relation:
|
|
111
|
+
if rel.value == key:
|
|
112
|
+
return rel
|
|
113
|
+
return default
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_inference_flag(relation: Relation) -> int:
|
|
117
|
+
"""Map a taxonomy relation → the `Fact.is_inference` column value (0/1).
|
|
118
|
+
|
|
119
|
+
Only `derives` is inferential. Returns an int (not bool) to match the schema's
|
|
120
|
+
`is_inference INTEGER` column and `Fact.is_inference: int` field exactly.
|
|
121
|
+
"""
|
|
122
|
+
return 1 if relation is Relation.DERIVES else 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_llm_only(relation: Relation) -> bool:
|
|
126
|
+
"""True if only the LLM-typing pass may emit this relation (currently `derives`).
|
|
127
|
+
|
|
128
|
+
The router consults this (via `DETERMINISTIC_RELATIONS`) to guarantee a
|
|
129
|
+
deterministic pass never ships an inferential edge unreviewed.
|
|
130
|
+
"""
|
|
131
|
+
return relation not in DETERMINISTIC_RELATIONS
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def triggers_versioning(relation: Relation) -> bool:
|
|
135
|
+
"""True if assigning this relation must run the supersession path (Pass 5).
|
|
136
|
+
|
|
137
|
+
Only `supersedes` flips the old slot fact's `valid_to`/`superseded_by`/
|
|
138
|
+
`is_latest`. `asserts`/`extends` are co-valid (nothing retired); `derives` adds
|
|
139
|
+
a new inferential row but does not retire the surface facts it was derived from.
|
|
140
|
+
"""
|
|
141
|
+
return relation is Relation.SUPERSEDES
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── predicate-slot helpers ──
|
|
145
|
+
# The versioning "slot" is the (subject_id, predicate) pair — the same key the
|
|
146
|
+
# store indexes (ix_fact_slot) and `find_latest_in_slot` looks up. Centralizing the
|
|
147
|
+
# slot construction here keeps the router, the contradiction check, and the store
|
|
148
|
+
# call agreeing on exactly one canonical key shape.
|
|
149
|
+
|
|
150
|
+
#: Canonical placeholder predicate for a candidate whose relation slot the
|
|
151
|
+
#: deterministic passes could not guess (GLiNER2 gives head/tail spans but the
|
|
152
|
+
#: predicate may be unknown until LLM typing). Router treats this as needs_typing.
|
|
153
|
+
UNTYPED_PREDICATE = "_untyped"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def normalize_predicate(predicate: Optional[str]) -> str:
|
|
157
|
+
"""Normalize a raw predicate string into a stable slot token.
|
|
158
|
+
|
|
159
|
+
Lowercase, collapse internal whitespace to single underscores, strip surrounding
|
|
160
|
+
punctuation. Mirrors the rules-pass convention (`works_at`, `lives_in`) so a
|
|
161
|
+
GLiNER2 relation name and a rules predicate collapse to the same slot when they
|
|
162
|
+
mean the same thing. Empty/None → `UNTYPED_PREDICATE`.
|
|
163
|
+
"""
|
|
164
|
+
if not predicate:
|
|
165
|
+
return UNTYPED_PREDICATE
|
|
166
|
+
cleaned = predicate.strip().lower().strip("\"'.,!?;:")
|
|
167
|
+
if not cleaned:
|
|
168
|
+
return UNTYPED_PREDICATE
|
|
169
|
+
return "_".join(cleaned.split())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def slot_key(subject_id: str, predicate: Optional[str]) -> tuple[str, str]:
|
|
173
|
+
"""The canonical `(subject_id, predicate)` versioning slot key.
|
|
174
|
+
|
|
175
|
+
This is the key for supersession lookup (`store.find_latest_in_slot`) and the
|
|
176
|
+
contradiction → supersession flow. The predicate is normalized so callers never
|
|
177
|
+
have to remember to do it themselves.
|
|
178
|
+
"""
|
|
179
|
+
return (subject_id, normalize_predicate(predicate))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class Candidate:
|
|
184
|
+
"""An over-generated extraction candidate BEFORE routing/typing (Pass 2-4 currency).
|
|
185
|
+
|
|
186
|
+
Emitted at high recall by the deterministic passes (rules + GLiNER2). The fields
|
|
187
|
+
are intentionally loose — `predicate`/`object_*` may be absent or only guessed —
|
|
188
|
+
because the whole point of Pass 2 is to over-generate and let Pass 3 (router) and
|
|
189
|
+
Pass 4 (LLM typing) decide which candidates are real and how to type them.
|
|
190
|
+
|
|
191
|
+
Fields:
|
|
192
|
+
subject_name — resolved later to a `subject_id`; the head span / subject text.
|
|
193
|
+
predicate — guessed relation slot, or None if GLiNER2 gave only spans.
|
|
194
|
+
object_name — entity-valued object span (tail), if any (resolved to object_id).
|
|
195
|
+
object_literal — literal-valued object (date/price/string), if any.
|
|
196
|
+
fact_text — the standalone sentence (what eventually gets embedded).
|
|
197
|
+
valid_at — world/event time (epoch ms), resolved against episode.t_ref.
|
|
198
|
+
confidence — generator confidence in [0,1] (GLiNER2 span/relation score, or
|
|
199
|
+
the rules-pass default). The router escalates low-confidence ones.
|
|
200
|
+
source — which generator emitted it: 'rules' | 'gliner2' | 'stub'.
|
|
201
|
+
subject_span — (start, end) char offsets of the head/subject span, if known
|
|
202
|
+
(GLiNER2 reports these; rules may not) — for coref/provenance.
|
|
203
|
+
object_span — (start, end) char offsets of the tail/object span, if known.
|
|
204
|
+
object_id — resolved entity id for the object, once the resolver runs.
|
|
205
|
+
needs_typing — the router's verdict (Pass 3). False until the router sets it;
|
|
206
|
+
True means "escalate to the Pass-4 LLM-typing batch". This is
|
|
207
|
+
the field the escalation-rate metric is computed over.
|
|
208
|
+
escalation_reasons — the router's recorded reasons for escalating (for the
|
|
209
|
+
first-class escalation-rate-by-reason metric and debugging).
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
subject_name: str
|
|
213
|
+
fact_text: str
|
|
214
|
+
valid_at: int
|
|
215
|
+
predicate: Optional[str] = None
|
|
216
|
+
object_name: Optional[str] = None
|
|
217
|
+
object_literal: Optional[str] = None
|
|
218
|
+
object_id: Optional[str] = None
|
|
219
|
+
confidence: float = 0.6
|
|
220
|
+
source: str = "gliner2" # 'rules' | 'gliner2' | 'stub'
|
|
221
|
+
subject_span: Optional[tuple[int, int]] = None
|
|
222
|
+
object_span: Optional[tuple[int, int]] = None
|
|
223
|
+
needs_typing: bool = False
|
|
224
|
+
escalation_reasons: list[str] = field(default_factory=list)
|
|
225
|
+
|
|
226
|
+
# The taxonomy relation, if/when assigned. None until typed (deterministic
|
|
227
|
+
# passes may set a tentative ASSERTS/EXTENDS/SUPERSEDES; Pass 4 may overwrite,
|
|
228
|
+
# and is the only writer of DERIVES). Kept here so the candidate carries the
|
|
229
|
+
# routing result through to the supersession/persistence step.
|
|
230
|
+
relation: Optional[Relation] = None
|
|
231
|
+
|
|
232
|
+
def slot(self, subject_id: str) -> tuple[str, str]:
|
|
233
|
+
"""Canonical `(subject_id, predicate)` versioning slot for this candidate."""
|
|
234
|
+
return slot_key(subject_id, self.predicate)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def normalized_predicate(self) -> str:
|
|
238
|
+
"""The slot-token form of `predicate` (`UNTYPED_PREDICATE` if unset)."""
|
|
239
|
+
return normalize_predicate(self.predicate)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def is_inference(self) -> int:
|
|
243
|
+
"""`Fact.is_inference` value implied by the assigned relation (0 if untyped)."""
|
|
244
|
+
return is_inference_flag(self.relation) if self.relation is not None else 0
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""MCP server exposing lean-memory as agent-memory tools.
|
|
2
|
+
|
|
3
|
+
Run it:
|
|
4
|
+
python -m lean_memory.mcp_server # stdio transport
|
|
5
|
+
lean-memory-mcp # console-script equivalent
|
|
6
|
+
|
|
7
|
+
Wire it into an MCP client (Claude Desktop / Claude Code) with examples/mcp_config.json.
|
|
8
|
+
|
|
9
|
+
Backends: if the `models` extra is installed (sentence-transformers importable), the
|
|
10
|
+
server uses the real SentenceTransformerEmbedder + CrossEncoderReranker for quality;
|
|
11
|
+
otherwise it falls back to lean-memory's offline stub defaults so it always runs.
|
|
12
|
+
|
|
13
|
+
Data root: LM_DATA_ROOT env var (default ~/.lean_memory). Each namespace is an
|
|
14
|
+
isolated SQLite file under that root (BET 4).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from mcp.server.fastmcp import FastMCP
|
|
25
|
+
|
|
26
|
+
from .memory import _SAFE_NS, Memory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _data_root() -> Path:
|
|
30
|
+
root = os.environ.get("LM_DATA_ROOT", "~/.lean_memory")
|
|
31
|
+
return Path(root).expanduser()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_memory(root: Path) -> Memory:
|
|
35
|
+
"""Opportunistically upgrade each backend when its optional extra is present.
|
|
36
|
+
|
|
37
|
+
Two INDEPENDENT upgrades, each guarded by its own import so either can succeed
|
|
38
|
+
without the other (`[models]` without `[extract]`, or vice versa):
|
|
39
|
+
* `models` (sentence_transformers) → real embedder + reranker for retrieval.
|
|
40
|
+
* `extract` (gliner2) → Gliner2Generator for real extraction.
|
|
41
|
+
Anything not installed falls back to lean-memory's deterministic offline stubs,
|
|
42
|
+
so the server always runs. GLiNER matters especially here: the frozen escalation/
|
|
43
|
+
granularity constants were calibrated ON the GLiNER path, so shipping it aligns
|
|
44
|
+
the canonical install with what the engine was tuned against.
|
|
45
|
+
|
|
46
|
+
Each successful upgrade logs ONE line to stderr (stdout is the MCP protocol
|
|
47
|
+
channel — NEVER print to stdout here) so cold-cache users, who wait through a
|
|
48
|
+
~2 GB download on first tool call, see progress in their client's server logs.
|
|
49
|
+
"""
|
|
50
|
+
kwargs: dict = {}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
import sentence_transformers # noqa: F401
|
|
54
|
+
|
|
55
|
+
from .embed.sentence_transformer import SentenceTransformerEmbedder
|
|
56
|
+
from .retrieve.rerank import CrossEncoderReranker
|
|
57
|
+
|
|
58
|
+
kwargs["embedder"] = SentenceTransformerEmbedder()
|
|
59
|
+
kwargs["reranker"] = CrossEncoderReranker()
|
|
60
|
+
print("[lean-memory] models extra active: real embedder + reranker", file=sys.stderr)
|
|
61
|
+
except ImportError:
|
|
62
|
+
# `models` extra not installed — keep the deterministic offline stub backends.
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
import gliner2 # noqa: F401
|
|
67
|
+
|
|
68
|
+
from .extract.gliner_extractor import Gliner2Generator
|
|
69
|
+
|
|
70
|
+
kwargs["generator"] = Gliner2Generator()
|
|
71
|
+
print("[lean-memory] extract extra active: GLiNER2 extraction", file=sys.stderr)
|
|
72
|
+
except ImportError:
|
|
73
|
+
# `extract` extra not installed — keep the stub candidate generator.
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return Memory(root=root, **kwargs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
mcp = FastMCP("lean-memory")
|
|
80
|
+
|
|
81
|
+
# Lazy, build-once Memory. Import-time construction would block an MCP client's
|
|
82
|
+
# server spawn through a ~2 GB cold-cache model download with no handshake; the
|
|
83
|
+
# first tool call pays that cost instead, so the handshake is immediate. A plain
|
|
84
|
+
# check-and-build is thread-safe enough for MCP's single-process stdio transport
|
|
85
|
+
# (tool calls are serialized on one event loop; no concurrent _mem() races).
|
|
86
|
+
_MEM: Optional[Memory] = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _mem() -> Memory:
|
|
90
|
+
"""Return the module-level Memory, building it (once) on first use."""
|
|
91
|
+
global _MEM
|
|
92
|
+
if _MEM is None:
|
|
93
|
+
_MEM = _build_memory(_data_root())
|
|
94
|
+
return _MEM
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _namespace_path(namespace: str) -> Path:
|
|
98
|
+
"""The SQLite file backing a namespace (mirrors Memory._store's naming)."""
|
|
99
|
+
safe = _SAFE_NS.sub("_", namespace) or "default"
|
|
100
|
+
return _mem().root / f"{safe}.db"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def memory_add(namespace: str, text: str) -> str:
|
|
105
|
+
"""Ingest text into the namespace's memory. Returns how many facts were written."""
|
|
106
|
+
written = _mem().add(namespace, text)
|
|
107
|
+
n = len(written)
|
|
108
|
+
return f"wrote {n} fact{'s' if n != 1 else ''}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
def memory_search(namespace: str, query: str, k: int = 5) -> str:
|
|
113
|
+
"""Search a namespace's memory and return the top-k facts as a bulleted list."""
|
|
114
|
+
hits = _mem().search(namespace, query, k=k)
|
|
115
|
+
if not hits:
|
|
116
|
+
return "No facts found."
|
|
117
|
+
# De-duplicate by exact fact_text, keeping the highest-ranked instance and
|
|
118
|
+
# preserving order: GLiNER over-generation can surface the same sentence
|
|
119
|
+
# multiple times in top-k, which would print duplicate bullets.
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
bullets = []
|
|
122
|
+
for h in hits:
|
|
123
|
+
text = h.fact.fact_text
|
|
124
|
+
if text in seen:
|
|
125
|
+
continue
|
|
126
|
+
seen.add(text)
|
|
127
|
+
bullets.append(f"- {text}")
|
|
128
|
+
return "\n".join(bullets)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@mcp.tool()
|
|
132
|
+
def memory_clear(namespace: str) -> str:
|
|
133
|
+
"""Delete all memory for a namespace by removing its SQLite file. Irreversible."""
|
|
134
|
+
# Release any cached open connection so the file handle is freed before unlink.
|
|
135
|
+
store = _mem()._stores.pop(namespace, None)
|
|
136
|
+
if store is not None:
|
|
137
|
+
store.close()
|
|
138
|
+
path = _namespace_path(namespace)
|
|
139
|
+
for p in (path, path.with_suffix(".db-wal"), path.with_suffix(".db-shm")):
|
|
140
|
+
p.unlink(missing_ok=True)
|
|
141
|
+
return f"cleared namespace '{namespace}'"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main() -> None:
|
|
145
|
+
"""Console-script / module entry point: serve over stdio."""
|
|
146
|
+
mcp.run()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
lean_memory/memory.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""`Memory` — the top-level facade. This is the public API for Phase 0.
|
|
2
|
+
|
|
3
|
+
mem = Memory(root="./data") # per-tenant files live under root/
|
|
4
|
+
mem.add("ns1", "I work at Acme.", t_ref=...)
|
|
5
|
+
hits = mem.search("ns1", "where does the user work?")
|
|
6
|
+
|
|
7
|
+
Per BET 4, each namespace gets its own SQLite file (write-isolation + brute-force
|
|
8
|
+
comfort). The Memory object owns a small cache of open per-namespace stores.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .embed.base import Embedder
|
|
18
|
+
from .embed.fake import FakeEmbedder
|
|
19
|
+
from .extract.contradiction import EXTENDS, SUPERSEDES, ContradictionResolver
|
|
20
|
+
from .extract.gliner_extractor import CandidateGenerator, StubCandidateGenerator
|
|
21
|
+
from .extract.llm_typer import StubTyper, TypedFact, Typer
|
|
22
|
+
from .extract.router import RecallBiasedRouter
|
|
23
|
+
from .extract.salience import score_salience
|
|
24
|
+
from .retrieve.rerank import IdentityReranker, Reranker
|
|
25
|
+
from .retrieve.retriever import Retriever
|
|
26
|
+
from .store.sqlite_store import SqliteStore
|
|
27
|
+
from .types import Entity, Episode, Fact, RetrievedFact, new_id, now_ms
|
|
28
|
+
|
|
29
|
+
# domain predicate the rules/stub passes use when none is guessed
|
|
30
|
+
_DEFAULT_PREDICATE = "about"
|
|
31
|
+
|
|
32
|
+
# Cap on the known-entity names handed to the router/typer per add(). The set
|
|
33
|
+
# otherwise grows unboundedly with namespace age (conversational data creates
|
|
34
|
+
# entities every turn), inflating — and eventually silently truncating — the
|
|
35
|
+
# constrained-typing prompt. Most recent names win.
|
|
36
|
+
_KNOWN_ENTITIES_CAP = 100
|
|
37
|
+
|
|
38
|
+
_SAFE_NS = re.compile(r"[^A-Za-z0-9_.-]")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Memory:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
root: str | Path = "./lm_data",
|
|
45
|
+
*,
|
|
46
|
+
embedder: Optional[Embedder] = None,
|
|
47
|
+
reranker: Optional[Reranker] = None,
|
|
48
|
+
generator: Optional[CandidateGenerator] = None,
|
|
49
|
+
router: Optional[RecallBiasedRouter] = None,
|
|
50
|
+
typer: Optional[Typer] = None,
|
|
51
|
+
contradiction: Optional[ContradictionResolver] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.root = Path(root)
|
|
54
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
# Every backend defaults to the OFFLINE stub so the engine runs with zero
|
|
56
|
+
# downloads/servers. Swap in the real ones (SentenceTransformerEmbedder,
|
|
57
|
+
# CrossEncoderReranker, Gliner2Generator, OllamaTyper) for production quality.
|
|
58
|
+
self.embedder = embedder or FakeEmbedder()
|
|
59
|
+
self.reranker = reranker or IdentityReranker()
|
|
60
|
+
# Phase 1 hybrid-extraction pipeline (Pass 2 → 3 → 4 + contradiction):
|
|
61
|
+
self.generator = generator or StubCandidateGenerator()
|
|
62
|
+
self.router = router or RecallBiasedRouter()
|
|
63
|
+
self.typer = typer or StubTyper()
|
|
64
|
+
self.contradiction = contradiction or ContradictionResolver()
|
|
65
|
+
self._stores: dict[str, SqliteStore] = {}
|
|
66
|
+
|
|
67
|
+
# ── per-tenant store management (BET 4: one file per namespace) ──
|
|
68
|
+
def _store(self, namespace: str) -> SqliteStore:
|
|
69
|
+
if namespace not in self._stores:
|
|
70
|
+
safe = _SAFE_NS.sub("_", namespace) or "default"
|
|
71
|
+
path = self.root / f"{safe}.db"
|
|
72
|
+
self._stores[namespace] = SqliteStore(
|
|
73
|
+
path, dim=self.embedder.dim, coarse_dim=self.embedder.coarse_dim
|
|
74
|
+
)
|
|
75
|
+
return self._stores[namespace]
|
|
76
|
+
|
|
77
|
+
# ── ingest (the Phase 1 hybrid pipeline) ──
|
|
78
|
+
def add(
|
|
79
|
+
self, namespace: str, text: str, *, t_ref: Optional[int] = None, source: str = "user"
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Ingest one message through the full hybrid pipeline (spec §5):
|
|
82
|
+
|
|
83
|
+
Pass 2 generate over-generated candidates (rules/GLiNER2, high recall)
|
|
84
|
+
Pass 3 recall-biased router → escalate the hard ones (logs escalation rate)
|
|
85
|
+
Pass 4 LLM constrained typing of the residual; cheap-type the rest
|
|
86
|
+
Pass 5 per fact: resolve entity → contradiction check → ADD-only persist
|
|
87
|
+
|
|
88
|
+
Returns the ids of the facts written. Nothing is ever deleted.
|
|
89
|
+
"""
|
|
90
|
+
t_ref = t_ref if t_ref is not None else now_ms()
|
|
91
|
+
store = self._store(namespace)
|
|
92
|
+
|
|
93
|
+
episode = Episode(namespace=namespace, raw=text, t_ref=t_ref, source=source)
|
|
94
|
+
store.add_episode(episode)
|
|
95
|
+
|
|
96
|
+
# Pass 2 — candidate generation (offline default: StubCandidateGenerator).
|
|
97
|
+
candidates = self.generator.generate(episode)
|
|
98
|
+
if not candidates:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
# Pass 3 — recall-biased router. known_entities is passed to the router/typer
|
|
102
|
+
# as context only; it no longer drives escalation (the prior_entity trigger was
|
|
103
|
+
# retired 2026-07 — see bench/results/calibration/README.md).
|
|
104
|
+
known = self._known_entity_names(store, namespace)
|
|
105
|
+
to_type, direct = self.router.route(candidates, known_entities=known)
|
|
106
|
+
|
|
107
|
+
# Pass 4 — type the escalated residual with the LLM (stub offline); the direct
|
|
108
|
+
# set is trivially explicit and typed cheaply (asserts, unless an inference cue).
|
|
109
|
+
typed: list[TypedFact] = []
|
|
110
|
+
if to_type:
|
|
111
|
+
typed += self.typer.type_candidates(episode.raw, to_type, known_entities=list(known))
|
|
112
|
+
if direct:
|
|
113
|
+
typed += StubTyper().type_candidates(episode.raw, direct, known_entities=list(known))
|
|
114
|
+
|
|
115
|
+
# Pass 5 — resolve, contradiction-check, persist (ADD-only).
|
|
116
|
+
written: list[str] = []
|
|
117
|
+
for tf in typed:
|
|
118
|
+
fact = self._build_fact(tf, namespace=namespace, episode_id=episode.id, store=store)
|
|
119
|
+
|
|
120
|
+
# Contradiction → supersession over the (subject, predicate) slot.
|
|
121
|
+
slot_latest = store.find_latest_in_slot(fact.subject_id, fact.predicate)
|
|
122
|
+
decision = self.contradiction.classify(
|
|
123
|
+
fact, slot_latest, self.embedder,
|
|
124
|
+
# ambiguous cases can escalate to the same typer; offline stub is fine.
|
|
125
|
+
llm_typer=None,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
full, coarse = self.embedder.embed_with_coarse(fact.fact_text)
|
|
129
|
+
store.add_fact(fact, full, coarse)
|
|
130
|
+
# SUPERSEDES retires the matched fact; EXTENDS keeps both co-valid; ASSERTS
|
|
131
|
+
# touches nothing else. Insert-new-first so the FK target exists.
|
|
132
|
+
if decision.label == SUPERSEDES and decision.target is not None:
|
|
133
|
+
store.supersede_fact(decision.target.id, fact.id, valid_to=fact.valid_at)
|
|
134
|
+
written.append(fact.id)
|
|
135
|
+
return written
|
|
136
|
+
|
|
137
|
+
def _build_fact(
|
|
138
|
+
self, tf: TypedFact, *, namespace: str, episode_id: str, store: SqliteStore
|
|
139
|
+
) -> Fact:
|
|
140
|
+
"""Bind a TypedFact → a persistable Fact: resolve the subject entity, rate
|
|
141
|
+
salience once (cached), carry the relation's is_inference flag."""
|
|
142
|
+
subject = store.upsert_entity(Entity(namespace=namespace, name=tf.subject_name, type=None))
|
|
143
|
+
salience = score_salience(
|
|
144
|
+
tf.fact_text, source="extract", is_inference=bool(tf.is_inference)
|
|
145
|
+
)
|
|
146
|
+
ts = now_ms()
|
|
147
|
+
return Fact(
|
|
148
|
+
id=new_id(),
|
|
149
|
+
namespace=namespace,
|
|
150
|
+
subject_id=subject.id,
|
|
151
|
+
predicate=tf.predicate or _DEFAULT_PREDICATE,
|
|
152
|
+
object_literal=tf.object_literal,
|
|
153
|
+
fact_text=tf.fact_text,
|
|
154
|
+
valid_at=tf.valid_at,
|
|
155
|
+
episode_id=episode_id,
|
|
156
|
+
confidence=tf.confidence,
|
|
157
|
+
salience=salience,
|
|
158
|
+
is_inference=int(tf.is_inference),
|
|
159
|
+
ingested_at=ts,
|
|
160
|
+
created_at=ts,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _known_entity_names(self, store: SqliteStore, namespace: str) -> set[str]:
|
|
164
|
+
"""Names of entities already seen in this namespace — passed to the router/typer
|
|
165
|
+
as context only; they no longer drive escalation (the prior_entity trigger was
|
|
166
|
+
retired 2026-07 — see bench/results/calibration/README.md).
|
|
167
|
+
Capped to the most recent _KNOWN_ENTITIES_CAP names (ids are time-sortable)."""
|
|
168
|
+
rows = store._db.execute(
|
|
169
|
+
"SELECT name FROM entity WHERE namespace=? ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
170
|
+
(namespace, _KNOWN_ENTITIES_CAP),
|
|
171
|
+
).fetchall()
|
|
172
|
+
return {r["name"] for r in rows}
|
|
173
|
+
|
|
174
|
+
# ── retrieve ──
|
|
175
|
+
def search(
|
|
176
|
+
self,
|
|
177
|
+
namespace: str,
|
|
178
|
+
query: str,
|
|
179
|
+
k: int = 5,
|
|
180
|
+
*,
|
|
181
|
+
as_of: Optional[int] = None,
|
|
182
|
+
is_latest_only: bool = True,
|
|
183
|
+
now: Optional[int] = None,
|
|
184
|
+
) -> list[RetrievedFact]:
|
|
185
|
+
"""`now` (epoch ms) anchors the recency-decay term — pass the corpus's
|
|
186
|
+
present when querying historical data, else the wall clock is used and
|
|
187
|
+
recency is ≈0 for everything old (the term de-ranks nothing)."""
|
|
188
|
+
store = self._store(namespace)
|
|
189
|
+
retriever = Retriever(store, self.embedder, self.reranker)
|
|
190
|
+
return retriever.retrieve(
|
|
191
|
+
query, k, as_of=as_of, is_latest_only=is_latest_only, now=now
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def close(self) -> None:
|
|
195
|
+
for s in self._stores.values():
|
|
196
|
+
s.close()
|
|
197
|
+
self._stores.clear()
|
|
198
|
+
|
|
199
|
+
def __enter__(self) -> "Memory":
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def __exit__(self, *exc) -> None:
|
|
203
|
+
self.close()
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Reranker interface + default backends.
|
|
2
|
+
|
|
3
|
+
Per BET 1, the reranker is the single largest accuracy lever and is MANDATORY in the
|
|
4
|
+
default pipeline. But the production model is harness-decided (the sub-150M field —
|
|
5
|
+
Ettin-32M / mxbai-base / Qwen3-Reranker-0.6B — is within noise; do NOT assume
|
|
6
|
+
superiority). Phase 0 ships:
|
|
7
|
+
- IdentityReranker: passes fused order through (keeps tests offline/deterministic).
|
|
8
|
+
- CrossEncoderReranker: lazy sentence-transformers CrossEncoder, default Ettin-32M.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Reranker(ABC):
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def score(self, query: str, docs: list[str]) -> list[float]:
|
|
19
|
+
"""Return one relevance score per doc (higher = better)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IdentityReranker(Reranker):
|
|
23
|
+
"""No-op reranker: preserves incoming order via a descending score ramp.
|
|
24
|
+
|
|
25
|
+
Used as the offline test default so the pipeline runs with zero model deps.
|
|
26
|
+
NOTE: this is explicitly NOT spec-compliant for production — the spec requires a
|
|
27
|
+
real cross-encoder. It exists only to make Phase 0 runnable without downloads.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def score(self, query: str, docs: list[str]) -> list[float]:
|
|
31
|
+
n = len(docs)
|
|
32
|
+
return [float(n - i) for i in range(n)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CrossEncoderReranker(Reranker):
|
|
36
|
+
"""Lazy cross-encoder. Default = Ettin-32M (a verified, real sub-150M option)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, model_name: str = "cross-encoder/ettin-reranker-32m-v1") -> None:
|
|
39
|
+
self.model_name = model_name
|
|
40
|
+
self._model = None
|
|
41
|
+
|
|
42
|
+
def _ensure(self):
|
|
43
|
+
if self._model is None:
|
|
44
|
+
try:
|
|
45
|
+
from sentence_transformers import CrossEncoder
|
|
46
|
+
except ImportError as e: # pragma: no cover
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"CrossEncoderReranker needs the 'models' extra: "
|
|
49
|
+
"pip install 'lean-memory[models]'"
|
|
50
|
+
) from e
|
|
51
|
+
self._model = CrossEncoder(self.model_name)
|
|
52
|
+
return self._model
|
|
53
|
+
|
|
54
|
+
def score(self, query: str, docs: list[str]) -> list[float]:
|
|
55
|
+
if not docs:
|
|
56
|
+
return []
|
|
57
|
+
model = self._ensure()
|
|
58
|
+
pairs = [[query, d] for d in docs]
|
|
59
|
+
return [float(s) for s in model.predict(pairs)]
|