memor-cli 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.
- memor/__init__.py +0 -0
- memor/cli.py +463 -0
- memor/daemon.py +294 -0
- memor/dashboard/__init__.py +0 -0
- memor/dashboard/server.py +153 -0
- memor/dashboard/static/index.html +688 -0
- memor/distill/__init__.py +0 -0
- memor/distill/distiller.py +112 -0
- memor/distill/extractive.py +161 -0
- memor/embed/__init__.py +0 -0
- memor/embed/api.py +15 -0
- memor/embed/fake.py +16 -0
- memor/embed/local.py +16 -0
- memor/eval/__init__.py +0 -0
- memor/eval/baselines/__init__.py +5 -0
- memor/eval/baselines/base.py +15 -0
- memor/eval/baselines/claude_mem.py +19 -0
- memor/eval/baselines/graphiti.py +25 -0
- memor/eval/dataset.py +48 -0
- memor/eval/embed_benchmark.py +67 -0
- memor/eval/judge.py +137 -0
- memor/eval/metrics.py +13 -0
- memor/eval/runner.py +78 -0
- memor/feedback.py +96 -0
- memor/hook_server.py +144 -0
- memor/ingest/__init__.py +0 -0
- memor/ingest/claude_code.py +135 -0
- memor/ingest/documents.py +28 -0
- memor/interfaces.py +20 -0
- memor/llm/__init__.py +0 -0
- memor/llm/anthropic.py +14 -0
- memor/llm/base.py +7 -0
- memor/llm/openai_compat.py +20 -0
- memor/project.py +69 -0
- memor/recall.py +115 -0
- memor/redact.py +129 -0
- memor/retrieve/__init__.py +0 -0
- memor/retrieve/retriever.py +78 -0
- memor/store/__init__.py +0 -0
- memor/store/sqlite_store.py +336 -0
- memor/tokencount.py +9 -0
- memor/types.py +45 -0
- memor_cli-0.1.0.dist-info/METADATA +273 -0
- memor_cli-0.1.0.dist-info/RECORD +48 -0
- memor_cli-0.1.0.dist-info/WHEEL +5 -0
- memor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- memor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- memor_cli-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import hashlib, json, re
|
|
3
|
+
from memor.types import Artifact, Scope
|
|
4
|
+
from memor.llm.base import DISTILL_PROMPT
|
|
5
|
+
from memor.distill.extractive import extract_key_chunks
|
|
6
|
+
from memor.tokencount import count_tokens
|
|
7
|
+
|
|
8
|
+
DEDUP_SIM_THRESHOLD = 0.92
|
|
9
|
+
SUPERSEDE_SIM_THRESHOLD = 0.80
|
|
10
|
+
|
|
11
|
+
_REPLACEMENT_RE = re.compile(
|
|
12
|
+
r"(instead of|no longer|switched from|ripped out|replaced .+ with|"
|
|
13
|
+
r"deprecated|migrated from|removed .+ in favor|moved away from|"
|
|
14
|
+
r"changed .+ to|swapped .+ for|dropped .+ for)", re.I)
|
|
15
|
+
|
|
16
|
+
def _extract_json(raw: str) -> dict:
|
|
17
|
+
m = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", raw, re.DOTALL)
|
|
18
|
+
text = m.group(1).strip() if m else raw.strip()
|
|
19
|
+
return json.loads(text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _store_memory(store, embedder, text: str, mem_type: str, session_id: str,
|
|
23
|
+
project: str, created: float, source_chunks: list[Artifact]) -> str | None:
|
|
24
|
+
"""Store a single memory with dedup/supersession check and provenance edges."""
|
|
25
|
+
mid = f"mem:{session_id}:{hashlib.sha1(text.encode()).hexdigest()[:8]}"
|
|
26
|
+
vec = embedder.embed([text])[0]
|
|
27
|
+
existing = store.search(vec, Scope(project=project, kinds=["memory"]), k=1)
|
|
28
|
+
if existing:
|
|
29
|
+
old_art, sim = existing[0]
|
|
30
|
+
if sim >= DEDUP_SIM_THRESHOLD:
|
|
31
|
+
return None
|
|
32
|
+
if (sim >= SUPERSEDE_SIM_THRESHOLD
|
|
33
|
+
and created > old_art.created_at
|
|
34
|
+
and _REPLACEMENT_RE.search(text)):
|
|
35
|
+
art = Artifact(
|
|
36
|
+
id=mid, kind="memory", project=project, source="distill",
|
|
37
|
+
text=text, token_count=max(1, count_tokens(text)), created_at=created,
|
|
38
|
+
meta={"mem_type": mem_type, "session_id": session_id},
|
|
39
|
+
)
|
|
40
|
+
store.add_artifacts([art], [vec])
|
|
41
|
+
store.deactivate(old_art.id, superseded_by=mid)
|
|
42
|
+
for c in source_chunks:
|
|
43
|
+
store.add_edge(mid, c.id, "derived_from")
|
|
44
|
+
return mid
|
|
45
|
+
art = Artifact(
|
|
46
|
+
id=mid, kind="memory", project=project, source="distill",
|
|
47
|
+
text=text, token_count=max(1, count_tokens(text)), created_at=created,
|
|
48
|
+
meta={"mem_type": mem_type, "session_id": session_id},
|
|
49
|
+
)
|
|
50
|
+
store.add_artifacts([art], [vec])
|
|
51
|
+
for c in source_chunks:
|
|
52
|
+
store.add_edge(mid, c.id, "derived_from")
|
|
53
|
+
return mid
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Distiller:
|
|
57
|
+
"""Two-step distiller: extractive pre-filter (free) → LLM abstractive (paid).
|
|
58
|
+
The extractive step reduces LLM input by ~90%."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, store, embedder, llm):
|
|
61
|
+
self.store, self.embedder, self.llm = store, embedder, llm
|
|
62
|
+
|
|
63
|
+
def distill_session(
|
|
64
|
+
self, session_id: str, chunks: list[Artifact], project: str
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
# Step 1: extractive pre-filter (local, free)
|
|
67
|
+
key_chunks = extract_key_chunks(chunks, self.embedder)
|
|
68
|
+
# Step 2: LLM abstractive distillation on the filtered set
|
|
69
|
+
session_text = "\n".join(c.text for c in key_chunks)
|
|
70
|
+
raw = self.llm.complete(DISTILL_PROMPT.format(session_text=session_text))
|
|
71
|
+
data = _extract_json(raw)
|
|
72
|
+
created = max((c.created_at for c in chunks), default=0.0)
|
|
73
|
+
new_ids: list[str] = []
|
|
74
|
+
for m in data.get("memories", []):
|
|
75
|
+
text = m["text"].strip()
|
|
76
|
+
mid = _store_memory(self.store, self.embedder, text, m["type"],
|
|
77
|
+
session_id, project, created, key_chunks)
|
|
78
|
+
if mid is None:
|
|
79
|
+
continue
|
|
80
|
+
sup = m.get("supersedes_text")
|
|
81
|
+
if sup:
|
|
82
|
+
prior = self.store.search(
|
|
83
|
+
self.embedder.embed([sup])[0],
|
|
84
|
+
Scope(project=project, kinds=["memory"]), k=1,
|
|
85
|
+
)
|
|
86
|
+
if prior and prior[0][1] >= 0.8 and prior[0][0].id != mid:
|
|
87
|
+
self.store.deactivate(prior[0][0].id, superseded_by=mid)
|
|
88
|
+
new_ids.append(mid)
|
|
89
|
+
return new_ids
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ExtractiveDistiller:
|
|
93
|
+
"""LLM-free distiller. Stores the key extracted chunks as memories directly.
|
|
94
|
+
Used as automatic fallback when no API key is available."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, store, embedder):
|
|
97
|
+
self.store, self.embedder = store, embedder
|
|
98
|
+
|
|
99
|
+
def distill_session(
|
|
100
|
+
self, session_id: str, chunks: list[Artifact], project: str
|
|
101
|
+
) -> list[str]:
|
|
102
|
+
from memor.distill.extractive import classify_chunk
|
|
103
|
+
key_chunks = extract_key_chunks(chunks, self.embedder)
|
|
104
|
+
created = max((c.created_at for c in chunks), default=0.0)
|
|
105
|
+
new_ids: list[str] = []
|
|
106
|
+
for c in key_chunks:
|
|
107
|
+
mem_type = classify_chunk(c.text)
|
|
108
|
+
mid = _store_memory(self.store, self.embedder, c.text, mem_type,
|
|
109
|
+
session_id, project, created, [c])
|
|
110
|
+
if mid is not None:
|
|
111
|
+
new_ids.append(mid)
|
|
112
|
+
return new_ids
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Extractive distillation — LLM-free, local-only.
|
|
2
|
+
|
|
3
|
+
Picks the highest-signal chunks from a session using:
|
|
4
|
+
1. TF-IDF scoring (rare/specific terms score higher)
|
|
5
|
+
2. Embedding clustering (one representative per topic cluster)
|
|
6
|
+
3. Heuristic rules (drop filler, prefer long structured responses + user questions)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import hashlib, math, re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from memor.types import Artifact
|
|
12
|
+
|
|
13
|
+
_DECISION_RE = re.compile(
|
|
14
|
+
r"(we decided|the approach is|instead of|switched to|chose .+ over|"
|
|
15
|
+
r"trade-?off|architecture:|design decision)", re.I)
|
|
16
|
+
_BUGFIX_RE = re.compile(
|
|
17
|
+
r"(the fix is|root cause|the bug was|the issue was|caused by|"
|
|
18
|
+
r"the problem is|this fails because|the error occurs)", re.I)
|
|
19
|
+
_LESSON_RE = re.compile(
|
|
20
|
+
r"(always use|never use|never do|important:|note:|pattern:|"
|
|
21
|
+
r"best practice|lesson learned|rule of thumb|should always|should never)", re.I)
|
|
22
|
+
_SNIPPET_RE = re.compile(r"```")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def classify_chunk(text: str) -> str:
|
|
26
|
+
"""Classify a chunk into a memory type based on content patterns."""
|
|
27
|
+
if _BUGFIX_RE.search(text):
|
|
28
|
+
return "bugfix"
|
|
29
|
+
if _DECISION_RE.search(text):
|
|
30
|
+
return "decision"
|
|
31
|
+
if _LESSON_RE.search(text):
|
|
32
|
+
return "lesson"
|
|
33
|
+
if _SNIPPET_RE.search(text) and len(text) > 200:
|
|
34
|
+
return "snippet"
|
|
35
|
+
return "extract"
|
|
36
|
+
|
|
37
|
+
MIN_CHUNK_TOKENS = 15
|
|
38
|
+
MAX_EXTRACTS = 12
|
|
39
|
+
|
|
40
|
+
_FILLER = re.compile(
|
|
41
|
+
r"^(Let me |Now let me |Now I|Good[,.]|Great[,.]|Perfect[!,.]|Done[!,.]"
|
|
42
|
+
r"|Sure[,.]|OK[,.]|Alright|Looking at |Checking |I'll )", re.I)
|
|
43
|
+
|
|
44
|
+
_SIGNAL_PATTERNS = re.compile(
|
|
45
|
+
r"(we decided|the fix is|the solution|use .+ instead of|should always|"
|
|
46
|
+
r"never use|important:|note:|bug:|pattern:|architecture:)", re.I)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tfidf_scores(chunks: list[Artifact]) -> list[float]:
|
|
50
|
+
"""Score each chunk by TF-IDF — chunks with rare, specific terms rank higher."""
|
|
51
|
+
tokenize = lambda t: re.findall(r"[a-zA-Z_][a-zA-Z0-9_]{2,}", t.lower())
|
|
52
|
+
doc_freq: Counter[str] = Counter()
|
|
53
|
+
chunk_tokens: list[list[str]] = []
|
|
54
|
+
for c in chunks:
|
|
55
|
+
toks = tokenize(c.text)
|
|
56
|
+
chunk_tokens.append(toks)
|
|
57
|
+
doc_freq.update(set(toks))
|
|
58
|
+
n = len(chunks) or 1
|
|
59
|
+
scores = []
|
|
60
|
+
for toks in chunk_tokens:
|
|
61
|
+
if not toks:
|
|
62
|
+
scores.append(0.0)
|
|
63
|
+
continue
|
|
64
|
+
tf = Counter(toks)
|
|
65
|
+
score = sum(
|
|
66
|
+
(tf[t] / len(toks)) * math.log((n + 1) / (doc_freq[t] + 1))
|
|
67
|
+
for t in tf
|
|
68
|
+
)
|
|
69
|
+
scores.append(score)
|
|
70
|
+
return scores
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _heuristic_score(chunk: Artifact) -> float:
|
|
74
|
+
"""Bonus/penalty based on content heuristics."""
|
|
75
|
+
score = 0.0
|
|
76
|
+
if chunk.token_count < MIN_CHUNK_TOKENS:
|
|
77
|
+
return -1.0
|
|
78
|
+
if _FILLER.match(chunk.text):
|
|
79
|
+
score -= 0.5
|
|
80
|
+
if _SIGNAL_PATTERNS.search(chunk.text):
|
|
81
|
+
score += 1.0
|
|
82
|
+
role = chunk.meta.get("role", "")
|
|
83
|
+
if role == "user" and chunk.token_count > 20:
|
|
84
|
+
score += 0.3
|
|
85
|
+
if role == "assistant" and chunk.token_count > 100:
|
|
86
|
+
score += 0.5
|
|
87
|
+
if "```" in chunk.text:
|
|
88
|
+
score += 0.2
|
|
89
|
+
return score
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cluster_select(chunks: list[Artifact], embedder, max_clusters: int) -> list[int]:
|
|
93
|
+
"""Cluster chunks by embedding similarity, return index of chunk nearest each centroid."""
|
|
94
|
+
if len(chunks) <= max_clusters:
|
|
95
|
+
return list(range(len(chunks)))
|
|
96
|
+
vecs = embedder.embed([c.text for c in chunks])
|
|
97
|
+
n = len(vecs)
|
|
98
|
+
dim = len(vecs[0])
|
|
99
|
+
# Simple k-means (few iterations, good enough for <300 chunks)
|
|
100
|
+
k = min(max_clusters, n)
|
|
101
|
+
centroids = [list(vecs[i]) for i in range(0, n, max(1, n // k))][:k]
|
|
102
|
+
assignments = [0] * n
|
|
103
|
+
for _ in range(8):
|
|
104
|
+
for i in range(n):
|
|
105
|
+
best_d, best_j = float("inf"), 0
|
|
106
|
+
for j, c in enumerate(centroids):
|
|
107
|
+
d = sum((a - b) ** 2 for a, b in zip(vecs[i], c))
|
|
108
|
+
if d < best_d:
|
|
109
|
+
best_d, best_j = d, j
|
|
110
|
+
assignments[i] = best_j
|
|
111
|
+
new_centroids = [[0.0] * dim for _ in range(k)]
|
|
112
|
+
counts = [0] * k
|
|
113
|
+
for i in range(n):
|
|
114
|
+
j = assignments[i]
|
|
115
|
+
counts[j] += 1
|
|
116
|
+
for d_idx in range(dim):
|
|
117
|
+
new_centroids[j][d_idx] += vecs[i][d_idx]
|
|
118
|
+
for j in range(k):
|
|
119
|
+
if counts[j] > 0:
|
|
120
|
+
for d_idx in range(dim):
|
|
121
|
+
new_centroids[j][d_idx] /= counts[j]
|
|
122
|
+
centroids = new_centroids
|
|
123
|
+
# Pick chunk nearest each centroid
|
|
124
|
+
selected = []
|
|
125
|
+
for j in range(k):
|
|
126
|
+
members = [i for i in range(n) if assignments[i] == j]
|
|
127
|
+
if not members:
|
|
128
|
+
continue
|
|
129
|
+
best_i = min(members, key=lambda i: sum(
|
|
130
|
+
(vecs[i][d] - centroids[j][d]) ** 2 for d in range(dim)))
|
|
131
|
+
selected.append(best_i)
|
|
132
|
+
return selected
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_key_chunks(
|
|
136
|
+
chunks: list[Artifact], embedder, *, max_extracts: int = MAX_EXTRACTS
|
|
137
|
+
) -> list[Artifact]:
|
|
138
|
+
"""Select the highest-signal chunks from a session. Pure local, no LLM."""
|
|
139
|
+
if not chunks:
|
|
140
|
+
return []
|
|
141
|
+
# Score each chunk: TF-IDF + heuristic
|
|
142
|
+
tfidf = _tfidf_scores(chunks)
|
|
143
|
+
max_tf = max(tfidf) or 1.0
|
|
144
|
+
combined = []
|
|
145
|
+
for i, c in enumerate(chunks):
|
|
146
|
+
h = _heuristic_score(c)
|
|
147
|
+
if h <= -1.0:
|
|
148
|
+
combined.append(-999.0)
|
|
149
|
+
continue
|
|
150
|
+
combined.append((tfidf[i] / max_tf) + h)
|
|
151
|
+
# Pre-filter: drop anything scored below 0
|
|
152
|
+
viable_idx = [i for i, s in enumerate(combined) if s > 0]
|
|
153
|
+
if not viable_idx:
|
|
154
|
+
viable_idx = list(range(len(chunks)))
|
|
155
|
+
viable = [chunks[i] for i in viable_idx]
|
|
156
|
+
# Cluster the viable chunks and pick representatives
|
|
157
|
+
cluster_idx = _cluster_select(viable, embedder, max_extracts)
|
|
158
|
+
selected = [viable[i] for i in cluster_idx]
|
|
159
|
+
# Sort by original order (temporal coherence)
|
|
160
|
+
selected.sort(key=lambda c: c.meta.get("ord", 0))
|
|
161
|
+
return selected[:max_extracts]
|
memor/embed/__init__.py
ADDED
|
File without changes
|
memor/embed/api.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class APIEmbedder:
|
|
5
|
+
"""OpenAI-compatible embedding API."""
|
|
6
|
+
def __init__(self, base_url: str = "https://api.openai.com/v1", api_key: str = "",
|
|
7
|
+
model: str = "text-embedding-3-small", dim: int = 1536):
|
|
8
|
+
self.base_url, self.api_key, self.model, self.dim = base_url, api_key, model, dim
|
|
9
|
+
|
|
10
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
11
|
+
r = httpx.post(f"{self.base_url}/embeddings",
|
|
12
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
13
|
+
json={"model": self.model, "input": texts}, timeout=60)
|
|
14
|
+
r.raise_for_status()
|
|
15
|
+
return [d["embedding"] for d in sorted(r.json()["data"], key=lambda d: d["index"])]
|
memor/embed/fake.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import hashlib, math
|
|
2
|
+
|
|
3
|
+
class FakeEmbedder:
|
|
4
|
+
"""Deterministic hash-based embedder for tests. No model download."""
|
|
5
|
+
def __init__(self, dim: int = 16):
|
|
6
|
+
self.dim = dim
|
|
7
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
8
|
+
out = []
|
|
9
|
+
for t in texts:
|
|
10
|
+
vec = [0.0] * self.dim
|
|
11
|
+
for tok in t.lower().split():
|
|
12
|
+
h = int(hashlib.sha256(tok.encode()).hexdigest(), 16)
|
|
13
|
+
vec[h % self.dim] += 1.0
|
|
14
|
+
norm = math.sqrt(sum(x*x for x in vec)) or 1.0
|
|
15
|
+
out.append([x / norm for x in vec])
|
|
16
|
+
return out
|
memor/embed/local.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from model2vec import StaticModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LocalEmbedder:
|
|
5
|
+
"""Local embedding via model2vec. Default potion-base-8M (dim 256).
|
|
6
|
+
|
|
7
|
+
Static token embeddings — no transformer inference needed.
|
|
8
|
+
~62MB model download on first use, 500x faster than ONNX transformers.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, model_name: str = "minishlab/potion-base-8M"):
|
|
11
|
+
self._model = StaticModel.from_pretrained(model_name)
|
|
12
|
+
self.dim = self._model.dim
|
|
13
|
+
|
|
14
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
15
|
+
vecs = self._model.encode(texts)
|
|
16
|
+
return vecs.tolist()
|
memor/eval/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from memor.types import Artifact
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ExternalBaseline:
|
|
6
|
+
name: str = ""
|
|
7
|
+
|
|
8
|
+
def available(self) -> bool:
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
|
|
11
|
+
def index(self, artifacts: list[Artifact]) -> None:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
def retrieve(self, query: str, project: str, k: int) -> list[str]:
|
|
15
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from memor.eval.baselines.base import ExternalBaseline
|
|
2
|
+
from memor.types import Artifact
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClaudeMemBaseline(ExternalBaseline):
|
|
7
|
+
"""Adapter for the claude-mem plugin store. Behind a flag; skipped when its
|
|
8
|
+
store path is absent so the suite still runs."""
|
|
9
|
+
|
|
10
|
+
name = "claude-mem"
|
|
11
|
+
|
|
12
|
+
def available(self) -> bool:
|
|
13
|
+
return os.path.exists(os.path.expanduser("~/.claude-mem"))
|
|
14
|
+
|
|
15
|
+
def index(self, artifacts: list[Artifact]) -> None:
|
|
16
|
+
raise NotImplementedError("point at the claude-mem store or re-ingest via its API")
|
|
17
|
+
|
|
18
|
+
def retrieve(self, query: str, project: str, k: int) -> list[str]:
|
|
19
|
+
raise NotImplementedError("query claude-mem; map results back to artifact ids")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from memor.eval.baselines.base import ExternalBaseline
|
|
2
|
+
from memor.types import Artifact
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GraphitiBaseline(ExternalBaseline):
|
|
6
|
+
"""Adapter for Zep Graphiti. Requires `pip install graphiti-core` + a Neo4j/FalkorDB
|
|
7
|
+
instance. Behind a flag; skipped when unavailable so the suite still runs."""
|
|
8
|
+
|
|
9
|
+
name = "graphiti"
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self._client = None
|
|
13
|
+
|
|
14
|
+
def available(self) -> bool:
|
|
15
|
+
try:
|
|
16
|
+
import graphiti_core # noqa
|
|
17
|
+
return True
|
|
18
|
+
except Exception:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def index(self, artifacts: list[Artifact]) -> None:
|
|
22
|
+
raise NotImplementedError("wire to graphiti_core.add_episode per artifact")
|
|
23
|
+
|
|
24
|
+
def retrieve(self, query: str, project: str, k: int) -> list[str]:
|
|
25
|
+
raise NotImplementedError("wire to graphiti_core search; map results back to artifact ids")
|
memor/eval/dataset.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class EvalCase:
|
|
6
|
+
query: str
|
|
7
|
+
scope_project: str
|
|
8
|
+
relevant_ids: set[str]
|
|
9
|
+
baseline_full_tokens: int # tokens if you dumped full history instead
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CaseResult:
|
|
13
|
+
recall: float
|
|
14
|
+
ndcg: float
|
|
15
|
+
tokens_sent: int
|
|
16
|
+
latency_ms: float
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from memor.types import Artifact
|
|
20
|
+
|
|
21
|
+
_WORD = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]{3,}")
|
|
22
|
+
|
|
23
|
+
def build_counterfactual_cases(artifacts: list["Artifact"], *, project: str,
|
|
24
|
+
min_prior_sessions: int = 1) -> list[EvalCase]:
|
|
25
|
+
"""For each session N (by start time), use its opening turn as the query and
|
|
26
|
+
label prior-session chunks that share salient tokens as the context it needed."""
|
|
27
|
+
by_session: dict[str, list[Artifact]] = {}
|
|
28
|
+
for a in artifacts:
|
|
29
|
+
by_session.setdefault(a.meta["session_id"], []).append(a)
|
|
30
|
+
for v in by_session.values():
|
|
31
|
+
v.sort(key=lambda a: a.meta.get("ord", 0))
|
|
32
|
+
sessions = sorted(by_session.items(), key=lambda kv: kv[1][0].created_at)
|
|
33
|
+
|
|
34
|
+
cases: list[EvalCase] = []
|
|
35
|
+
for idx, (sid, chunks) in enumerate(sessions):
|
|
36
|
+
if idx < min_prior_sessions:
|
|
37
|
+
continue
|
|
38
|
+
opening = chunks[0].text
|
|
39
|
+
need_tokens = {w.lower() for w in _WORD.findall(opening)}
|
|
40
|
+
prior = [a for j, (_, cs) in enumerate(sessions) if j < idx for a in cs]
|
|
41
|
+
relevant = {a.id for a in prior
|
|
42
|
+
if need_tokens & {w.lower() for w in _WORD.findall(a.text)}}
|
|
43
|
+
if not relevant:
|
|
44
|
+
continue
|
|
45
|
+
full_tokens = sum(a.token_count for a in prior)
|
|
46
|
+
cases.append(EvalCase(query=opening, scope_project=project,
|
|
47
|
+
relevant_ids=relevant, baseline_full_tokens=full_tokens))
|
|
48
|
+
return cases
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from memor.store.sqlite_store import SqliteStore
|
|
5
|
+
from memor.eval.runner import run_suite
|
|
6
|
+
from memor.eval.dataset import EvalCase
|
|
7
|
+
from memor.types import Artifact
|
|
8
|
+
|
|
9
|
+
CANDIDATE_MODELS = [
|
|
10
|
+
{"name": "potion-base-8M", "model_name": "minishlab/potion-base-8M"},
|
|
11
|
+
{"name": "potion-base-32M", "model_name": "minishlab/potion-base-32M"},
|
|
12
|
+
{"name": "potion-code-16M", "model_name": "minishlab/potion-code-16M"},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EmbedBenchmarkResult:
|
|
18
|
+
model_name: str
|
|
19
|
+
dim: int
|
|
20
|
+
recall_at_k: float
|
|
21
|
+
ndcg_at_k: float
|
|
22
|
+
tokens_sent: float
|
|
23
|
+
embed_latency_ms: float
|
|
24
|
+
retrieval_latency_ms: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_embed_benchmark(
|
|
28
|
+
artifacts: list[Artifact],
|
|
29
|
+
cases: list[EvalCase],
|
|
30
|
+
*,
|
|
31
|
+
model_specs: list[dict] | None = None,
|
|
32
|
+
embedder_factory=None,
|
|
33
|
+
db_dir: str = "/tmp",
|
|
34
|
+
k: int = 8,
|
|
35
|
+
) -> list[EmbedBenchmarkResult]:
|
|
36
|
+
specs = model_specs or CANDIDATE_MODELS
|
|
37
|
+
results = []
|
|
38
|
+
|
|
39
|
+
for spec in specs:
|
|
40
|
+
name = spec["name"]
|
|
41
|
+
if embedder_factory:
|
|
42
|
+
embedder = embedder_factory(spec)
|
|
43
|
+
else:
|
|
44
|
+
from memor.embed.local import LocalEmbedder
|
|
45
|
+
embedder = LocalEmbedder(model_name=spec["model_name"])
|
|
46
|
+
|
|
47
|
+
db_path = f"{db_dir}/bench_{name.replace('/', '_')}.db"
|
|
48
|
+
store = SqliteStore(db_path, dim=embedder.dim)
|
|
49
|
+
|
|
50
|
+
t0 = time.perf_counter()
|
|
51
|
+
vecs = embedder.embed([a.text for a in artifacts])
|
|
52
|
+
embed_ms = (time.perf_counter() - t0) * 1000
|
|
53
|
+
|
|
54
|
+
store.add_artifacts(artifacts, vecs)
|
|
55
|
+
summary = run_suite(cases, store=store, embedder=embedder, k=k)
|
|
56
|
+
mem = summary.get("memory", {})
|
|
57
|
+
|
|
58
|
+
results.append(EmbedBenchmarkResult(
|
|
59
|
+
model_name=name,
|
|
60
|
+
dim=embedder.dim,
|
|
61
|
+
recall_at_k=mem.get("recall@k", 0.0),
|
|
62
|
+
ndcg_at_k=mem.get("ndcg@k", 0.0),
|
|
63
|
+
tokens_sent=mem.get("tokens_sent", 0),
|
|
64
|
+
embed_latency_ms=embed_ms,
|
|
65
|
+
retrieval_latency_ms=mem.get("latency_ms_p50", 0.0),
|
|
66
|
+
))
|
|
67
|
+
return results
|
memor/eval/judge.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json, re, time
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from memor.types import Artifact, Scope
|
|
5
|
+
from memor.retrieve.retriever import Retriever
|
|
6
|
+
|
|
7
|
+
JUDGE_PROMPT = """You are evaluating whether recalled memory context would help a coding agent.
|
|
8
|
+
|
|
9
|
+
The agent is about to work on this task:
|
|
10
|
+
---
|
|
11
|
+
{query}
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Here is what actually happened next in the session (the agent doesn't see this — you use it to judge):
|
|
15
|
+
---
|
|
16
|
+
{holdout}
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
Here is the context that was recalled from prior sessions:
|
|
20
|
+
---
|
|
21
|
+
{recalled_context}
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
Score how relevant and useful the recalled context is for the task, given what actually happened.
|
|
25
|
+
- 1.0 = recalled context directly addresses or anticipates what happened
|
|
26
|
+
- 0.7 = recalled context is clearly relevant and would save the agent time
|
|
27
|
+
- 0.4 = recalled context is somewhat related but not directly useful
|
|
28
|
+
- 0.1 = recalled context is mostly irrelevant
|
|
29
|
+
- 0.0 = no recalled context, or completely irrelevant
|
|
30
|
+
|
|
31
|
+
Return STRICT JSON: {{"relevance_score": <float 0-1>, "reasoning": "<one sentence>"}}"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class JudgeCase:
|
|
36
|
+
query: str
|
|
37
|
+
holdout_texts: list[str]
|
|
38
|
+
scope_project: str
|
|
39
|
+
session_id: str
|
|
40
|
+
baseline_full_tokens: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class JudgeVerdict:
|
|
45
|
+
relevance_score: float
|
|
46
|
+
reasoning: str
|
|
47
|
+
tokens_recalled: int
|
|
48
|
+
latency_ms: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_judge_cases(
|
|
52
|
+
artifacts: list[Artifact], *, project: str,
|
|
53
|
+
holdout_turns: int = 2, min_session_turns: int = 4,
|
|
54
|
+
min_prior_sessions: int = 1,
|
|
55
|
+
) -> list[JudgeCase]:
|
|
56
|
+
by_session: dict[str, list[Artifact]] = {}
|
|
57
|
+
for a in artifacts:
|
|
58
|
+
by_session.setdefault(a.meta.get("session_id", "?"), []).append(a)
|
|
59
|
+
for v in by_session.values():
|
|
60
|
+
v.sort(key=lambda a: a.meta.get("ord", 0))
|
|
61
|
+
sessions = sorted(by_session.items(), key=lambda kv: kv[1][0].created_at)
|
|
62
|
+
|
|
63
|
+
cases: list[JudgeCase] = []
|
|
64
|
+
for idx, (sid, chunks) in enumerate(sessions):
|
|
65
|
+
if idx < min_prior_sessions:
|
|
66
|
+
continue
|
|
67
|
+
if len(chunks) < min_session_turns:
|
|
68
|
+
continue
|
|
69
|
+
query = chunks[0].text
|
|
70
|
+
holdout = [c.text for c in chunks[-holdout_turns:]]
|
|
71
|
+
prior_chunks = [a for j, (_, cs) in enumerate(sessions) if j < idx for a in cs]
|
|
72
|
+
full_tokens = sum(a.token_count for a in prior_chunks)
|
|
73
|
+
cases.append(JudgeCase(
|
|
74
|
+
query=query, holdout_texts=holdout, scope_project=project,
|
|
75
|
+
session_id=sid, baseline_full_tokens=full_tokens,
|
|
76
|
+
))
|
|
77
|
+
return cases
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_json(raw: str) -> dict:
|
|
81
|
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
82
|
+
if m:
|
|
83
|
+
return json.loads(m.group())
|
|
84
|
+
return json.loads(raw)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_judge_case(
|
|
88
|
+
case: JudgeCase, *, store, embedder, llm, k: int = 8
|
|
89
|
+
) -> JudgeVerdict:
|
|
90
|
+
scope = Scope(project=case.scope_project)
|
|
91
|
+
r = Retriever(store, embedder, k=k)
|
|
92
|
+
t0 = time.perf_counter()
|
|
93
|
+
trace = r.query(case.query, scope)
|
|
94
|
+
latency = (time.perf_counter() - t0) * 1000
|
|
95
|
+
|
|
96
|
+
if not trace.hits:
|
|
97
|
+
return JudgeVerdict(relevance_score=0.0, reasoning="No context recalled",
|
|
98
|
+
tokens_recalled=0, latency_ms=latency)
|
|
99
|
+
|
|
100
|
+
recalled = "\n\n".join(
|
|
101
|
+
f"[{h.artifact.kind}] {h.artifact.text}" for h in trace.hits
|
|
102
|
+
)
|
|
103
|
+
tokens_recalled = sum(h.artifact.token_count for h in trace.hits)
|
|
104
|
+
holdout = "\n".join(case.holdout_texts)
|
|
105
|
+
|
|
106
|
+
prompt = JUDGE_PROMPT.format(
|
|
107
|
+
query=case.query, holdout=holdout, recalled_context=recalled,
|
|
108
|
+
)
|
|
109
|
+
raw = llm.complete(prompt)
|
|
110
|
+
data = _extract_json(raw)
|
|
111
|
+
return JudgeVerdict(
|
|
112
|
+
relevance_score=float(data.get("relevance_score", 0.0)),
|
|
113
|
+
reasoning=data.get("reasoning", ""),
|
|
114
|
+
tokens_recalled=tokens_recalled,
|
|
115
|
+
latency_ms=latency,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_judge_suite(
|
|
120
|
+
cases: list[JudgeCase], *, store, embedder, llm, k: int = 8
|
|
121
|
+
) -> dict:
|
|
122
|
+
verdicts = []
|
|
123
|
+
for c in cases:
|
|
124
|
+
v = run_judge_case(c, store=store, embedder=embedder, llm=llm, k=k)
|
|
125
|
+
verdicts.append(v)
|
|
126
|
+
n = len(verdicts) or 1
|
|
127
|
+
return {
|
|
128
|
+
"mean_relevance": sum(v.relevance_score for v in verdicts) / n,
|
|
129
|
+
"mean_tokens_recalled": sum(v.tokens_recalled for v in verdicts) / n,
|
|
130
|
+
"mean_latency_ms": sum(v.latency_ms for v in verdicts) / n,
|
|
131
|
+
"n_cases": len(verdicts),
|
|
132
|
+
"verdicts": [
|
|
133
|
+
{"score": v.relevance_score, "reasoning": v.reasoning,
|
|
134
|
+
"tokens": v.tokens_recalled, "latency_ms": v.latency_ms}
|
|
135
|
+
for v in verdicts
|
|
136
|
+
],
|
|
137
|
+
}
|
memor/eval/metrics.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
|
|
4
|
+
if not relevant:
|
|
5
|
+
return 0.0
|
|
6
|
+
topk = retrieved[:k]
|
|
7
|
+
return len(set(topk) & relevant) / len(relevant)
|
|
8
|
+
|
|
9
|
+
def ndcg_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
|
|
10
|
+
dcg = sum((1.0 / math.log2(i + 2)) for i, x in enumerate(retrieved[:k]) if x in relevant)
|
|
11
|
+
ideal_n = min(len(relevant), k)
|
|
12
|
+
idcg = sum(1.0 / math.log2(i + 2) for i in range(ideal_n)) or 1.0
|
|
13
|
+
return dcg / idcg
|