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/daemon.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Auto-ingest daemon: polls ~/.claude/projects/ for new/modified .jsonl transcripts,
|
|
2
|
+
then auto-distills new sessions into compact memories."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from memor.ingest.claude_code import parse_transcript
|
|
11
|
+
from memor.store.sqlite_store import SqliteStore
|
|
12
|
+
from memor.types import Scope
|
|
13
|
+
|
|
14
|
+
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
15
|
+
STATE_DIR = Path.home() / ".memor"
|
|
16
|
+
DEFAULT_DB = STATE_DIR / "memor.db"
|
|
17
|
+
STATE_FILE = STATE_DIR / "ingested.json"
|
|
18
|
+
DISTILLED_FILE = STATE_DIR / "distilled.json"
|
|
19
|
+
POLL_INTERVAL = 30 # seconds
|
|
20
|
+
MAX_DISTILL_TOKENS = 4000 # cap text sent to LLM per session
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _project_name_from_dir(dirname: str) -> str:
|
|
24
|
+
"""Derive a clean project name from a Claude projects directory name.
|
|
25
|
+
Uses the smart filesystem-aware resolver that handles dashes in dir names."""
|
|
26
|
+
from memor.project import resolve_project_from_claude_dir
|
|
27
|
+
return resolve_project_from_claude_dir(dirname)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_state() -> dict[str, float]:
|
|
31
|
+
"""Load the ingested state file: {filepath -> mtime}."""
|
|
32
|
+
if STATE_FILE.exists():
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(STATE_FILE.read_text())
|
|
35
|
+
except (json.JSONDecodeError, OSError):
|
|
36
|
+
return {}
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_state(state: dict[str, float]) -> None:
|
|
41
|
+
"""Persist the ingested state file."""
|
|
42
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scan_transcripts(projects_dir: Path) -> list[tuple[Path, str]]:
|
|
47
|
+
"""Scan for all .jsonl transcript files, returning (path, project_name) pairs."""
|
|
48
|
+
results = []
|
|
49
|
+
if not projects_dir.is_dir():
|
|
50
|
+
return results
|
|
51
|
+
for project_dir in sorted(projects_dir.iterdir()):
|
|
52
|
+
if not project_dir.is_dir():
|
|
53
|
+
continue
|
|
54
|
+
project_name = _project_name_from_dir(project_dir.name)
|
|
55
|
+
for jsonl_file in sorted(project_dir.rglob("*.jsonl")):
|
|
56
|
+
results.append((jsonl_file, project_name))
|
|
57
|
+
return results
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_distilled_state() -> set[str]:
|
|
61
|
+
"""Load the set of already-distilled session IDs."""
|
|
62
|
+
if DISTILLED_FILE.exists():
|
|
63
|
+
try:
|
|
64
|
+
return set(json.loads(DISTILLED_FILE.read_text()))
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
return set()
|
|
67
|
+
return set()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_distilled_state(distilled: set[str]) -> None:
|
|
71
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
DISTILLED_FILE.write_text(json.dumps(sorted(distilled), indent=2))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _make_llm():
|
|
76
|
+
"""Try to create an LLM for distillation. Returns None if no API key."""
|
|
77
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
78
|
+
if api_key:
|
|
79
|
+
from memor.llm.anthropic import AnthropicLLM
|
|
80
|
+
return AnthropicLLM(model="claude-sonnet-4-6", api_key=api_key)
|
|
81
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
82
|
+
base_url = os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1")
|
|
83
|
+
if api_key:
|
|
84
|
+
from memor.llm.openai_compat import OpenAICompatLLM
|
|
85
|
+
return OpenAICompatLLM(base_url=base_url, api_key=api_key, model="gpt-4o-mini")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ingest_file(path: Path, project: str, store: SqliteStore, embedder) -> int:
|
|
90
|
+
"""Ingest a single transcript file. Returns number of chunks ingested."""
|
|
91
|
+
arts = parse_transcript(path, project=project, filter_noise=True)
|
|
92
|
+
if not arts:
|
|
93
|
+
return 0
|
|
94
|
+
vecs = embedder.embed([a.text for a in arts])
|
|
95
|
+
store.add_artifacts(arts, vecs)
|
|
96
|
+
return len(arts)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def distill_new_sessions(
|
|
100
|
+
store: SqliteStore, embedder, llm, distilled: set[str]
|
|
101
|
+
) -> set[str]:
|
|
102
|
+
"""Distill any sessions that haven't been distilled yet. Returns updated set.
|
|
103
|
+
Uses full LLM distiller if llm is provided, otherwise falls back to extractive-only."""
|
|
104
|
+
if llm:
|
|
105
|
+
from memor.distill.distiller import Distiller
|
|
106
|
+
d = Distiller(store, embedder, llm)
|
|
107
|
+
else:
|
|
108
|
+
from memor.distill.distiller import ExtractiveDistiller
|
|
109
|
+
d = ExtractiveDistiller(store, embedder)
|
|
110
|
+
rows = store.db.execute(
|
|
111
|
+
"SELECT * FROM artifacts WHERE kind='session_chunk'"
|
|
112
|
+
).fetchall()
|
|
113
|
+
by_session: dict[str, list] = {}
|
|
114
|
+
for r in rows:
|
|
115
|
+
a = store._row_to_artifact(r)
|
|
116
|
+
sid = a.meta.get("session_id", "?")
|
|
117
|
+
by_session.setdefault(sid, []).append(a)
|
|
118
|
+
|
|
119
|
+
for sid, chunks in by_session.items():
|
|
120
|
+
if sid in distilled:
|
|
121
|
+
continue
|
|
122
|
+
chunks.sort(key=lambda a: a.meta.get("ord", 0))
|
|
123
|
+
total_tok = sum(c.token_count for c in chunks)
|
|
124
|
+
# Cap context sent to LLM
|
|
125
|
+
if total_tok > MAX_DISTILL_TOKENS:
|
|
126
|
+
selected = chunks[:10] + chunks[-5:]
|
|
127
|
+
else:
|
|
128
|
+
selected = chunks
|
|
129
|
+
project = chunks[0].project
|
|
130
|
+
try:
|
|
131
|
+
mem_ids = d.distill_session(sid, selected, project=project)
|
|
132
|
+
distilled.add(sid)
|
|
133
|
+
if mem_ids:
|
|
134
|
+
print(f" distilled session {sid[:20]}... -> {len(mem_ids)} memories")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f" ERROR distilling {sid[:20]}...: {e}")
|
|
137
|
+
return distilled
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
COMPACT_SIM_THRESHOLD = 0.90
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def compact_memories(store: SqliteStore, embedder) -> int:
|
|
144
|
+
"""Find near-duplicate active memories and deactivate the older/lower-quality one."""
|
|
145
|
+
rows = store.db.execute(
|
|
146
|
+
"SELECT * FROM artifacts WHERE kind='memory' AND active=1"
|
|
147
|
+
).fetchall()
|
|
148
|
+
if len(rows) < 2:
|
|
149
|
+
return 0
|
|
150
|
+
memories = [store._row_to_artifact(r) for r in rows]
|
|
151
|
+
vecs = embedder.embed([m.text for m in memories])
|
|
152
|
+
deactivated = 0
|
|
153
|
+
seen = set()
|
|
154
|
+
for i in range(len(memories)):
|
|
155
|
+
if memories[i].id in seen:
|
|
156
|
+
continue
|
|
157
|
+
for j in range(i + 1, len(memories)):
|
|
158
|
+
if memories[j].id in seen:
|
|
159
|
+
continue
|
|
160
|
+
if memories[i].project != memories[j].project:
|
|
161
|
+
continue
|
|
162
|
+
dot = sum(a * b for a, b in zip(vecs[i], vecs[j]))
|
|
163
|
+
norm_i = sum(a * a for a in vecs[i]) ** 0.5
|
|
164
|
+
norm_j = sum(a * a for a in vecs[j]) ** 0.5
|
|
165
|
+
sim = dot / (norm_i * norm_j) if norm_i and norm_j else 0
|
|
166
|
+
if sim >= COMPACT_SIM_THRESHOLD:
|
|
167
|
+
qi = store.get_quality_score(memories[i].id)
|
|
168
|
+
qj = store.get_quality_score(memories[j].id)
|
|
169
|
+
if qi != qj:
|
|
170
|
+
loser = j if qi > qj else i
|
|
171
|
+
else:
|
|
172
|
+
loser = i if memories[j].created_at > memories[i].created_at else j
|
|
173
|
+
winner = j if loser == i else i
|
|
174
|
+
store.deactivate(memories[loser].id, superseded_by=memories[winner].id)
|
|
175
|
+
seen.add(memories[loser].id)
|
|
176
|
+
deactivated += 1
|
|
177
|
+
return deactivated
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_poll_cycle(
|
|
181
|
+
state: dict[str, float],
|
|
182
|
+
store: SqliteStore,
|
|
183
|
+
embedder,
|
|
184
|
+
projects_dir: Path = CLAUDE_PROJECTS_DIR,
|
|
185
|
+
llm=None,
|
|
186
|
+
distilled: set[str] | None = None,
|
|
187
|
+
) -> tuple[dict[str, float], set[str]]:
|
|
188
|
+
"""Run one poll cycle: ingest new files, then distill new sessions.
|
|
189
|
+
Returns (updated ingest state, updated distilled set)."""
|
|
190
|
+
if distilled is None:
|
|
191
|
+
distilled = set()
|
|
192
|
+
|
|
193
|
+
new_ingested = False
|
|
194
|
+
transcripts = scan_transcripts(projects_dir)
|
|
195
|
+
|
|
196
|
+
# Pre-filter to only files that are new or modified
|
|
197
|
+
pending = [
|
|
198
|
+
(path, project)
|
|
199
|
+
for path, project in transcripts
|
|
200
|
+
if not (state.get(str(path)) is not None and path.stat().st_mtime <= state[str(path)])
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
bulk = len(pending) > 10
|
|
204
|
+
total_pending = len(pending)
|
|
205
|
+
|
|
206
|
+
for idx, (path, project) in enumerate(pending):
|
|
207
|
+
path_str = str(path)
|
|
208
|
+
current_mtime = path.stat().st_mtime
|
|
209
|
+
progress_prefix = f"[{idx + 1}/{total_pending}] " if bulk else ""
|
|
210
|
+
try:
|
|
211
|
+
count = ingest_file(path, project, store, embedder)
|
|
212
|
+
state[path_str] = current_mtime
|
|
213
|
+
if count > 0:
|
|
214
|
+
print(f" {progress_prefix}ingested {count} chunks from {path.name} (project: {project})")
|
|
215
|
+
new_ingested = True
|
|
216
|
+
else:
|
|
217
|
+
print(f" {progress_prefix}skipped {path.name} (0 chunks after filtering)")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print(f" {progress_prefix}ERROR ingesting {path.name}: {e}")
|
|
220
|
+
|
|
221
|
+
# Auto-distill new sessions (LLM if available, extractive fallback otherwise)
|
|
222
|
+
if new_ingested:
|
|
223
|
+
mode = "abstractive" if llm else "extractive (LLM-free)"
|
|
224
|
+
print(f" running {mode} distillation on new sessions...")
|
|
225
|
+
distilled = distill_new_sessions(store, embedder, llm, distilled)
|
|
226
|
+
|
|
227
|
+
# Feedback analysis: check if recalled memories were used in completed sessions
|
|
228
|
+
if new_ingested:
|
|
229
|
+
from memor.feedback import analyze_session_feedback
|
|
230
|
+
for path, project in pending:
|
|
231
|
+
session_id = path.stem
|
|
232
|
+
try:
|
|
233
|
+
used = analyze_session_feedback(store, session_id, path)
|
|
234
|
+
if used > 0:
|
|
235
|
+
print(f" feedback: {used} memories confirmed used in {session_id[:12]}...")
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# Compact near-duplicate memories (run occasionally, not every cycle)
|
|
240
|
+
if new_ingested:
|
|
241
|
+
try:
|
|
242
|
+
compacted = compact_memories(store, embedder)
|
|
243
|
+
if compacted > 0:
|
|
244
|
+
print(f" compacted {compacted} near-duplicate memories")
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
return state, distilled
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _make_embedder():
|
|
252
|
+
"""Local ONNX embedder by default. No API key needed for search."""
|
|
253
|
+
from memor.embed.local import LocalEmbedder
|
|
254
|
+
return LocalEmbedder()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def run_daemon(poll_interval: int = POLL_INTERVAL, projects_dir: Path = CLAUDE_PROJECTS_DIR) -> None:
|
|
258
|
+
"""Run the daemon loop (foreground, blocking)."""
|
|
259
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
embedder = _make_embedder()
|
|
261
|
+
store = SqliteStore(str(DEFAULT_DB), dim=embedder.dim)
|
|
262
|
+
state = load_state()
|
|
263
|
+
distilled = load_distilled_state()
|
|
264
|
+
|
|
265
|
+
llm = _make_llm()
|
|
266
|
+
|
|
267
|
+
print(r"""
|
|
268
|
+
_
|
|
269
|
+
_ __ ___ ___ _ __ ___ ___ _ __ __ _(_)
|
|
270
|
+
| '_ ` _ \ / _ \ '_ ` _ \ / _ \| '__|____ / _` | |
|
|
271
|
+
| | | | | | __/ | | | | | (_) | | |_____| (_| | |
|
|
272
|
+
|_| |_| |_|\___|_| |_| |_|\___/|_| \__,_|_|
|
|
273
|
+
""")
|
|
274
|
+
print(f" watching: {projects_dir}")
|
|
275
|
+
print(f" db: {DEFAULT_DB}")
|
|
276
|
+
print(f" embeddings: local model2vec (dim={embedder.dim})")
|
|
277
|
+
print(f" poll interval: {poll_interval}s")
|
|
278
|
+
print(f" tracking: {len(state)} ingested files, {len(distilled)} sessions distilled")
|
|
279
|
+
print(f" distillation: {'abstractive' if llm else 'extractive'}")
|
|
280
|
+
print()
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
while True:
|
|
284
|
+
print(f"[{time.strftime('%H:%M:%S')}] polling...")
|
|
285
|
+
state, distilled = run_poll_cycle(
|
|
286
|
+
state, store, embedder, projects_dir, llm=llm, distilled=distilled
|
|
287
|
+
)
|
|
288
|
+
save_state(state)
|
|
289
|
+
save_distilled_state(distilled)
|
|
290
|
+
time.sleep(poll_interval)
|
|
291
|
+
except KeyboardInterrupt:
|
|
292
|
+
print("\ndaemon stopped.")
|
|
293
|
+
save_state(state)
|
|
294
|
+
save_distilled_state(distilled)
|
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from fastapi import FastAPI, Query
|
|
5
|
+
from fastapi.responses import HTMLResponse
|
|
6
|
+
from memor.store.sqlite_store import SqliteStore
|
|
7
|
+
|
|
8
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(db_path: str | None = None) -> FastAPI:
|
|
12
|
+
if db_path is None:
|
|
13
|
+
db_path = str(Path.home() / ".memor" / "memor.db")
|
|
14
|
+
|
|
15
|
+
app = FastAPI(title="Memor Dashboard")
|
|
16
|
+
_db_path = db_path
|
|
17
|
+
|
|
18
|
+
def _store() -> SqliteStore:
|
|
19
|
+
return SqliteStore(_db_path, dim=_get_dim(_db_path))
|
|
20
|
+
|
|
21
|
+
@app.get("/", response_class=HTMLResponse)
|
|
22
|
+
def index():
|
|
23
|
+
html_path = STATIC_DIR / "index.html"
|
|
24
|
+
if html_path.exists():
|
|
25
|
+
return HTMLResponse(html_path.read_text())
|
|
26
|
+
return HTMLResponse("<h1>Memor Dashboard</h1><p>index.html not found</p>")
|
|
27
|
+
|
|
28
|
+
@app.get("/api/summary")
|
|
29
|
+
def summary():
|
|
30
|
+
store = _store()
|
|
31
|
+
recall_stats = store.get_recall_stats()
|
|
32
|
+
ingestion = {}
|
|
33
|
+
for row in store.db.execute(
|
|
34
|
+
"SELECT kind, COUNT(*) as c, SUM(token_count) as tokens "
|
|
35
|
+
"FROM artifacts WHERE active=1 GROUP BY kind"
|
|
36
|
+
).fetchall():
|
|
37
|
+
ingestion[row["kind"]] = {"count": row["c"], "tokens": row["tokens"] or 0}
|
|
38
|
+
project_count = store.db.execute(
|
|
39
|
+
"SELECT COUNT(DISTINCT project) as c FROM artifacts"
|
|
40
|
+
).fetchone()["c"]
|
|
41
|
+
recall_stats["ingestion"] = ingestion
|
|
42
|
+
recall_stats["project_count"] = project_count
|
|
43
|
+
return recall_stats
|
|
44
|
+
|
|
45
|
+
@app.get("/api/projects")
|
|
46
|
+
def projects():
|
|
47
|
+
store = _store()
|
|
48
|
+
recall_stats = store.get_project_stats()
|
|
49
|
+
if recall_stats:
|
|
50
|
+
return recall_stats
|
|
51
|
+
rows = store.db.execute("""
|
|
52
|
+
SELECT project,
|
|
53
|
+
COUNT(*) as artifacts,
|
|
54
|
+
SUM(CASE WHEN kind='session_chunk' THEN 1 ELSE 0 END) as chunks,
|
|
55
|
+
SUM(CASE WHEN kind='memory' THEN 1 ELSE 0 END) as memories,
|
|
56
|
+
SUM(token_count) as total_tokens,
|
|
57
|
+
MAX(created_at) as last_activity
|
|
58
|
+
FROM artifacts WHERE active=1
|
|
59
|
+
GROUP BY project
|
|
60
|
+
ORDER BY artifacts DESC
|
|
61
|
+
""").fetchall()
|
|
62
|
+
return [dict(r) for r in rows]
|
|
63
|
+
|
|
64
|
+
@app.get("/api/recalls")
|
|
65
|
+
def recalls(limit: int = Query(50, ge=1, le=500),
|
|
66
|
+
project: str | None = Query(None)):
|
|
67
|
+
store = _store()
|
|
68
|
+
return store.get_recent_recalls(limit=limit, project=project)
|
|
69
|
+
|
|
70
|
+
@app.get("/api/quality")
|
|
71
|
+
def quality():
|
|
72
|
+
store = _store()
|
|
73
|
+
rows = store.db.execute("""
|
|
74
|
+
SELECT q.artifact_id, q.recall_count, q.use_count, q.quality_score,
|
|
75
|
+
a.project, a.kind, json_extract(a.meta, '$.mem_type') as mem_type,
|
|
76
|
+
substr(a.text, 1, 100) as preview
|
|
77
|
+
FROM memory_quality q
|
|
78
|
+
JOIN artifacts a ON a.id = q.artifact_id
|
|
79
|
+
WHERE a.active = 1
|
|
80
|
+
ORDER BY q.quality_score DESC
|
|
81
|
+
LIMIT 50
|
|
82
|
+
""").fetchall()
|
|
83
|
+
return [dict(r) for r in rows]
|
|
84
|
+
|
|
85
|
+
@app.get("/api/efficiency")
|
|
86
|
+
def efficiency():
|
|
87
|
+
store = _store()
|
|
88
|
+
return store.get_efficiency_stats()
|
|
89
|
+
|
|
90
|
+
@app.get("/api/savings")
|
|
91
|
+
def savings():
|
|
92
|
+
store = _store()
|
|
93
|
+
rows = store.db.execute("""
|
|
94
|
+
SELECT project,
|
|
95
|
+
SUM(tokens_injected) as recalled_tokens,
|
|
96
|
+
AVG(CASE WHEN hits_count > 0 THEN top_score END) as avg_relevance
|
|
97
|
+
FROM recall_log
|
|
98
|
+
WHERE status IN ('ok', 'extractive_only')
|
|
99
|
+
GROUP BY project
|
|
100
|
+
""").fetchall()
|
|
101
|
+
result = []
|
|
102
|
+
for r in rows:
|
|
103
|
+
project = r["project"]
|
|
104
|
+
full_ctx = store.db.execute(
|
|
105
|
+
"SELECT SUM(token_count) as total FROM artifacts WHERE project=? AND active=1",
|
|
106
|
+
(project,)).fetchone()["total"] or 0
|
|
107
|
+
recalled = r["recalled_tokens"] or 0
|
|
108
|
+
result.append({
|
|
109
|
+
"project": project,
|
|
110
|
+
"recalled_tokens": recalled,
|
|
111
|
+
"full_context_tokens": full_ctx,
|
|
112
|
+
"reduction_pct": round((1 - recalled / full_ctx) * 100, 1) if full_ctx > 0 else 0,
|
|
113
|
+
"avg_relevance": round(r["avg_relevance"] or 0, 3),
|
|
114
|
+
})
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
@app.get("/api/health")
|
|
118
|
+
def health():
|
|
119
|
+
store = _store()
|
|
120
|
+
db_size = os.path.getsize(_db_path) if os.path.exists(_db_path) else 0
|
|
121
|
+
counts = {}
|
|
122
|
+
for row in store.db.execute(
|
|
123
|
+
"SELECT kind, COUNT(*) as c FROM artifacts WHERE active=1 GROUP BY kind"
|
|
124
|
+
).fetchall():
|
|
125
|
+
counts[row["kind"]] = row["c"]
|
|
126
|
+
last_ingest = store.db.execute(
|
|
127
|
+
"SELECT MAX(created_at) as t FROM artifacts"
|
|
128
|
+
).fetchone()["t"]
|
|
129
|
+
dim_row = store.db.execute("SELECT value FROM meta WHERE key='dim'").fetchone()
|
|
130
|
+
return {
|
|
131
|
+
"onboarding_status": store.get_onboarding_status(),
|
|
132
|
+
"db_size_bytes": db_size,
|
|
133
|
+
"artifact_counts": counts,
|
|
134
|
+
"last_ingest_timestamp": last_ingest,
|
|
135
|
+
"embedder_dim": int(dim_row["value"]) if dim_row else None,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return app
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_dim(db_path: str) -> int:
|
|
142
|
+
"""Read dim from meta table, default to 1536 (OpenAI)."""
|
|
143
|
+
import sqlite3
|
|
144
|
+
try:
|
|
145
|
+
db = sqlite3.connect(db_path)
|
|
146
|
+
db.row_factory = sqlite3.Row
|
|
147
|
+
row = db.execute("SELECT value FROM meta WHERE key='dim'").fetchone()
|
|
148
|
+
db.close()
|
|
149
|
+
if row:
|
|
150
|
+
return int(row["value"])
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
return 1536
|