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/__init__.py ADDED
File without changes
memor/cli.py ADDED
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+ import typer
6
+ from memor.store.sqlite_store import SqliteStore
7
+ from memor.retrieve.retriever import Retriever
8
+ from memor.types import Scope
9
+ from memor.ingest.claude_code import parse_transcript
10
+
11
+ app = typer.Typer(no_args_is_help=True)
12
+
13
+ def _db_path(db: str) -> str:
14
+ return str(Path(db).expanduser())
15
+
16
+ def _get_dim(db_path: str) -> int:
17
+ import sqlite3
18
+ try:
19
+ db = sqlite3.connect(db_path)
20
+ db.row_factory = sqlite3.Row
21
+ row = db.execute("SELECT value FROM meta WHERE key='dim'").fetchone()
22
+ db.close()
23
+ if row:
24
+ return int(row["value"])
25
+ except Exception:
26
+ pass
27
+ return 256
28
+
29
+ def _embedder(fake: bool):
30
+ if fake:
31
+ from memor.embed.fake import FakeEmbedder
32
+ return FakeEmbedder(dim=16)
33
+ return _auto_embedder()
34
+
35
+
36
+ def _auto_embedder():
37
+ """Local ONNX embedder by default. No API key needed for search."""
38
+ from memor.embed.local import LocalEmbedder
39
+ return LocalEmbedder()
40
+
41
+ HELP_TEXT = """\
42
+ memor — measured memory for coding agents
43
+
44
+ GETTING STARTED
45
+ memor install-hook Install the Claude Code recall hook + download model
46
+ memor daemon Start the background watcher (ingests + distills)
47
+ memor dashboard Open the web dashboard at localhost:8420
48
+
49
+ QUERYING
50
+ memor query <text> Search memories from the command line
51
+ --project <name> Scope to a specific project
52
+ --k <n> Number of results (default: 8)
53
+
54
+ INGESTION
55
+ memor ingest-cc <file> Ingest a single transcript
56
+ memor ingest-project <dir> Bulk ingest a project's transcripts
57
+ --project <name> Project name (required)
58
+ memor ingest-doc <file> Ingest a markdown document
59
+ --project <name> Project name (required)
60
+
61
+ MAINTENANCE
62
+ memor reingest Wipe DB and re-ingest everything
63
+ memor reingest --project <name> Re-ingest only one project
64
+ memor distill --project <name> Run distillation manually
65
+ memor forget-stale Deactivate memories not recalled in 30 days
66
+ memor scan Audit DB for leaked secrets
67
+ memor scan --purge Redact secrets in place
68
+ memor setup-model Download/retry the embedding model (~60MB)
69
+
70
+ EVALUATION
71
+ memor eval <cases.json> Run eval suite
72
+ memor eval-judge --project <name> LLM-as-judge evaluation
73
+ memor bench-embed --project <name> Compare embedding models
74
+
75
+ CONFIGURATION
76
+ Everything works locally with zero API keys.
77
+ No configuration needed — just install, hook, and run the daemon.
78
+
79
+ EXAMPLES
80
+ memor install-hook && memor daemon # one-time setup
81
+ memor query "auth flow" --project my-app
82
+ memor reingest --project my-app -y # refresh one project
83
+ memor dashboard # see stats at localhost:8420
84
+ """
85
+
86
+
87
+ @app.command("help")
88
+ def help_cmd():
89
+ """Print the memor manual."""
90
+ typer.echo(HELP_TEXT)
91
+
92
+
93
+ @app.command("setup-model")
94
+ def setup_model():
95
+ """Download the embedding model (~60MB). Re-run to retry a failed download."""
96
+ typer.echo("Downloading embedding model...")
97
+ try:
98
+ from memor.embed.local import LocalEmbedder
99
+ embedder = LocalEmbedder()
100
+ typer.echo(f"Model ready: potion-base-8M (dim={embedder.dim})")
101
+ except Exception as e:
102
+ typer.echo(f"Download failed: {e}")
103
+ typer.echo("Check your internet connection and try again: memor setup-model")
104
+ raise typer.Exit(1)
105
+
106
+
107
+ @app.command("ingest-cc")
108
+ def ingest_cc(path: str, project: str = typer.Option(...), db: str = "memor.db",
109
+ fake: bool = False, no_filter: bool = False):
110
+ e = _embedder(fake)
111
+ s = SqliteStore(_db_path(db), dim=e.dim)
112
+ arts = parse_transcript(Path(path), project=project, filter_noise=not no_filter)
113
+ s.add_artifacts(arts, e.embed([a.text for a in arts]))
114
+ typer.echo(f"ingested {len(arts)} chunks from {path}")
115
+
116
+ @app.command("query")
117
+ def query(text: str, project: str = typer.Option(None), db: str = "memor.db",
118
+ k: int = 8, fake: bool = False):
119
+ e = _embedder(fake)
120
+ s = SqliteStore(_db_path(db), dim=e.dim)
121
+ r = Retriever(s, e, k=k)
122
+ trace = r.query(text, Scope(project=project))
123
+ for h in trace.hits:
124
+ typer.echo(f"[{h.score:.3f}] {h.artifact.id} :: {h.artifact.text[:100]}")
125
+ typer.echo(f"-- {len(trace.hits)} hits, {trace.latency_ms:.1f}ms, "
126
+ f"{sum(h.artifact.token_count for h in trace.hits)} tokens")
127
+
128
+ @app.command("eval")
129
+ def eval_cmd(cases_path: str, db: str = "memor.db", k: int = 8, fake: bool = False):
130
+ from memor.eval.dataset import EvalCase
131
+ from memor.eval.runner import run_suite
132
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
133
+ raw = json.loads(Path(cases_path).read_text())
134
+ cases = [EvalCase(query=c["query"], scope_project=c["project"],
135
+ relevant_ids=set(c["relevant_ids"]),
136
+ baseline_full_tokens=c["baseline_full_tokens"]) for c in raw]
137
+ summary = run_suite(cases, store=s, embedder=e, k=k)
138
+ typer.echo(json.dumps(summary, indent=2))
139
+ s.save_eval_run({"k": k, "cases": cases_path}, summary)
140
+ typer.echo("(eval run persisted)")
141
+
142
+ @app.command("build-cases")
143
+ def build_cases(project: str = typer.Option(...), db: str = "memor.db",
144
+ out: str = "cases.json", fake: bool = False):
145
+ from memor.eval.dataset import build_counterfactual_cases
146
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
147
+ rows = s.db.execute("SELECT * FROM artifacts WHERE project=? AND kind='session_chunk'", (project,)).fetchall()
148
+ arts = [s._row_to_artifact(r) for r in rows]
149
+ cases = build_counterfactual_cases(arts, project=project)
150
+ Path(out).write_text(json.dumps([{"query":c.query,"project":c.scope_project,
151
+ "relevant_ids":sorted(c.relevant_ids),"baseline_full_tokens":c.baseline_full_tokens} for c in cases], indent=2))
152
+ typer.echo(f"wrote {len(cases)} cases to {out}")
153
+
154
+ @app.command("eval-judge")
155
+ def eval_judge_cmd(project: str = typer.Option(...), db: str = "memor.db",
156
+ k: int = 8, fake: bool = False,
157
+ llm_provider: str = "anthropic", llm_model: str = "claude-sonnet-4-6",
158
+ holdout: int = 2):
159
+ """Run LLM-as-judge eval: measures whether recalled context is actually useful."""
160
+ from memor.eval.judge import build_judge_cases, run_judge_suite
161
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
162
+ rows = s.db.execute("SELECT * FROM artifacts WHERE project=? AND kind='session_chunk'",
163
+ (project,)).fetchall()
164
+ arts = [s._row_to_artifact(r) for r in rows]
165
+ cases = build_judge_cases(arts, project=project, holdout_turns=holdout)
166
+ if not cases:
167
+ typer.echo("No judge cases could be built — need at least 2 sessions with >= 4 turns each.")
168
+ raise typer.Exit(1)
169
+ typer.echo(f"Built {len(cases)} judge cases. Running evaluation...")
170
+ if llm_provider == "anthropic":
171
+ from memor.llm.anthropic import AnthropicLLM
172
+ llm = AnthropicLLM(model=llm_model)
173
+ else:
174
+ from memor.llm.openai_compat import OpenAICompatLLM
175
+ import os
176
+ llm = OpenAICompatLLM(base_url=os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1"),
177
+ api_key=os.environ.get("OPENAI_API_KEY", ""), model=llm_model)
178
+ summary = run_judge_suite(cases, store=s, embedder=e, llm=llm, k=k)
179
+ typer.echo(json.dumps(summary, indent=2))
180
+ s.save_eval_run({"type": "judge", "k": k, "project": project, "holdout": holdout}, summary)
181
+ typer.echo(f"Judge eval complete: mean relevance = {summary['mean_relevance']:.3f}")
182
+
183
+
184
+ @app.command("bench-embed")
185
+ def bench_embed(project: str = typer.Option(...), db: str = "memor.db",
186
+ k: int = 8, fake: bool = False):
187
+ """Benchmark multiple embedding models on your data. Compares recall@k, nDCG@k, and latency."""
188
+ from memor.eval.embed_benchmark import run_embed_benchmark, CANDIDATE_MODELS
189
+ from memor.eval.dataset import build_counterfactual_cases
190
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
191
+ rows = s.db.execute("SELECT * FROM artifacts WHERE project=? AND kind='session_chunk'",
192
+ (project,)).fetchall()
193
+ arts = [s._row_to_artifact(r) for r in rows]
194
+ cases = build_counterfactual_cases(arts, project=project)
195
+ if not cases:
196
+ typer.echo("No eval cases could be built.")
197
+ raise typer.Exit(1)
198
+ typer.echo(f"Running benchmark on {len(arts)} artifacts, {len(cases)} cases...")
199
+ if fake:
200
+ from memor.embed.fake import FakeEmbedder
201
+ results = run_embed_benchmark(arts, cases, model_specs=[{"name":"fake","model_name":"fake"}],
202
+ embedder_factory=lambda _: FakeEmbedder(dim=16),
203
+ db_dir=str(Path(db).parent), k=k)
204
+ else:
205
+ results = run_embed_benchmark(arts, cases, db_dir=str(Path(db).parent), k=k)
206
+ typer.echo(f"\n{'Model':<30} {'Dim':>5} {'Recall@k':>10} {'nDCG@k':>10} {'Embed ms':>10} {'Query ms':>10}")
207
+ typer.echo("-" * 80)
208
+ for r in results:
209
+ typer.echo(f"{r.model_name:<30} {r.dim:>5} {r.recall_at_k:>10.3f} {r.ndcg_at_k:>10.3f} "
210
+ f"{r.embed_latency_ms:>10.1f} {r.retrieval_latency_ms:>10.1f}")
211
+
212
+
213
+ @app.command("distill")
214
+ def distill(project: str = typer.Option(...), db: str = "memor.db",
215
+ fake: bool = False, llm_provider: str = "anthropic", llm_model: str = "claude-sonnet-4-6"):
216
+ from memor.distill.distiller import Distiller
217
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
218
+ if llm_provider == "anthropic":
219
+ from memor.llm.anthropic import AnthropicLLM; llm = AnthropicLLM(model=llm_model)
220
+ else:
221
+ from memor.llm.openai_compat import OpenAICompatLLM
222
+ import os
223
+ llm = OpenAICompatLLM(base_url=os.environ.get("OPENAI_BASE_URL","http://localhost:11434/v1"),
224
+ api_key=os.environ.get("OPENAI_API_KEY",""), model=llm_model)
225
+ d = Distiller(s, e, llm)
226
+ rows = s.db.execute("SELECT * FROM artifacts WHERE project=? AND kind='session_chunk'", (project,)).fetchall()
227
+ by_session: dict[str, list] = {}
228
+ for r in rows:
229
+ a = s._row_to_artifact(r)
230
+ by_session.setdefault(a.meta.get("session_id","?"), []).append(a)
231
+ total = 0
232
+ for sid, chunks in by_session.items():
233
+ chunks.sort(key=lambda a: a.meta.get("ord",0))
234
+ ids = d.distill_session(sid, chunks, project=project)
235
+ total += len(ids)
236
+ typer.echo(f" session {sid}: {len(ids)} memories")
237
+ typer.echo(f"distilled {total} memories from {len(by_session)} sessions")
238
+
239
+
240
+ @app.command("ingest-project")
241
+ def ingest_project(project_dir: str, project: str = typer.Option(...),
242
+ db: str = "memor.db", fake: bool = False, no_filter: bool = False):
243
+ """Recursively ingest all .jsonl transcripts (including subagent transcripts) from a Claude Code project directory."""
244
+ e = _embedder(fake)
245
+ s = SqliteStore(_db_path(db), dim=e.dim)
246
+ files = sorted(Path(project_dir).rglob("*.jsonl"))
247
+ total = 0
248
+ for f in files:
249
+ arts = parse_transcript(f, project=project, filter_noise=not no_filter)
250
+ if arts:
251
+ s.add_artifacts(arts, e.embed([a.text for a in arts]))
252
+ total += len(arts)
253
+ typer.echo(f" {f.name}: {len(arts)} chunks")
254
+ typer.echo(f"ingested {total} chunks from {len(files)} files")
255
+
256
+ @app.command("ingest-doc")
257
+ def ingest_doc(path: str, project: str = typer.Option(...), kind: str = "note",
258
+ db: str = "memor.db", fake: bool = False):
259
+ from memor.ingest.documents import parse_document
260
+ e = _embedder(fake); s = SqliteStore(_db_path(db), dim=e.dim)
261
+ arts = parse_document(Path(path), project=project, kind=kind)
262
+ s.add_artifacts(arts, e.embed([a.text for a in arts]))
263
+ typer.echo(f"ingested {len(arts)} chunks from {path}")
264
+
265
+
266
+ @app.command("reingest")
267
+ def reingest(project: str = typer.Option(None, help="Only reingest a specific project"),
268
+ projects_dir: str = typer.Option(None, help="Override ~/.claude/projects/"),
269
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation")):
270
+ """Re-ingest transcripts. Without --project, wipes the entire DB and starts fresh.
271
+ With --project, only clears and re-ingests that project's data."""
272
+ from memor.daemon import (
273
+ CLAUDE_PROJECTS_DIR, DEFAULT_DB, STATE_FILE, DISTILLED_FILE,
274
+ run_poll_cycle, scan_transcripts, ingest_file, _make_embedder,
275
+ save_state, save_distilled_state, load_state, load_distilled_state,
276
+ )
277
+ d = Path(projects_dir) if projects_dir else CLAUDE_PROJECTS_DIR
278
+
279
+ if project:
280
+ if not confirm:
281
+ typer.confirm(
282
+ f'This will clear all data for project "{project}" and re-ingest. Continue?',
283
+ abort=True)
284
+ db_path = str(DEFAULT_DB)
285
+ if not DEFAULT_DB.exists():
286
+ typer.echo("No database found. Run 'memor daemon' first.")
287
+ raise typer.Exit(1)
288
+ embedder = _make_embedder()
289
+ store = SqliteStore(db_path, dim=embedder.dim)
290
+ deleted = store.db.execute(
291
+ "DELETE FROM artifacts WHERE project=?", (project,)).rowcount
292
+ store.db.commit()
293
+ typer.echo(f" cleared {deleted} artifacts for project '{project}'")
294
+ state = load_state()
295
+ transcripts = scan_transcripts(d)
296
+ count = 0
297
+ for path, proj_name in transcripts:
298
+ if proj_name == project:
299
+ n = ingest_file(path, proj_name, store, embedder)
300
+ state[str(path)] = path.stat().st_mtime
301
+ count += n
302
+ save_state(state)
303
+ distilled = load_distilled_state()
304
+ distilled -= {sid for sid in distilled if True}
305
+ save_distilled_state(distilled)
306
+ typer.echo(f"Done: re-ingested {count} chunks for project '{project}'")
307
+ else:
308
+ if not confirm:
309
+ typer.confirm(
310
+ f"This will delete {DEFAULT_DB} and re-ingest all transcripts. Continue?",
311
+ abort=True)
312
+ for f in [DEFAULT_DB, STATE_FILE, DISTILLED_FILE]:
313
+ if f.exists():
314
+ f.unlink()
315
+ typer.echo(f" deleted {f}")
316
+ embedder = _make_embedder()
317
+ store = SqliteStore(str(DEFAULT_DB), dim=embedder.dim)
318
+ typer.echo(f"Re-ingesting from {d}...")
319
+ state, distilled = run_poll_cycle({}, store, embedder, d)
320
+ save_state(state)
321
+ save_distilled_state(distilled)
322
+ chunks = store.db.execute(
323
+ "SELECT COUNT(*) as c FROM artifacts WHERE kind='session_chunk' AND active=1"
324
+ ).fetchone()["c"]
325
+ memories = store.db.execute(
326
+ "SELECT COUNT(*) as c FROM artifacts WHERE kind='memory' AND active=1"
327
+ ).fetchone()["c"]
328
+ typer.echo(f"Done: {chunks} chunks, {memories} memories")
329
+
330
+
331
+ @app.command("forget-stale")
332
+ def forget_stale(days: int = typer.Option(30, help="Deactivate memories not recalled in this many days"),
333
+ db: str = typer.Option(str(Path.home() / ".memor" / "memor.db")),
334
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation")):
335
+ """Deactivate memories that haven't been recalled in N days."""
336
+ db_path = _db_path(db)
337
+ if not Path(db_path).exists():
338
+ typer.echo("No database found.")
339
+ raise typer.Exit(1)
340
+ s = SqliteStore(db_path, dim=_get_dim(db_path))
341
+ stale = s.get_stale_memories(days)
342
+ if not stale:
343
+ typer.echo("No stale memories found.")
344
+ return
345
+ if not confirm:
346
+ typer.confirm(f"Deactivate {len(stale)} memories not recalled in {days} days?", abort=True)
347
+ count = s.deactivate_stale(days)
348
+ typer.echo(f"Deactivated {count} stale memories.")
349
+
350
+
351
+ @app.command("scan")
352
+ def scan(db: str = typer.Option(str(Path.home() / ".memor" / "memor.db")),
353
+ purge: bool = typer.Option(False, "--purge", help="Redact secrets in place"),
354
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation")):
355
+ """Scan the memory DB for secrets (API keys, tokens, connection strings)."""
356
+ from memor.redact import scan_artifacts, purge_secrets_from_db
357
+ db_path = _db_path(db)
358
+ if not Path(db_path).exists():
359
+ typer.echo("No database found.")
360
+ raise typer.Exit(1)
361
+ s = SqliteStore(db_path, dim=_get_dim(db_path))
362
+ findings = scan_artifacts(s)
363
+ if not findings:
364
+ typer.echo("No secrets detected in the memory store.")
365
+ return
366
+ typer.echo(f"Found potential secrets in {len(findings)} artifacts:")
367
+ for f in findings[:20]:
368
+ types = ", ".join(name for name, _ in f["secrets"])
369
+ typer.echo(f" [{f['kind']}] {f['artifact_id'][:30]} ({f['project']}) — {types}")
370
+ if len(findings) > 20:
371
+ typer.echo(f" ... and {len(findings) - 20} more")
372
+ if purge:
373
+ if not confirm:
374
+ typer.confirm(f"Redact secrets in {len(findings)} artifacts?", abort=True)
375
+ count = purge_secrets_from_db(s)
376
+ typer.echo(f"Redacted secrets in {count} artifacts.")
377
+ else:
378
+ typer.echo("Run with --purge to redact these secrets in place.")
379
+
380
+
381
+ @app.command("daemon")
382
+ def daemon(poll_interval: int = typer.Option(30, help="Seconds between polls"),
383
+ projects_dir: str = typer.Option(None, help="Override ~/.claude/projects/")):
384
+ """Run the auto-ingest daemon (foreground). Watches ~/.claude/projects/ for new transcripts."""
385
+ from memor.daemon import run_daemon, CLAUDE_PROJECTS_DIR
386
+ d = Path(projects_dir) if projects_dir else CLAUDE_PROJECTS_DIR
387
+ run_daemon(poll_interval=poll_interval, projects_dir=d)
388
+
389
+
390
+ def _install_hook_logic(settings_path: Path, hook_path: str) -> None:
391
+ """Core logic for install-hook, separated for testing."""
392
+ if settings_path.exists():
393
+ data = json.loads(settings_path.read_text())
394
+ else:
395
+ data = {}
396
+ hooks = data.setdefault("hooks", {})
397
+ prompt_hooks = hooks.setdefault("UserPromptSubmit", [])
398
+ python = sys.executable
399
+ hook_cmd = {"type": "command", "command": f"{python} {hook_path}", "timeout": 5000}
400
+ entry = {"matcher": "", "hooks": [hook_cmd]}
401
+ existing_idx = None
402
+ for i, group in enumerate(prompt_hooks):
403
+ for h in group.get("hooks", []):
404
+ if "memor-hook" in h.get("command", ""):
405
+ existing_idx = i
406
+ break
407
+ if existing_idx is not None:
408
+ break
409
+ if existing_idx is not None:
410
+ prompt_hooks[existing_idx] = entry
411
+ else:
412
+ prompt_hooks.append(entry)
413
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
414
+ settings_path.write_text(json.dumps(data, indent=2))
415
+
416
+
417
+ @app.command("install-hook")
418
+ def install_hook():
419
+ """Install the Claude Code recall hook into ~/.claude/settings.json."""
420
+ hook_path = str(Path(__file__).resolve().parent.parent / "bin" / "memor-hook.py")
421
+ settings_path = Path.home() / ".claude" / "settings.json"
422
+ _install_hook_logic(settings_path, hook_path)
423
+ typer.echo(f"Hook installed: {hook_path}")
424
+ typer.echo(f"Settings updated: {settings_path}")
425
+ typer.echo()
426
+ typer.echo("Pre-downloading embedding model...")
427
+ try:
428
+ from memor.embed.local import LocalEmbedder
429
+ embedder = LocalEmbedder()
430
+ typer.echo(f" model ready (dim={embedder.dim})")
431
+ except Exception as e:
432
+ typer.echo(f" model download failed: {e}")
433
+ typer.echo(" retry later with: memor setup-model")
434
+ typer.echo()
435
+ typer.echo("Next steps:")
436
+ typer.echo(" 1. Start the daemon: memor daemon")
437
+ typer.echo(" 2. Open the dashboard: memor dashboard")
438
+ typer.echo(" Everything works locally — no API keys needed.")
439
+
440
+
441
+ @app.command("dashboard")
442
+ def dashboard(port: int = typer.Option(8420, help="Port to serve on"),
443
+ no_open: bool = typer.Option(False, help="Don't open browser"),
444
+ db: str = typer.Option(str(Path.home() / ".memor" / "memor.db"))):
445
+ """Launch the web dashboard."""
446
+ import uvicorn
447
+ from memor.dashboard.server import create_app
448
+ db_resolved = _db_path(db)
449
+ if not Path(db_resolved).exists():
450
+ typer.echo(f"Database not found at {db_resolved}")
451
+ typer.echo("Run 'memor daemon' first to create and populate the database.")
452
+ raise typer.Exit(1)
453
+ app_instance = create_app(db_resolved)
454
+ if not no_open:
455
+ import webbrowser
456
+ import threading
457
+ threading.Timer(1.0, lambda: webbrowser.open(f"http://localhost:{port}")).start()
458
+ typer.echo(f"Memor dashboard: http://localhost:{port}")
459
+ uvicorn.run(app_instance, host="127.0.0.1", port=port, log_level="warning")
460
+
461
+
462
+ if __name__ == "__main__":
463
+ app()