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/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