dev-recall 0.2.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.
recall/cli.py ADDED
@@ -0,0 +1,1032 @@
1
+ """Recall CLI — all commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+ from rich import box
20
+ from rich.text import Text
21
+
22
+ from recall.config import load_config, save_config
23
+ from recall.models import EventType
24
+
25
+ console = Console()
26
+ err_console = Console(stderr=True)
27
+
28
+ # Event type icon mapping
29
+ _ICONS = {
30
+ "terminal_cmd": "⬢",
31
+ "git_commit": "◆",
32
+ "git_branch_switch": "⬡",
33
+ "git_push": "▲",
34
+ "git_merge": "⊕",
35
+ "file_save": "✎",
36
+ "file_create": "✚",
37
+ "file_delete": "✖",
38
+ "file_rename": "↔",
39
+ "repo_open": "▶",
40
+ "repo_close": "■",
41
+ "ai_chat": "✦",
42
+ "debug_session": "⏯",
43
+ "test_run": "✓",
44
+ }
45
+ _TYPE_STYLES = {
46
+ "terminal_cmd": "cyan",
47
+ "git_commit": "green",
48
+ "git_branch_switch": "yellow",
49
+ "git_push": "bright_green",
50
+ "git_merge": "green",
51
+ "file_save": "blue",
52
+ "file_create": "bright_blue",
53
+ "file_delete": "red",
54
+ "file_rename": "blue",
55
+ "repo_open": "magenta",
56
+ "repo_close": "dim magenta",
57
+ "ai_chat": "bright_yellow",
58
+ "debug_session": "bright_red",
59
+ "test_run": "bright_green",
60
+ }
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # CLI group
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @click.group()
69
+ @click.version_option()
70
+ def cli():
71
+ """Recall — local-first developer memory layer."""
72
+ pass
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # init
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @cli.command()
81
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
82
+ def init(yes: bool):
83
+ """Set up Recall: create dirs, install hooks, start daemon."""
84
+ config = load_config()
85
+
86
+ console.rule("[bold]Recall — Developer Memory Layer[/bold]")
87
+ console.print()
88
+
89
+ steps = [
90
+ "Creating data directory",
91
+ "Initializing database",
92
+ "Installing shell hook",
93
+ "Installing git hook",
94
+ "Starting daemon",
95
+ "VS Code extension (optional)",
96
+ ]
97
+
98
+ # Step 1: Create directories
99
+ _print_step(1, steps[0])
100
+ config.data_dir.mkdir(parents=True, exist_ok=True)
101
+ config.config_dir.mkdir(parents=True, exist_ok=True)
102
+ console.print(f" → [green]✓[/green] {config.data_dir}")
103
+
104
+ # Step 2: Init DB and FAISS
105
+ _print_step(2, steps[1])
106
+ from recall.storage.db import DB
107
+ from recall.storage.vectors import VectorStore
108
+
109
+ db = DB(config.db_path)
110
+ db.close()
111
+ vs = VectorStore(dim=config.embedding_dim)
112
+ vs.save(config.faiss_path)
113
+ console.print(" → [green]✓[/green] events.db + vectors.faiss")
114
+
115
+ # Step 3: Shell hook
116
+ _print_step(3, steps[2])
117
+ _install_shell_hook(config)
118
+
119
+ # Step 4: Git hooks
120
+ _print_step(4, steps[3])
121
+ _install_git_hooks(config)
122
+
123
+ # Step 5: Start daemon
124
+ _print_step(5, steps[4])
125
+ _start_daemon(config)
126
+
127
+ # Step 6: VS Code extension hint
128
+ _print_step(6, steps[5])
129
+ console.print(" → Run: [cyan]code --install-extension recall.recall-vscode[/cyan] (optional)")
130
+
131
+ console.print()
132
+ console.print("[bold green]Done. Recall is running.[/bold green]")
133
+ console.print()
134
+ console.print("Try:")
135
+ console.print(" [cyan]recall today[/cyan] — see today's activity")
136
+ console.print(' [cyan]recall ask "what did I work on?"[/cyan]')
137
+ console.print(" [cyan]recall timeline[/cyan]")
138
+
139
+
140
+ def _print_step(n: int, label: str):
141
+ console.print(f"[dim]\\[{n}/6][/dim] {label}")
142
+
143
+
144
+ def _install_shell_hook(config) -> None:
145
+ """Install shell hooks for zsh, bash, and fish unconditionally."""
146
+ hooks = [
147
+ ("zsh", Path(__file__).parent.parent / "shell" / "hook.zsh", config.hook_zsh_path, Path.home() / ".zshrc"),
148
+ ("bash", Path(__file__).parent.parent / "shell" / "hook.bash", config.hook_bash_path, Path.home() / ".bashrc"),
149
+ ("fish", Path(__file__).parent.parent / "shell" / "hook.fish", config.hook_fish_path, Path.home() / ".config" / "fish" / "config.fish"),
150
+ ]
151
+
152
+ installed: list[str] = []
153
+ for shell_name, hook_src, hook_dst, rc_file in hooks:
154
+ # Copy/write hook file
155
+ if hook_src.exists():
156
+ shutil.copy2(str(hook_src), str(hook_dst))
157
+ else:
158
+ _write_hook_from_package(config, shell_name)
159
+
160
+ # Only add to rc file if the shell is actually installed
161
+ if shell_name == "fish" and not shutil.which("fish"):
162
+ continue
163
+
164
+ # Append source line to rc file if not already present
165
+ source_line = f"\n# Recall shell hook\nsource \"{hook_dst}\"\n"
166
+ rc_content = rc_file.read_text() if rc_file.exists() else ""
167
+ if str(hook_dst) not in rc_content:
168
+ rc_file.parent.mkdir(parents=True, exist_ok=True)
169
+ with rc_file.open("a") as f:
170
+ f.write(source_line)
171
+ installed.append(shell_name)
172
+
173
+ if installed:
174
+ console.print(f" → [green]✓[/green] Installed hooks for: {', '.join(installed)}")
175
+ console.print(" → Run: [cyan]source ~/.zshrc[/cyan] or [cyan]source ~/.bashrc[/cyan] (or open a new terminal)")
176
+ else:
177
+ console.print(" → [dim]Already installed[/dim]")
178
+
179
+
180
+ def _write_hook_from_package(config, shell_name: str) -> None:
181
+ """Write a single hook file when the package is installed (no source tree available)."""
182
+ from recall._hooks import ZSH_HOOK, BASH_HOOK, FISH_HOOK # type: ignore[import]
183
+
184
+ if shell_name == "zsh":
185
+ config.hook_zsh_path.parent.mkdir(parents=True, exist_ok=True)
186
+ config.hook_zsh_path.write_text(ZSH_HOOK)
187
+ elif shell_name == "bash":
188
+ config.hook_bash_path.parent.mkdir(parents=True, exist_ok=True)
189
+ config.hook_bash_path.write_text(BASH_HOOK)
190
+ elif shell_name == "fish":
191
+ config.hook_fish_path.parent.mkdir(parents=True, exist_ok=True)
192
+ config.hook_fish_path.write_text(FISH_HOOK)
193
+
194
+
195
+ def _install_git_hooks(config) -> None:
196
+ """Copy git hooks and set core.hooksPath globally."""
197
+ hooks_src = Path(__file__).parent.parent / "git-hooks"
198
+ hooks_dst = config.git_hooks_dir
199
+ hooks_dst.mkdir(parents=True, exist_ok=True)
200
+
201
+ if hooks_src.exists():
202
+ for name in ("post-commit", "post-checkout", "pre-push", "post-merge"):
203
+ src = hooks_src / name
204
+ dst = hooks_dst / name
205
+ if src.exists():
206
+ shutil.copy2(str(src), str(dst))
207
+ os.chmod(str(dst), 0o755)
208
+ else:
209
+ # Installed via pip — hooks are bundled in recall._hooks
210
+ from recall._hooks import GIT_POST_COMMIT, GIT_POST_CHECKOUT, GIT_PRE_PUSH, GIT_POST_MERGE
211
+ for name, content in [
212
+ ("post-commit", GIT_POST_COMMIT),
213
+ ("post-checkout", GIT_POST_CHECKOUT),
214
+ ("pre-push", GIT_PRE_PUSH),
215
+ ("post-merge", GIT_POST_MERGE),
216
+ ]:
217
+ dst = hooks_dst / name
218
+ dst.write_text(content)
219
+ os.chmod(str(dst), 0o755)
220
+
221
+ try:
222
+ subprocess.run(
223
+ ["git", "config", "--global", "core.hooksPath", str(hooks_dst)],
224
+ check=True,
225
+ capture_output=True,
226
+ )
227
+ console.print(f" → [green]✓[/green] Set core.hooksPath = {hooks_dst}")
228
+ except subprocess.CalledProcessError as exc:
229
+ console.print(f" → [yellow]Warning:[/yellow] could not set git hooksPath: {exc.stderr.decode()}")
230
+
231
+
232
+ def _start_daemon(config) -> None:
233
+ """Try to start via systemd, fallback to background subprocess."""
234
+ from recall.daemon import write_systemd_unit, is_running
235
+
236
+ if is_running(config):
237
+ console.print(" → [dim]Daemon already running[/dim]")
238
+ return
239
+
240
+ try:
241
+ unit_path = write_systemd_unit(config)
242
+ subprocess.run(
243
+ ["systemctl", "--user", "enable", "--now", "dev-recall"],
244
+ check=True,
245
+ capture_output=True,
246
+ )
247
+ console.print(f" → [green]✓[/green] Installed systemd user service ({unit_path})")
248
+ # Get PID
249
+ time.sleep(1)
250
+ result = subprocess.run(
251
+ ["systemctl", "--user", "show", "dev-recall", "--property=MainPID"],
252
+ capture_output=True, text=True,
253
+ )
254
+ pid = result.stdout.strip().split("=")[-1]
255
+ console.print(f" → dev-recall.service is running (PID {pid})")
256
+ except (subprocess.CalledProcessError, FileNotFoundError):
257
+ # systemd not available — start as background process
258
+ from recall.daemon import start_daemon_background
259
+ try:
260
+ pid = start_daemon_background(config)
261
+ console.print(f" → [green]✓[/green] Started daemon in background (PID {pid})")
262
+ except Exception as exc:
263
+ console.print(f" → [yellow]Warning:[/yellow] could not start daemon: {exc}")
264
+ console.print(" Run manually: [cyan]recall daemon start[/cyan]")
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # daemon
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ @cli.group()
273
+ def daemon():
274
+ """Manage the Recall background daemon."""
275
+ pass
276
+
277
+
278
+ @daemon.command("start")
279
+ @click.option("--foreground", is_flag=True, help="Run in foreground (for systemd/debug)")
280
+ def daemon_start(foreground: bool):
281
+ """Start the daemon."""
282
+ config = load_config()
283
+ from recall.daemon import is_running, Daemon
284
+
285
+ if not foreground and is_running(config):
286
+ console.print("[yellow]Daemon is already running.[/yellow]")
287
+ return
288
+
289
+ if foreground:
290
+ logging.basicConfig(
291
+ level=logging.INFO,
292
+ format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
293
+ )
294
+ d = Daemon(config)
295
+ d.start(foreground=True)
296
+ else:
297
+ from recall.daemon import start_daemon_background
298
+ pid = start_daemon_background(config)
299
+ time.sleep(1)
300
+ console.print(f"[green]Daemon started (PID {pid})[/green]")
301
+
302
+
303
+ @daemon.command("stop")
304
+ def daemon_stop():
305
+ """Stop the daemon."""
306
+ config = load_config()
307
+ from recall.daemon import stop_daemon, is_running
308
+
309
+ if not is_running(config):
310
+ console.print("[yellow]Daemon is not running.[/yellow]")
311
+ return
312
+ if stop_daemon(config):
313
+ console.print("[green]Daemon stopped.[/green]")
314
+ else:
315
+ console.print("[red]Could not stop daemon.[/red]")
316
+
317
+
318
+ @daemon.command("status")
319
+ def daemon_status():
320
+ """Show daemon status and stats."""
321
+ config = load_config()
322
+ from recall.daemon import is_running, read_pid
323
+
324
+ running = is_running(config)
325
+ pid = read_pid(config)
326
+ status_str = "[green]running[/green]" if running else "[red]stopped[/red]"
327
+ console.print(f"Status: {status_str}" + (f" (PID {pid})" if pid else ""))
328
+
329
+ if config.db_path.exists():
330
+ from recall.storage.db import DB
331
+ from recall.storage.vectors import VectorStore
332
+
333
+ db = DB(config.db_path)
334
+ total = db.get_event_count()
335
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
336
+ today_events = len(db.get_events_by_date(today))
337
+ db.close()
338
+
339
+ vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim)
340
+
341
+ console.print(f"Events total: {total}")
342
+ console.print(f"Events today: {today_events}")
343
+ console.print(f"Vectors: {vectors.size()}")
344
+ console.print(f"DB size: {_human_size(config.db_path.stat().st_size)}")
345
+
346
+
347
+ @daemon.command("install")
348
+ def daemon_install():
349
+ """Install and enable the systemd user service."""
350
+ config = load_config()
351
+ from recall.daemon import write_systemd_unit
352
+
353
+ unit_path = write_systemd_unit(config)
354
+ console.print(f"Written: {unit_path}")
355
+ try:
356
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
357
+ subprocess.run(["systemctl", "--user", "enable", "--now", "dev-recall"], check=True)
358
+ console.print("[green]Service enabled and started.[/green]")
359
+ except (subprocess.CalledProcessError, FileNotFoundError) as exc:
360
+ console.print(f"[yellow]systemctl failed: {exc}[/yellow]")
361
+ console.print("Start manually: [cyan]recall daemon start[/cyan]")
362
+
363
+
364
+ @daemon.command("logs")
365
+ @click.option("--lines", "-n", default=50, help="Number of lines to show")
366
+ def daemon_logs(lines: int):
367
+ """Tail the daemon log file."""
368
+ config = load_config()
369
+ if not config.log_path.exists():
370
+ console.print("[yellow]No log file found.[/yellow]")
371
+ return
372
+ try:
373
+ result = subprocess.run(
374
+ ["tail", f"-n{lines}", str(config.log_path)],
375
+ capture_output=True, text=True,
376
+ )
377
+ console.print(result.stdout)
378
+ except FileNotFoundError:
379
+ # tail not available, fallback
380
+ lines_all = config.log_path.read_text().splitlines()
381
+ for line in lines_all[-lines:]:
382
+ console.print(line)
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # ask
387
+ # ---------------------------------------------------------------------------
388
+
389
+
390
+ @cli.command()
391
+ @click.argument("query")
392
+ @click.option("--top-k", default=10, help="Number of results to retrieve")
393
+ @click.option("--show-events", is_flag=True, help="Print retrieved events alongside the answer")
394
+ @click.option("--no-llm", is_flag=True, help="Skip LLM, just show retrieved events")
395
+ def ask(query: str, top_k: int, show_events: bool, no_llm: bool):
396
+ """Answer a question about your work history using AI."""
397
+ config = load_config()
398
+ _ensure_db(config)
399
+
400
+ from recall.storage.db import DB
401
+ from recall.storage.vectors import VectorStore
402
+ from recall.processor.embedder import EmbedderQueue
403
+ from recall.query.retriever import Retriever
404
+ from recall.query.context import build_prompt_ask
405
+ from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure
406
+ from recall.query.timeparser import parse_time_expression, humanise_range
407
+
408
+ configure(model=config.llm_model)
409
+
410
+ db = DB(config.db_path)
411
+ vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim)
412
+ embedder = EmbedderQueue(db=db, vectors=vectors, model_name=config.embedding_model)
413
+ retriever = Retriever(db=db, vectors=vectors, embedder=embedder)
414
+
415
+ with console.status("[dim]Searching…[/dim]"):
416
+ parsed_range = parse_time_expression(query)
417
+ events = retriever.search(query, top_k=top_k)
418
+
419
+ time_range_str: Optional[str] = None
420
+ if parsed_range:
421
+ time_range_str = humanise_range(parsed_range[0], parsed_range[1])
422
+
423
+ if not events:
424
+ console.print("[yellow]No matching events found.[/yellow]")
425
+ db.close()
426
+ return
427
+
428
+ if no_llm or not is_available():
429
+ if not no_llm:
430
+ console.print("[dim]No LLM key configured — showing raw events.[/dim]")
431
+ _print_events_table(events)
432
+ db.close()
433
+ return
434
+
435
+ try:
436
+ with console.status("[dim]Asking LLM…[/dim]"):
437
+ messages = build_prompt_ask(query, events, time_range_str)
438
+ answer = llm_ask(messages)
439
+ except DevMemLLMError as exc:
440
+ console.print(f"[red]LLM error:[/red] {exc}")
441
+ _print_events_table(events)
442
+ db.close()
443
+ return
444
+
445
+ console.print()
446
+ console.print(answer)
447
+
448
+ if show_events:
449
+ console.print()
450
+ _print_events_table(events)
451
+
452
+ db.close()
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # today
457
+ # ---------------------------------------------------------------------------
458
+
459
+
460
+ @cli.command()
461
+ @click.option("--raw", is_flag=True, help="Skip LLM, show chronological event list")
462
+ def today(raw: bool):
463
+ """Show what you worked on today."""
464
+ config = load_config()
465
+ _ensure_db(config)
466
+
467
+ from recall.storage.db import DB
468
+ from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure
469
+ from recall.query.context import build_prompt_summary
470
+
471
+ configure(model=config.llm_model)
472
+ db = DB(config.db_path)
473
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
474
+ events = db.get_events_by_date(date_str)
475
+
476
+ if not events:
477
+ console.print("[yellow]No events recorded today yet.[/yellow]")
478
+ console.print("[dim]Make sure the daemon is running: recall daemon status[/dim]")
479
+ db.close()
480
+ return
481
+
482
+ if raw:
483
+ _print_events_table(events, title=f"Today ({date_str})")
484
+ db.close()
485
+ return
486
+
487
+ # Check for cached summary
488
+ summary_row = db.get_daily_summary(date_str)
489
+ if summary_row and summary_row.get("summary"):
490
+ console.rule(f"[bold]Today — {date_str}[/bold]")
491
+ console.print(summary_row["summary"])
492
+ db.close()
493
+ return
494
+
495
+ if not is_available():
496
+ console.print("[dim]No LLM key — showing raw events.[/dim]")
497
+ _print_events_table(events, title=f"Today ({date_str})")
498
+ db.close()
499
+ return
500
+
501
+ try:
502
+ with console.status("[dim]Generating summary…[/dim]"):
503
+ messages = build_prompt_summary(date_str, events)
504
+ summary = llm_ask(messages)
505
+ repos = list({e.repo_name for e in events if e.repo_name})
506
+ highlights = [e.content for e in events if "commit" in e.event_type.value][:10]
507
+ db.upsert_daily_summary(date_str, summary, repos, highlights, len(events))
508
+ console.rule(f"[bold]Today — {date_str}[/bold]")
509
+ console.print(summary)
510
+ except DevMemLLMError as exc:
511
+ console.print(f"[red]LLM error:[/red] {exc}")
512
+ _print_events_table(events, title=f"Today ({date_str})")
513
+
514
+ db.close()
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # week
519
+ # ---------------------------------------------------------------------------
520
+
521
+
522
+ @cli.command()
523
+ @click.option("--raw", is_flag=True, help="Skip LLM, show per-day event list")
524
+ def week(raw: bool):
525
+ """Show what you worked on this week."""
526
+ config = load_config()
527
+ _ensure_db(config)
528
+
529
+ from recall.storage.db import DB
530
+ from recall.query.llm import is_available, ask as llm_ask, DevMemLLMError, configure
531
+ from recall.query.context import build_prompt_summary
532
+
533
+ configure(model=config.llm_model)
534
+ db = DB(config.db_path)
535
+
536
+ now = datetime.now(timezone.utc)
537
+ monday = now.replace(hour=0, minute=0, second=0, microsecond=0)
538
+ monday -= __import__("datetime").timedelta(days=now.weekday())
539
+ start_str = monday.strftime("%Y-%m-%dT%H:%M:%SZ")
540
+ end_str = now.strftime("%Y-%m-%dT%H:%M:%SZ")
541
+ events = db.get_events_by_date_range(start_str, end_str)
542
+
543
+ if not events:
544
+ console.print("[yellow]No events recorded this week yet.[/yellow]")
545
+ db.close()
546
+ return
547
+
548
+ if raw:
549
+ # Group by date
550
+ by_date: dict[str, list] = {}
551
+ for e in events:
552
+ by_date.setdefault(e.date, []).append(e)
553
+ for date, day_events in sorted(by_date.items()):
554
+ _print_events_table(day_events, title=date)
555
+ db.close()
556
+ return
557
+
558
+ if not is_available():
559
+ console.print("[dim]No LLM key — showing raw events.[/dim]")
560
+ _print_events_table(events, title="This Week")
561
+ db.close()
562
+ return
563
+
564
+ week_label = f"{monday.strftime('%b %-d')} – {now.strftime('%b %-d')}"
565
+ try:
566
+ with console.status("[dim]Generating weekly summary…[/dim]"):
567
+ messages = build_prompt_summary(week_label, events)
568
+ summary = llm_ask(messages)
569
+ console.rule(f"[bold]This Week — {week_label}[/bold]")
570
+ console.print(summary)
571
+ except DevMemLLMError as exc:
572
+ console.print(f"[red]LLM error:[/red] {exc}")
573
+ _print_events_table(events, title="This Week")
574
+
575
+ db.close()
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # timeline
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ @cli.command()
584
+ @click.option("--date", default=None, help="Date to show (YYYY-MM-DD, default: today)")
585
+ @click.option("--repo", default=None, help="Filter by repo name")
586
+ def timeline(date: Optional[str], repo: Optional[str]):
587
+ """Chronological activity timeline, grouped by session."""
588
+ config = load_config()
589
+ _ensure_db(config)
590
+
591
+ from recall.storage.db import DB
592
+
593
+ db = DB(config.db_path)
594
+ date_str = date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
595
+ events = db.get_events_by_date(date_str)
596
+
597
+ if repo:
598
+ events = [e for e in events if e.repo_name == repo]
599
+
600
+ if not events:
601
+ console.print(f"[yellow]No events for {date_str}.[/yellow]")
602
+ db.close()
603
+ return
604
+
605
+ console.rule(f"[bold]Timeline — {date_str}[/bold]")
606
+
607
+ # Group by session
608
+ sessions: dict[str, list] = {}
609
+ for e in events:
610
+ sid = e.session_id or "unsessioned"
611
+ sessions.setdefault(sid, []).append(e)
612
+
613
+ for sid, sess_events in sessions.items():
614
+ first_ts = sess_events[0].timestamp
615
+ last_ts = sess_events[-1].timestamp
616
+ repo_names = list({e.repo_name for e in sess_events if e.repo_name})
617
+ repo_label = ", ".join(repo_names) if repo_names else "unknown"
618
+ console.print(
619
+ f"\n[dim]Session {sid[:8]} · {_fmt_ts(first_ts)}–{_fmt_ts(last_ts)} · {repo_label}[/dim]"
620
+ )
621
+ for event in sess_events:
622
+ icon = _ICONS.get(event.event_type.value, "·")
623
+ style = _TYPE_STYLES.get(event.event_type.value, "white")
624
+ ts = _fmt_ts(event.timestamp)
625
+ console.print(f" [dim]{ts}[/dim] [{style}]{icon}[/{style}] {event.content}")
626
+
627
+ db.close()
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # repos
632
+ # ---------------------------------------------------------------------------
633
+
634
+
635
+ @cli.command()
636
+ @click.option("--sort", type=click.Choice(["activity", "name", "count"]), default="activity")
637
+ def repos(sort: str):
638
+ """List all tracked repositories."""
639
+ config = load_config()
640
+ _ensure_db(config)
641
+
642
+ from recall.storage.db import DB
643
+
644
+ db = DB(config.db_path)
645
+ all_repos = db.get_all_repos()
646
+ db.close()
647
+
648
+ if not all_repos:
649
+ console.print("[yellow]No repos tracked yet.[/yellow]")
650
+ return
651
+
652
+ if sort == "name":
653
+ all_repos.sort(key=lambda r: r["name"].lower())
654
+ elif sort == "count":
655
+ all_repos.sort(key=lambda r: r["event_count"], reverse=True)
656
+ # default "activity" is already sorted by last_active DESC from DB
657
+
658
+ table = Table(title="Tracked Repositories", box=box.ROUNDED)
659
+ table.add_column("Repo", style="bold")
660
+ table.add_column("Last Active")
661
+ table.add_column("Events", justify="right")
662
+ table.add_column("Path", style="dim")
663
+
664
+ for r in all_repos:
665
+ last_active = r.get("last_active") or r.get("first_seen", "?")
666
+ try:
667
+ dt = datetime.fromisoformat(last_active.replace("Z", "+00:00"))
668
+ last_str = dt.strftime("%Y-%m-%d %H:%M")
669
+ except (ValueError, AttributeError):
670
+ last_str = last_active
671
+ table.add_row(r["name"], last_str, str(r["event_count"]), r["path"])
672
+
673
+ console.print(table)
674
+
675
+
676
+ # ---------------------------------------------------------------------------
677
+ # search
678
+ # ---------------------------------------------------------------------------
679
+
680
+
681
+ @cli.command()
682
+ @click.argument("query")
683
+ @click.option("--type", "event_type", default=None, help="Filter by event type")
684
+ @click.option("--repo", default=None, help="Filter by repo name")
685
+ @click.option("--since", default=None, help="Start date (YYYY-MM-DD)")
686
+ @click.option("--until", default=None, help="End date (YYYY-MM-DD)")
687
+ @click.option("--top-k", default=20, help="Max results to return")
688
+ def search(query: str, event_type: Optional[str], repo: Optional[str],
689
+ since: Optional[str], until: Optional[str], top_k: int):
690
+ """Search activity history without LLM."""
691
+ config = load_config()
692
+ _ensure_db(config)
693
+
694
+ from recall.storage.db import DB
695
+ from recall.storage.vectors import VectorStore
696
+ from recall.processor.embedder import EmbedderQueue
697
+ from recall.query.retriever import Retriever
698
+
699
+ db = DB(config.db_path)
700
+ vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim)
701
+ embedder = EmbedderQueue(db=db, vectors=vectors, model_name=config.embedding_model)
702
+ retriever = Retriever(db=db, vectors=vectors, embedder=embedder)
703
+
704
+ date_range = None
705
+ if since or until:
706
+ start_dt = datetime.fromisoformat((since or "2000-01-01") + "T00:00:00+00:00")
707
+ end_dt = datetime.fromisoformat((until or "2099-12-31") + "T23:59:59+00:00")
708
+ date_range = (start_dt, end_dt)
709
+
710
+ etypes = None
711
+ if event_type:
712
+ try:
713
+ etypes = [EventType(event_type)]
714
+ except ValueError:
715
+ console.print(f"[red]Unknown event type: {event_type}[/red]")
716
+ console.print(f"Valid types: {', '.join(e.value for e in EventType)}")
717
+ db.close()
718
+ return
719
+
720
+ events = retriever.search(query, top_k=top_k, date_range=date_range,
721
+ event_types=etypes, repo_name=repo)
722
+
723
+ if not events:
724
+ console.print("[yellow]No results found.[/yellow]")
725
+ db.close()
726
+ return
727
+
728
+ _print_events_table(events, title=f"Search: {query}")
729
+ db.close()
730
+
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # stats
734
+ # ---------------------------------------------------------------------------
735
+
736
+
737
+ @cli.command()
738
+ def stats():
739
+ """Show capture statistics."""
740
+ config = load_config()
741
+ _ensure_db(config)
742
+
743
+ from recall.storage.db import DB
744
+ from recall.storage.vectors import VectorStore
745
+ from recall.daemon import is_running
746
+
747
+ db = DB(config.db_path)
748
+ vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim)
749
+
750
+ # Events by type
751
+ counts = db.get_event_counts_by_type()
752
+ type_table = Table(title="Events by Type", box=box.ROUNDED)
753
+ type_table.add_column("Type")
754
+ type_table.add_column("Count", justify="right")
755
+ for et in EventType:
756
+ type_table.add_row(et.value, str(counts.get(et.value, 0)))
757
+ console.print(type_table)
758
+
759
+ # Events per day (last 7)
760
+ per_day = db.get_events_per_day(7)
761
+ day_table = Table(title="Last 7 Days", box=box.ROUNDED)
762
+ day_table.add_column("Date")
763
+ day_table.add_column("Events", justify="right")
764
+ day_table.add_column("Sparkline")
765
+ max_count = max((r["cnt"] for r in per_day), default=1)
766
+ for row in reversed(per_day):
767
+ bar = "█" * int(row["cnt"] / max_count * 10)
768
+ day_table.add_row(row["date"], str(row["cnt"]), f"[cyan]{bar}[/cyan]")
769
+ console.print(day_table)
770
+
771
+ # Most active repos
772
+ all_repos = db.get_all_repos()[:10]
773
+ repo_table = Table(title="Most Active Repos", box=box.ROUNDED)
774
+ repo_table.add_column("Repo")
775
+ repo_table.add_column("Events", justify="right")
776
+ for r in sorted(all_repos, key=lambda x: x["event_count"], reverse=True)[:5]:
777
+ repo_table.add_row(r["name"], str(r["event_count"]))
778
+ console.print(repo_table)
779
+
780
+ # Misc stats
781
+ db_size = config.db_path.stat().st_size if config.db_path.exists() else 0
782
+ console.print(f"Total events: {db.get_event_count()}")
783
+ console.print(f"Vector index: {vectors.size()} vectors")
784
+ console.print(f"DB size: {_human_size(db_size)}")
785
+ console.print(f"Daemon: {'[green]running[/green]' if is_running(config) else '[red]stopped[/red]'}")
786
+
787
+ db.close()
788
+
789
+
790
+ # ---------------------------------------------------------------------------
791
+ # privacy
792
+ # ---------------------------------------------------------------------------
793
+
794
+
795
+ @cli.group()
796
+ def privacy():
797
+ """Manage privacy settings and delete captured data."""
798
+ pass
799
+
800
+
801
+ @privacy.command("list")
802
+ def privacy_list():
803
+ """Show what is being captured and counts per event type."""
804
+ config = load_config()
805
+ _ensure_db(config)
806
+
807
+ from recall.storage.db import DB
808
+
809
+ db = DB(config.db_path)
810
+ counts = db.get_event_counts_by_type()
811
+ db.close()
812
+
813
+ capture = config.capture
814
+ priv = config.privacy
815
+
816
+ console.rule("[bold]Capture Settings[/bold]")
817
+ for key, enabled in capture.items():
818
+ status = "[green]ON[/green]" if enabled else "[red]OFF[/red]"
819
+ count = counts.get(key, 0)
820
+ console.print(f" {key:<20} {status} ({count} events)")
821
+
822
+ console.print()
823
+ console.rule("[bold]Privacy Filters[/bold]")
824
+ console.print("Command ignore patterns:")
825
+ for p in priv.get("cmd_ignore_patterns", []):
826
+ console.print(f" [dim]•[/dim] {p}")
827
+ console.print("File ignore patterns:")
828
+ for p in priv.get("file_ignore_patterns", []):
829
+ console.print(f" [dim]•[/dim] {p}")
830
+
831
+
832
+ @privacy.command("delete")
833
+ @click.option("--before", default=None, help="Delete events before this date (YYYY-MM-DD)")
834
+ @click.option("--type", "event_type", default=None, help="Delete all events of this type")
835
+ @click.confirmation_option(prompt="This will permanently delete events. Continue?")
836
+ def privacy_delete(before: Optional[str], event_type: Optional[str]):
837
+ """Delete captured events."""
838
+ config = load_config()
839
+ _ensure_db(config)
840
+
841
+ from recall.storage.db import DB
842
+
843
+ db = DB(config.db_path)
844
+
845
+ if before:
846
+ n = db.delete_events_before(before)
847
+ console.print(f"[green]Deleted {n} events before {before}.[/green]")
848
+
849
+ if event_type:
850
+ # Direct SQL delete by type
851
+ with db._tx() as conn:
852
+ cur = conn.execute("DELETE FROM events WHERE event_type = ?", (event_type,))
853
+ n = cur.rowcount
854
+ console.print(f"[green]Deleted {n} events of type '{event_type}'.[/green]")
855
+
856
+ db.close()
857
+
858
+
859
+ @privacy.command("ignore")
860
+ @click.option("--cmd", default=None, help="Add a command pattern to ignore")
861
+ def privacy_ignore(cmd: Optional[str]):
862
+ """Add an ignore pattern to the privacy config."""
863
+ config = load_config()
864
+
865
+ if cmd:
866
+ patterns = config.privacy.get("cmd_ignore_patterns", [])
867
+ if cmd not in patterns:
868
+ patterns.append(cmd)
869
+ config._data["privacy"]["cmd_ignore_patterns"] = patterns
870
+ save_config(config)
871
+ console.print(f"[green]Added ignore pattern: {cmd}[/green]")
872
+ else:
873
+ console.print(f"[yellow]Pattern already exists: {cmd}[/yellow]")
874
+
875
+
876
+ # ---------------------------------------------------------------------------
877
+ # config
878
+ # ---------------------------------------------------------------------------
879
+
880
+
881
+ @cli.command("config")
882
+ @click.argument("key", required=False)
883
+ @click.argument("value", required=False)
884
+ def config_cmd(key: Optional[str], value: Optional[str]):
885
+ """Get or set configuration values.
886
+
887
+ \b
888
+ Examples:
889
+ devmem config # show all
890
+ devmem config daemon_port # show one value
891
+ devmem config daemon_port 8080 # set a value
892
+ """
893
+ config = load_config()
894
+
895
+ if key is None:
896
+ console.print_json(json.dumps(config.as_dict(), indent=2))
897
+ return
898
+
899
+ if value is None:
900
+ val = config.get(key)
901
+ if val is None:
902
+ console.print(f"[yellow]Key not found: {key}[/yellow]")
903
+ else:
904
+ console.print(f"{key} = {val!r}")
905
+ return
906
+
907
+ config.set(key, value)
908
+ save_config(config)
909
+ console.print(f"[green]{key} = {config.get(key)!r}[/green]")
910
+
911
+
912
+ # ---------------------------------------------------------------------------
913
+ # export
914
+ # ---------------------------------------------------------------------------
915
+
916
+
917
+ @cli.command()
918
+ @click.option("--from", "from_date", default=None, help="Start date YYYY-MM-DD")
919
+ @click.option("--to", "to_date", default=None, help="End date YYYY-MM-DD")
920
+ @click.option("--type", "event_type", default=None, help="Filter by event type")
921
+ @click.option("--format", "fmt", type=click.Choice(["json", "csv"]), default="json")
922
+ @click.option("--output", "-o", default=None, help="Output file (default: stdout)")
923
+ def export(from_date: Optional[str], to_date: Optional[str],
924
+ event_type: Optional[str], fmt: str, output: Optional[str]):
925
+ """Export events as JSON or CSV."""
926
+ config = load_config()
927
+ _ensure_db(config)
928
+
929
+ from recall.storage.db import DB
930
+
931
+ db = DB(config.db_path)
932
+ etypes = [EventType(event_type)] if event_type else None
933
+ date_range = None
934
+ if from_date or to_date:
935
+ date_range = (
936
+ (from_date or "2000-01-01") + "T00:00:00Z",
937
+ (to_date or "2099-12-31") + "T23:59:59Z",
938
+ )
939
+
940
+ events = db.get_events_by_filters(date_range=date_range, event_types=etypes, limit=100_000)
941
+ db.close()
942
+
943
+ if fmt == "json":
944
+ data = json.dumps([e.to_dict() for e in events], indent=2)
945
+ else:
946
+ import csv
947
+ import io
948
+
949
+ buf = io.StringIO()
950
+ writer = csv.DictWriter(
951
+ buf,
952
+ fieldnames=["id", "timestamp", "date", "event_type", "source",
953
+ "repo_name", "content"],
954
+ )
955
+ writer.writeheader()
956
+ for e in events:
957
+ writer.writerow({
958
+ "id": e.id,
959
+ "timestamp": e.timestamp,
960
+ "date": e.date,
961
+ "event_type": e.event_type.value,
962
+ "source": e.source.value,
963
+ "repo_name": e.repo_name or "",
964
+ "content": e.content,
965
+ })
966
+ data = buf.getvalue()
967
+
968
+ if output:
969
+ Path(output).write_text(data)
970
+ console.print(f"[green]Exported {len(events)} events to {output}[/green]")
971
+ else:
972
+ print(data)
973
+
974
+
975
+ # ---------------------------------------------------------------------------
976
+ # mcp-serve
977
+ # ---------------------------------------------------------------------------
978
+
979
+
980
+ @cli.command("mcp-serve")
981
+ def mcp_serve():
982
+ """Start the MCP server on stdio (for Claude Code / Copilot integration)."""
983
+ from recall.mcp_server import run_mcp_server
984
+
985
+ run_mcp_server()
986
+
987
+
988
+ # ---------------------------------------------------------------------------
989
+ # Helpers
990
+ # ---------------------------------------------------------------------------
991
+
992
+
993
+ def _ensure_db(config) -> None:
994
+ if not config.db_path.exists():
995
+ err_console.print(
996
+ "[red]Recall database not found. Run: recall init[/red]"
997
+ )
998
+ sys.exit(1)
999
+
1000
+
1001
+ def _print_events_table(events, title: str = "Results") -> None:
1002
+ table = Table(title=title, box=box.ROUNDED, show_lines=False)
1003
+ table.add_column("Time", style="dim", min_width=16)
1004
+ table.add_column("Type", min_width=10)
1005
+ table.add_column("Content")
1006
+ table.add_column("Repo", style="dim")
1007
+
1008
+ for event in events:
1009
+ icon = _ICONS.get(event.event_type.value, "·")
1010
+ style = _TYPE_STYLES.get(event.event_type.value, "white")
1011
+ ts = _fmt_ts(event.timestamp)
1012
+ type_cell = Text(f"{icon} {event.event_type.value}", style=style)
1013
+ table.add_row(ts, type_cell, event.content[:80], event.repo_name or "")
1014
+
1015
+ console.print(table)
1016
+
1017
+
1018
+ def _fmt_ts(ts_str: str) -> str:
1019
+ try:
1020
+ dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
1021
+ return dt.astimezone().strftime("%m-%d %H:%M")
1022
+ except ValueError:
1023
+ return ts_str[:16]
1024
+
1025
+
1026
+ def _human_size(size_bytes: int) -> str:
1027
+ size = float(size_bytes)
1028
+ for unit in ("B", "KB", "MB", "GB"):
1029
+ if size < 1024:
1030
+ return f"{size:.1f} {unit}"
1031
+ size /= 1024
1032
+ return f"{size:.1f} TB"