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.
Files changed (48) hide show
  1. memor/__init__.py +0 -0
  2. memor/cli.py +463 -0
  3. memor/daemon.py +294 -0
  4. memor/dashboard/__init__.py +0 -0
  5. memor/dashboard/server.py +153 -0
  6. memor/dashboard/static/index.html +688 -0
  7. memor/distill/__init__.py +0 -0
  8. memor/distill/distiller.py +112 -0
  9. memor/distill/extractive.py +161 -0
  10. memor/embed/__init__.py +0 -0
  11. memor/embed/api.py +15 -0
  12. memor/embed/fake.py +16 -0
  13. memor/embed/local.py +16 -0
  14. memor/eval/__init__.py +0 -0
  15. memor/eval/baselines/__init__.py +5 -0
  16. memor/eval/baselines/base.py +15 -0
  17. memor/eval/baselines/claude_mem.py +19 -0
  18. memor/eval/baselines/graphiti.py +25 -0
  19. memor/eval/dataset.py +48 -0
  20. memor/eval/embed_benchmark.py +67 -0
  21. memor/eval/judge.py +137 -0
  22. memor/eval/metrics.py +13 -0
  23. memor/eval/runner.py +78 -0
  24. memor/feedback.py +96 -0
  25. memor/hook_server.py +144 -0
  26. memor/ingest/__init__.py +0 -0
  27. memor/ingest/claude_code.py +135 -0
  28. memor/ingest/documents.py +28 -0
  29. memor/interfaces.py +20 -0
  30. memor/llm/__init__.py +0 -0
  31. memor/llm/anthropic.py +14 -0
  32. memor/llm/base.py +7 -0
  33. memor/llm/openai_compat.py +20 -0
  34. memor/project.py +69 -0
  35. memor/recall.py +115 -0
  36. memor/redact.py +129 -0
  37. memor/retrieve/__init__.py +0 -0
  38. memor/retrieve/retriever.py +78 -0
  39. memor/store/__init__.py +0 -0
  40. memor/store/sqlite_store.py +336 -0
  41. memor/tokencount.py +9 -0
  42. memor/types.py +45 -0
  43. memor_cli-0.1.0.dist-info/METADATA +273 -0
  44. memor_cli-0.1.0.dist-info/RECORD +48 -0
  45. memor_cli-0.1.0.dist-info/WHEEL +5 -0
  46. memor_cli-0.1.0.dist-info/entry_points.txt +2 -0
  47. memor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. 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]
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,5 @@
1
+ from memor.eval.baselines.base import ExternalBaseline
2
+ from memor.eval.baselines.claude_mem import ClaudeMemBaseline
3
+ from memor.eval.baselines.graphiti import GraphitiBaseline
4
+
5
+ __all__ = ["ExternalBaseline", "ClaudeMemBaseline", "GraphitiBaseline"]
@@ -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