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
memor/recall.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+ import time
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from memor.types import Scope
7
+
8
+
9
+ @dataclass
10
+ class RecallResult:
11
+ hits_count: int
12
+ top_score: float
13
+ tokens_injected: int
14
+ latency_ms: float
15
+ status: str
16
+ status_message: str
17
+ formatted_context: str
18
+ hit_ids: list[str] = None
19
+
20
+
21
+ def _format_timestamp(epoch: float) -> str:
22
+ return datetime.fromtimestamp(epoch).strftime("%Y-%m-%d")
23
+
24
+
25
+ def _detect_status(store, project: str, hits_count: int) -> str:
26
+ if hits_count > 0:
27
+ llm_mems = store.db.execute("""
28
+ SELECT COUNT(*) as c FROM artifacts
29
+ WHERE kind='memory' AND project=? AND active=1
30
+ AND json_extract(meta, '$.mem_type') IN ('decision','lesson','snippet','bugfix')
31
+ """, (project,)).fetchone()["c"]
32
+ if llm_mems > 0:
33
+ return "ok"
34
+ return "extractive_only"
35
+ return "no_hits"
36
+
37
+
38
+ def _status_message(status: str, project: str, hits_count: int,
39
+ tokens: int, top_score: float) -> str:
40
+ if status == "ok":
41
+ return f"Memor: recalled {hits_count} memories ({tokens} tokens, {top_score:.2f} top score)"
42
+ if status == "extractive_only":
43
+ return f"Memor: recalled {hits_count} memories ({tokens} tokens, {top_score:.2f} top score)"
44
+ if status == "no_hits":
45
+ return f'Memor: no relevant memories for project "{project}" yet'
46
+ if status == "empty_db":
47
+ return 'Memor: memory store is empty — run "memor daemon" to start ingesting sessions'
48
+ if status == "no_embedder":
49
+ return "Memor: inactive — run 'memor setup-model' to download the embedding model"
50
+ return f"Memor: status={status}"
51
+
52
+
53
+ def recall(query: str, project: str, db_path: str, *,
54
+ embedder=None, k: int = 8, threshold: float = 0.3,
55
+ session_id: str = "") -> RecallResult:
56
+ t0 = time.perf_counter()
57
+
58
+ if not Path(db_path).exists():
59
+ ms = (time.perf_counter() - t0) * 1000
60
+ return RecallResult(
61
+ hits_count=0, top_score=0.0, tokens_injected=0,
62
+ latency_ms=ms, status="empty_db",
63
+ status_message=_status_message("empty_db", project, 0, 0, 0.0),
64
+ formatted_context="")
65
+
66
+ from memor.store.sqlite_store import SqliteStore
67
+ from memor.retrieve.retriever import Retriever
68
+
69
+ store = SqliteStore(db_path, dim=embedder.dim)
70
+ retriever = Retriever(store, embedder, k=k)
71
+ trace = retriever.query(query, Scope(project=project))
72
+
73
+ hits = list(trace.hits)
74
+ if session_id:
75
+ hits = [h for h in hits if h.artifact.meta.get("session_id") != session_id]
76
+ if threshold > 0.0:
77
+ hits = [h for h in hits if h.score >= threshold]
78
+ top_score = hits[0].score if hits else 0.0
79
+ tokens = sum(h.artifact.token_count for h in hits)
80
+
81
+ if not hits:
82
+ status = "no_hits"
83
+ else:
84
+ status = _detect_status(store, project, len(hits))
85
+
86
+ msg = _status_message(status, project, len(hits), tokens, top_score)
87
+
88
+ lines = []
89
+ if hits:
90
+ lines.append(f"## Recalled Memories (project: {project})")
91
+ lines.append("")
92
+ for i, h in enumerate(hits, 1):
93
+ a = h.artifact
94
+ kind_tag = a.meta.get("mem_type", a.kind)
95
+ text = a.text if len(a.text) <= 600 else a.text[:600] + "..."
96
+ source_parts = []
97
+ sid = a.meta.get("session_id")
98
+ if sid:
99
+ source_parts.append(f"session {sid[:8]}")
100
+ source_parts.append(_format_timestamp(a.created_at))
101
+ source = ", ".join(source_parts)
102
+ lines.append(f"### {i}. [{kind_tag}] {text}")
103
+ lines.append(f"Source: {source} | score: {h.score:.3f}")
104
+ lines.append("")
105
+
106
+ lines.append("---")
107
+ lines.append(msg)
108
+ formatted = "\n".join(lines)
109
+
110
+ ms = (time.perf_counter() - t0) * 1000
111
+ return RecallResult(
112
+ hits_count=len(hits), top_score=top_score, tokens_injected=tokens,
113
+ latency_ms=ms, status=status, status_message=msg,
114
+ formatted_context=formatted,
115
+ hit_ids=[h.artifact.id for h in hits])
memor/redact.py ADDED
@@ -0,0 +1,129 @@
1
+ """Secret detection and redaction — runs at ingest before embedding or storage.
2
+
3
+ Catches: API keys (AWS, OpenAI, GitHub, Anthropic, Stripe, etc.), JWTs,
4
+ PEM blocks, connection strings, .env-style assignments, high-entropy tokens.
5
+ Redacts in-place to preserve surrounding signal."""
6
+ from __future__ import annotations
7
+ import math
8
+ import re
9
+
10
+ _PLACEHOLDER = "[REDACTED]"
11
+
12
+ # --- Pattern-based detection ---
13
+
14
+ _AWS_KEY = re.compile(r"AKIA[0-9A-Z]{16}")
15
+ _AWS_SECRET = re.compile(r"(?:aws.{0,20})?[0-9a-zA-Z/+]{40}(?=\s|$|\")")
16
+ _OPENAI_KEY = re.compile(r"sk-[a-zA-Z0-9_-]{20,}")
17
+ _ANTHROPIC_KEY = re.compile(r"sk-ant-[a-zA-Z0-9_-]{20,}")
18
+ _GITHUB_TOKEN = re.compile(r"gh[ps]_[A-Za-z0-9_]{36,}")
19
+ _GITHUB_FINE = re.compile(r"github_pat_[A-Za-z0-9_]{20,}")
20
+ _STRIPE_KEY = re.compile(r"[sr]k_(live|test)_[A-Za-z0-9]{20,}")
21
+ _SLACK_TOKEN = re.compile(r"xox[bpras]-[A-Za-z0-9\-]{10,}")
22
+ _GENERIC_KEY = re.compile(r"(?:key|token|secret|password|apikey|api_key)[\"']?\s*[=:]\s*['\"]?([^\s'\"]{8,})", re.I)
23
+ _JWT = re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}")
24
+ _PEM_BLOCK = re.compile(r"-----BEGIN [A-Z ]+-----.*?-----END [A-Z ]+-----", re.DOTALL)
25
+ _CONN_STRING = re.compile(
26
+ r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|sqlite)"
27
+ r"://[^\s\"'`]{10,}", re.I)
28
+ _ENV_ASSIGNMENT = re.compile(
29
+ r"^[A-Z_]{2,50}(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASS|_API|_CREDENTIAL)[=:]\s*\S+",
30
+ re.MULTILINE)
31
+
32
+ _PATTERNS: list[tuple[str, re.Pattern]] = [
33
+ ("aws_key", _AWS_KEY),
34
+ ("aws_secret", _AWS_SECRET),
35
+ ("openai_key", _OPENAI_KEY),
36
+ ("anthropic_key", _ANTHROPIC_KEY),
37
+ ("github_token", _GITHUB_TOKEN),
38
+ ("github_fine", _GITHUB_FINE),
39
+ ("stripe_key", _STRIPE_KEY),
40
+ ("slack_token", _SLACK_TOKEN),
41
+ ("jwt", _JWT),
42
+ ("pem", _PEM_BLOCK),
43
+ ("connection_string", _CONN_STRING),
44
+ ("env_assignment", _ENV_ASSIGNMENT),
45
+ ("generic_key", _GENERIC_KEY),
46
+ ]
47
+
48
+ # --- Entropy-based detection for unrecognized high-entropy tokens ---
49
+
50
+ _TOKEN_RE = re.compile(r"[A-Za-z0-9_/+\-]{20,}")
51
+
52
+ def _shannon_entropy(s: str) -> float:
53
+ if not s:
54
+ return 0.0
55
+ freq: dict[str, int] = {}
56
+ for c in s:
57
+ freq[c] = freq.get(c, 0) + 1
58
+ n = len(s)
59
+ return -sum((count / n) * math.log2(count / n) for count in freq.values())
60
+
61
+ _ENTROPY_THRESHOLD = 4.0
62
+ _MIN_ENTROPY_LEN = 20
63
+
64
+
65
+ def detect_secrets(text: str) -> list[tuple[str, str]]:
66
+ """Return list of (pattern_name, matched_string) for all secrets found."""
67
+ found: list[tuple[str, str]] = []
68
+ for name, pattern in _PATTERNS:
69
+ for m in pattern.finditer(text):
70
+ matched = m.group(1) if m.lastindex and name == "generic_key" else m.group(0)
71
+ found.append((name, matched))
72
+
73
+ for m in _TOKEN_RE.finditer(text):
74
+ token = m.group(0)
75
+ if len(token) >= _MIN_ENTROPY_LEN and _shannon_entropy(token) >= _ENTROPY_THRESHOLD:
76
+ already = any(token in s for _, s in found)
77
+ if not already:
78
+ found.append(("high_entropy", token))
79
+ return found
80
+
81
+
82
+ def redact_text(text: str) -> tuple[str, int]:
83
+ """Redact secrets in text. Returns (redacted_text, count_of_redactions)."""
84
+ secrets = detect_secrets(text)
85
+ if not secrets:
86
+ return text, 0
87
+ result = text
88
+ count = 0
89
+ for _, secret in sorted(secrets, key=lambda x: -len(x[1])):
90
+ if secret in result:
91
+ result = result.replace(secret, _PLACEHOLDER)
92
+ count += 1
93
+ return result, count
94
+
95
+
96
+ def scan_artifacts(store) -> list[dict]:
97
+ """Scan all active artifacts for secrets. Returns list of findings."""
98
+ rows = store.db.execute(
99
+ "SELECT id, project, text, kind FROM artifacts WHERE active = 1"
100
+ ).fetchall()
101
+ findings = []
102
+ for r in rows:
103
+ secrets = detect_secrets(r["text"])
104
+ if secrets:
105
+ findings.append({
106
+ "artifact_id": r["id"],
107
+ "project": r["project"],
108
+ "kind": r["kind"],
109
+ "secrets": [(name, s[:20] + "...") for name, s in secrets],
110
+ })
111
+ return findings
112
+
113
+
114
+ def purge_secrets_from_db(store) -> int:
115
+ """Redact secrets from all active artifacts in place. Returns count of affected artifacts."""
116
+ rows = store.db.execute(
117
+ "SELECT id, text FROM artifacts WHERE active = 1"
118
+ ).fetchall()
119
+ affected = 0
120
+ for r in rows:
121
+ redacted, count = redact_text(r["text"])
122
+ if count > 0:
123
+ store.db.execute(
124
+ "UPDATE artifacts SET text = ? WHERE id = ?",
125
+ (redacted, r["id"]))
126
+ affected += 1
127
+ if affected:
128
+ store.db.commit()
129
+ return affected
File without changes
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+ import math
3
+ import time
4
+ from memor.types import Scope, Hit, RetrievalTrace
5
+ from memor.interfaces import Embedder, MemoryStore
6
+
7
+ EDGE_TYPES = ["fixes", "supersedes", "part_of", "derived_from"]
8
+
9
+ KIND_WEIGHTS = {
10
+ "memory": 1.3,
11
+ "session_chunk": 1.0,
12
+ "note": 1.1,
13
+ }
14
+
15
+ # Half-life in days: memories older than this get half the recency boost
16
+ RECENCY_HALF_LIFE_DAYS = 14
17
+
18
+
19
+ class Retriever:
20
+ def __init__(self, store: MemoryStore, embedder: Embedder, *,
21
+ k: int = 8, recency_weight: float = 0.25,
22
+ kind_weight: float = 0.15, quality_weight: float = 0.10,
23
+ edge_expand: bool = True):
24
+ self.store, self.embedder = store, embedder
25
+ self.k, self.edge_expand = k, edge_expand
26
+ self.w_sim = 1.0 - recency_weight - kind_weight - quality_weight
27
+ self.w_rec = recency_weight
28
+ self.w_kind = kind_weight
29
+ self.w_qual = quality_weight
30
+
31
+ def query(self, text: str, scope: Scope) -> RetrievalTrace:
32
+ t0 = time.perf_counter()
33
+ now = time.time()
34
+ qv = self.embedder.embed([text])[0]
35
+ base = self.store.search(qv, scope, self.k)
36
+
37
+ candidates = len(base)
38
+ hits: dict[str, Hit] = {}
39
+
40
+ sim_scores = [sim for _, sim in base]
41
+ sim_max = max(sim_scores) if sim_scores else 1.0
42
+ sim_min = min(sim_scores) if sim_scores else 0.0
43
+ sim_range = (sim_max - sim_min) or 1.0
44
+
45
+ quality_cache = {}
46
+ has_quality = hasattr(self.store, 'get_quality_score')
47
+
48
+ for a, sim in base:
49
+ norm_sim = (sim - sim_min) / sim_range
50
+
51
+ age_days = (now - a.created_at) / 86400
52
+ recency = math.exp(-0.693 * age_days / RECENCY_HALF_LIFE_DAYS)
53
+
54
+ kind_boost = KIND_WEIGHTS.get(a.kind, 1.0) - 1.0
55
+
56
+ if has_quality and a.id not in quality_cache:
57
+ quality_cache[a.id] = self.store.get_quality_score(a.id)
58
+ quality = quality_cache.get(a.id, 0.5)
59
+
60
+ score = (self.w_sim * norm_sim + self.w_rec * recency
61
+ + self.w_kind * kind_boost + self.w_qual * quality)
62
+ hits[a.id] = Hit(a, score, {
63
+ "sim": sim, "norm_sim": round(norm_sim, 3),
64
+ "recency": round(recency, 3), "kind": a.kind,
65
+ "quality": round(quality, 3), "edge": 0.0,
66
+ })
67
+
68
+ if self.edge_expand and base:
69
+ seed_ids = [a.id for a, _ in base]
70
+ for nb in self.store.neighbors(seed_ids, EDGE_TYPES, hops=1):
71
+ if nb.id not in hits:
72
+ hits[nb.id] = Hit(nb, 0.5 * max(h.score for h in hits.values()),
73
+ {"sim": 0.0, "norm_sim": 0.0,
74
+ "recency": 0.0, "kind": nb.kind, "edge": 1.0})
75
+
76
+ ranked = sorted(hits.values(), key=lambda h: h.score, reverse=True)[:self.k]
77
+ return RetrievalTrace(query=text, scope=scope, candidates=candidates,
78
+ hits=ranked, latency_ms=(time.perf_counter()-t0)*1000)
File without changes
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+ import json, sqlite3, struct
3
+ from pathlib import Path
4
+ import sqlite_vec
5
+ from memor.types import Artifact, Scope
6
+
7
+ def _serialize(v: list[float]) -> bytes:
8
+ return struct.pack("%sf" % len(v), *v)
9
+
10
+ class SqliteStore:
11
+ def __init__(self, path: str, dim: int):
12
+ self.dim = dim
13
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
14
+ self.db = sqlite3.connect(path, check_same_thread=False)
15
+ self.db.execute("PRAGMA journal_mode=WAL")
16
+ self.db.row_factory = sqlite3.Row
17
+ self.db.enable_load_extension(True)
18
+ sqlite_vec.load(self.db)
19
+ self.db.enable_load_extension(False)
20
+ self._init_schema()
21
+ self._check_dim(dim)
22
+
23
+ def _init_schema(self):
24
+ self.db.executescript(f"""
25
+ CREATE TABLE IF NOT EXISTS artifacts(
26
+ id TEXT PRIMARY KEY, kind TEXT, project TEXT, source TEXT,
27
+ text TEXT, token_count INTEGER, created_at REAL, meta TEXT,
28
+ active INTEGER DEFAULT 1, superseded_by TEXT);
29
+ CREATE TABLE IF NOT EXISTS edges(
30
+ src_id TEXT, dst_id TEXT, type TEXT,
31
+ PRIMARY KEY(src_id, dst_id, type));
32
+ CREATE INDEX IF NOT EXISTS idx_art_project ON artifacts(project, active);
33
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_artifacts USING vec0(
34
+ embedding float[{self.dim}]);
35
+ CREATE TABLE IF NOT EXISTS eval_runs(
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT, created_at REAL, config TEXT, metrics TEXT);
37
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
38
+ CREATE TABLE IF NOT EXISTS memory_quality(
39
+ artifact_id TEXT PRIMARY KEY,
40
+ recall_count INTEGER DEFAULT 0,
41
+ use_count INTEGER DEFAULT 0,
42
+ last_recalled REAL,
43
+ quality_score REAL DEFAULT 0.5);
44
+ CREATE TABLE IF NOT EXISTS recall_log(
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ timestamp REAL, project TEXT, query_preview TEXT,
47
+ hits_count INTEGER, top_score REAL, tokens_injected INTEGER,
48
+ latency_ms REAL, status TEXT, session_id TEXT);
49
+ """)
50
+ self.db.commit()
51
+
52
+ def _check_dim(self, dim: int):
53
+ row = self.db.execute("SELECT value FROM meta WHERE key='dim'").fetchone()
54
+ if row is None:
55
+ self.db.execute("INSERT INTO meta(key, value) VALUES('dim', ?)", (str(dim),))
56
+ self.db.commit()
57
+ elif int(row["value"]) != dim:
58
+ raise SystemExit(
59
+ f"Embedding dimension mismatch: database was created with dim={row['value']} "
60
+ f"but current embedder has dim={dim}. Use the same embedder or re-ingest."
61
+ )
62
+
63
+ def add_artifacts(self, artifacts: list[Artifact], vectors: list[list[float]]) -> None:
64
+ cur = self.db.cursor()
65
+ for a, v in zip(artifacts, vectors):
66
+ cur.execute(
67
+ "INSERT OR REPLACE INTO artifacts(id,kind,project,source,text,token_count,created_at,meta,active,superseded_by)"
68
+ " VALUES(?,?,?,?,?,?,?,?,1,NULL)",
69
+ (a.id, a.kind, a.project, a.source, a.text, a.token_count, a.created_at, json.dumps(a.meta)))
70
+ rowid = cur.execute("SELECT rowid FROM artifacts WHERE id=?", (a.id,)).fetchone()[0]
71
+ cur.execute("INSERT OR REPLACE INTO vec_artifacts(rowid, embedding) VALUES(?,?)",
72
+ (rowid, _serialize(v)))
73
+ self.db.commit()
74
+
75
+ def add_edge(self, src_id: str, dst_id: str, type: str) -> None:
76
+ self.db.execute("INSERT OR IGNORE INTO edges(src_id,dst_id,type) VALUES(?,?,?)",
77
+ (src_id, dst_id, type))
78
+ self.db.commit()
79
+
80
+ def _row_to_artifact(self, r) -> Artifact:
81
+ return Artifact(id=r["id"], kind=r["kind"], project=r["project"], source=r["source"],
82
+ text=r["text"], token_count=r["token_count"], created_at=r["created_at"],
83
+ meta=json.loads(r["meta"]))
84
+
85
+ def search(self, vector: list[float], scope: Scope, k: int) -> list[tuple[Artifact, float]]:
86
+ rows = self.db.execute(f"""
87
+ SELECT a.*, v.distance AS distance
88
+ FROM (SELECT rowid, distance FROM vec_artifacts
89
+ WHERE embedding MATCH ? AND k = ?) v
90
+ JOIN artifacts a ON a.rowid = v.rowid
91
+ WHERE a.active = 1
92
+ AND (? IS NULL OR a.project = ?)
93
+ AND (? IS NULL OR a.created_at >= ?)
94
+ AND (? IS NULL OR a.created_at <= ?)
95
+ ORDER BY v.distance ASC
96
+ """, (_serialize(vector), max(k*20, 200),
97
+ scope.project, scope.project,
98
+ scope.since, scope.since,
99
+ scope.until, scope.until)).fetchall()
100
+ out = []
101
+ for r in rows[:k]:
102
+ if scope.kinds is not None and r["kind"] not in scope.kinds:
103
+ continue
104
+ sim = 1.0 - float(r["distance"])
105
+ out.append((self._row_to_artifact(r), sim))
106
+ return out
107
+
108
+ def neighbors(self, ids: list[str], types: list[str], hops: int = 1) -> list[Artifact]:
109
+ qmarks_ids = ",".join("?" * len(ids))
110
+ qmarks_types = ",".join("?" * len(types))
111
+ rows = self.db.execute(f"""
112
+ WITH RECURSIVE walk(id, depth) AS (
113
+ SELECT dst_id, 1 FROM edges
114
+ WHERE src_id IN ({qmarks_ids}) AND type IN ({qmarks_types})
115
+ UNION
116
+ SELECT e.dst_id, w.depth+1 FROM edges e JOIN walk w ON e.src_id = w.id
117
+ WHERE w.depth < ? AND e.type IN ({qmarks_types})
118
+ )
119
+ SELECT DISTINCT a.* FROM artifacts a JOIN walk ON a.id = walk.id
120
+ WHERE a.active = 1
121
+ """, (*ids, *types, hops, *types)).fetchall()
122
+ return [self._row_to_artifact(r) for r in rows]
123
+
124
+ def deactivate(self, artifact_id: str, superseded_by: str) -> None:
125
+ self.db.execute("UPDATE artifacts SET active=0, superseded_by=? WHERE id=?",
126
+ (superseded_by, artifact_id))
127
+ self.add_edge(superseded_by, artifact_id, "supersedes")
128
+
129
+ def recent(self, scope: Scope, k: int) -> list[Artifact]:
130
+ """Return the k most recent active artifacts matching scope, ordered by created_at DESC."""
131
+ rows = self.db.execute("""
132
+ SELECT * FROM artifacts WHERE active = 1
133
+ AND (? IS NULL OR project = ?)
134
+ AND (? IS NULL OR created_at >= ?)
135
+ AND (? IS NULL OR created_at <= ?)
136
+ ORDER BY created_at DESC LIMIT ?
137
+ """, (scope.project, scope.project, scope.since, scope.since,
138
+ scope.until, scope.until, k)).fetchall()
139
+ return [self._row_to_artifact(r) for r in rows]
140
+
141
+ def save_eval_run(self, config: dict, metrics: dict) -> int:
142
+ import time
143
+ cur = self.db.execute("INSERT INTO eval_runs(created_at, config, metrics) VALUES(?,?,?)",
144
+ (time.time(), json.dumps(config), json.dumps(metrics)))
145
+ self.db.commit()
146
+ return cur.lastrowid
147
+
148
+ def log_recall(self, project: str, query_preview: str, hits_count: int,
149
+ top_score: float, tokens_injected: int, latency_ms: float,
150
+ status: str, session_id: str = "") -> None:
151
+ import time as _time
152
+ self.db.execute(
153
+ "INSERT INTO recall_log(timestamp,project,query_preview,hits_count,"
154
+ "top_score,tokens_injected,latency_ms,status,session_id) "
155
+ "VALUES(?,?,?,?,?,?,?,?,?)",
156
+ (_time.time(), project, query_preview[:100], hits_count, top_score,
157
+ tokens_injected, latency_ms, status, session_id))
158
+ self.db.commit()
159
+
160
+ def get_recall_stats(self) -> dict:
161
+ r = self.db.execute("""
162
+ SELECT COUNT(*) as total,
163
+ SUM(tokens_injected) as tokens,
164
+ AVG(latency_ms) as avg_latency,
165
+ SUM(CASE WHEN hits_count > 0 THEN 1 ELSE 0 END) as with_hits
166
+ FROM recall_log
167
+ """).fetchone()
168
+ total = r["total"] or 0
169
+ return {
170
+ "total_recalls": total,
171
+ "total_tokens": r["tokens"] or 0,
172
+ "avg_latency_ms": round(r["avg_latency"] or 0, 1),
173
+ "hit_rate": round((r["with_hits"] or 0) / total, 3) if total > 0 else 0,
174
+ }
175
+
176
+ def get_project_stats(self) -> list[dict]:
177
+ rows = self.db.execute("""
178
+ SELECT project,
179
+ COUNT(*) as recalls,
180
+ SUM(tokens_injected) as tokens,
181
+ AVG(CASE WHEN hits_count > 0 THEN top_score END) as avg_score,
182
+ SUM(CASE WHEN status='ok' THEN 1 ELSE 0 END) as ok_count,
183
+ SUM(CASE WHEN status='no_hits' THEN 1 ELSE 0 END) as no_hits_count,
184
+ SUM(CASE WHEN status='extractive_only' THEN 1 ELSE 0 END) as extractive_count
185
+ FROM recall_log
186
+ GROUP BY project
187
+ ORDER BY recalls DESC
188
+ """).fetchall()
189
+ return [dict(r) for r in rows]
190
+
191
+ def get_recent_recalls(self, limit: int = 50, project: str | None = None) -> list[dict]:
192
+ if project:
193
+ rows = self.db.execute(
194
+ "SELECT * FROM recall_log WHERE project=? ORDER BY timestamp DESC LIMIT ?",
195
+ (project, limit)).fetchall()
196
+ else:
197
+ rows = self.db.execute(
198
+ "SELECT * FROM recall_log ORDER BY timestamp DESC LIMIT ?",
199
+ (limit,)).fetchall()
200
+ return [dict(r) for r in rows]
201
+
202
+ def record_recall(self, artifact_ids: list[str]) -> None:
203
+ import time as _time
204
+ now = _time.time()
205
+ for aid in artifact_ids:
206
+ self.db.execute("""
207
+ INSERT INTO memory_quality(artifact_id, recall_count, use_count, last_recalled, quality_score)
208
+ VALUES(?, 1, 0, ?, 0.5)
209
+ ON CONFLICT(artifact_id) DO UPDATE SET
210
+ recall_count = recall_count + 1,
211
+ last_recalled = ?
212
+ """, (aid, now, now))
213
+ self.db.commit()
214
+
215
+ def record_usage(self, artifact_ids: list[str]) -> None:
216
+ for aid in artifact_ids:
217
+ self.db.execute("""
218
+ UPDATE memory_quality SET use_count = use_count + 1 WHERE artifact_id = ?
219
+ """, (aid,))
220
+ self.db.commit()
221
+ self._recompute_quality(artifact_ids)
222
+
223
+ def _recompute_quality(self, artifact_ids: list[str]) -> None:
224
+ for aid in artifact_ids:
225
+ row = self.db.execute(
226
+ "SELECT recall_count, use_count FROM memory_quality WHERE artifact_id=?",
227
+ (aid,)).fetchone()
228
+ if row:
229
+ rc = row["recall_count"] or 1
230
+ uc = row["use_count"] or 0
231
+ score = (uc + 1) / (rc + 2)
232
+ self.db.execute(
233
+ "UPDATE memory_quality SET quality_score=? WHERE artifact_id=?",
234
+ (round(score, 3), aid))
235
+ self.db.commit()
236
+
237
+ def get_quality_score(self, artifact_id: str) -> float:
238
+ row = self.db.execute(
239
+ "SELECT quality_score FROM memory_quality WHERE artifact_id=?",
240
+ (artifact_id,)).fetchone()
241
+ return row["quality_score"] if row else 0.5
242
+
243
+ def get_stale_memories(self, days: int = 30) -> list[str]:
244
+ import time as _time
245
+ cutoff = _time.time() - (days * 86400)
246
+ rows = self.db.execute("""
247
+ SELECT a.id FROM artifacts a
248
+ LEFT JOIN memory_quality q ON a.id = q.artifact_id
249
+ WHERE a.kind = 'memory' AND a.active = 1
250
+ AND (q.artifact_id IS NULL OR q.last_recalled IS NULL OR q.last_recalled < ?)
251
+ AND a.created_at < ?
252
+ """, (cutoff, cutoff)).fetchall()
253
+ return [r["id"] for r in rows]
254
+
255
+ def deactivate_stale(self, days: int = 30) -> int:
256
+ ids = self.get_stale_memories(days)
257
+ for aid in ids:
258
+ self.db.execute("UPDATE artifacts SET active=0 WHERE id=?", (aid,))
259
+ self.db.commit()
260
+ return len(ids)
261
+
262
+ def get_efficiency_stats(self, context_window: int = 200_000) -> dict:
263
+ sessions = self.db.execute("""
264
+ SELECT session_id,
265
+ COUNT(*) as prompts,
266
+ SUM(tokens_injected) as total_injected,
267
+ SUM(CASE WHEN hits_count > 0 THEN 1 ELSE 0 END) as prompts_with_hits,
268
+ AVG(CASE WHEN hits_count > 0 THEN top_score END) as avg_quality,
269
+ MIN(timestamp) as first_recall,
270
+ MAX(timestamp) as last_recall
271
+ FROM recall_log
272
+ WHERE session_id != ''
273
+ GROUP BY session_id
274
+ ORDER BY last_recall DESC
275
+ LIMIT 50
276
+ """).fetchall()
277
+ session_list = []
278
+ for s in sessions:
279
+ injected = s["total_injected"] or 0
280
+ prompts = s["prompts"] or 1
281
+ precision = (s["prompts_with_hits"] or 0) / prompts
282
+ overhead_pct = round(injected / context_window * 100, 2)
283
+ session_list.append({
284
+ "session_id": s["session_id"],
285
+ "prompts": prompts,
286
+ "tokens_injected": injected,
287
+ "overhead_pct": overhead_pct,
288
+ "precision": round(precision, 3),
289
+ "avg_quality": round(s["avg_quality"] or 0, 3),
290
+ "first_recall": s["first_recall"],
291
+ "last_recall": s["last_recall"],
292
+ })
293
+
294
+ totals = self.db.execute("""
295
+ SELECT COUNT(*) as total_recalls,
296
+ SUM(tokens_injected) as total_injected,
297
+ SUM(CASE WHEN hits_count > 0 THEN 1 ELSE 0 END) as with_hits,
298
+ AVG(CASE WHEN hits_count > 0 THEN top_score END) as avg_quality,
299
+ COUNT(DISTINCT session_id) as session_count
300
+ FROM recall_log WHERE session_id != ''
301
+ """).fetchone()
302
+ total_recalls = totals["total_recalls"] or 0
303
+ total_injected = totals["total_injected"] or 0
304
+ session_count = totals["session_count"] or 1
305
+ avg_per_session = round(total_injected / session_count) if session_count else 0
306
+ return {
307
+ "context_window": context_window,
308
+ "total_sessions": totals["session_count"] or 0,
309
+ "total_recalls": total_recalls,
310
+ "total_tokens_injected": total_injected,
311
+ "avg_tokens_per_session": avg_per_session,
312
+ "avg_overhead_pct": round(avg_per_session / context_window * 100, 2),
313
+ "precision": round((totals["with_hits"] or 0) / total_recalls, 3) if total_recalls else 0,
314
+ "avg_quality": round(totals["avg_quality"] or 0, 3),
315
+ "sessions": session_list,
316
+ }
317
+
318
+ def get_onboarding_status(self) -> str:
319
+ chunks = self.db.execute(
320
+ "SELECT COUNT(*) as c FROM artifacts WHERE kind='session_chunk' AND active=1"
321
+ ).fetchone()["c"]
322
+ if chunks == 0:
323
+ return "no_data"
324
+ mems = self.db.execute(
325
+ "SELECT COUNT(*) as c FROM artifacts WHERE kind='memory' AND active=1"
326
+ ).fetchone()["c"]
327
+ if mems == 0:
328
+ return "ingesting"
329
+ llm_mems = self.db.execute("""
330
+ SELECT COUNT(*) as c FROM artifacts
331
+ WHERE kind='memory' AND active=1
332
+ AND json_extract(meta, '$.mem_type') IN ('decision','lesson','snippet','bugfix')
333
+ """).fetchone()["c"]
334
+ if llm_mems > 0:
335
+ return "full"
336
+ return "extractive"
memor/tokencount.py ADDED
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+ import tiktoken
3
+
4
+ _enc = tiktoken.get_encoding("cl100k_base")
5
+
6
+ def count_tokens(text: str) -> int:
7
+ if not text:
8
+ return 0
9
+ return len(_enc.encode(text))