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
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)
|
memor/store/__init__.py
ADDED
|
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"
|