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.
- dev_recall-0.2.0.dist-info/METADATA +281 -0
- dev_recall-0.2.0.dist-info/RECORD +34 -0
- dev_recall-0.2.0.dist-info/WHEEL +5 -0
- dev_recall-0.2.0.dist-info/entry_points.txt +2 -0
- dev_recall-0.2.0.dist-info/top_level.txt +1 -0
- recall/__init__.py +3 -0
- recall/_hooks.py +211 -0
- recall/cli.py +1032 -0
- recall/collectors/__init__.py +1 -0
- recall/collectors/ai_chat.py +644 -0
- recall/collectors/containers.py +164 -0
- recall/collectors/git.py +540 -0
- recall/collectors/linux_process.py +230 -0
- recall/collectors/linux_session.py +229 -0
- recall/collectors/linux_window.py +199 -0
- recall/collectors/shell.py +300 -0
- recall/collectors/vscode.py +175 -0
- recall/config.py +257 -0
- recall/daemon.py +466 -0
- recall/daemon_main.py +25 -0
- recall/mcp_server.py +290 -0
- recall/models.py +225 -0
- recall/processor/__init__.py +1 -0
- recall/processor/embedder.py +213 -0
- recall/processor/enricher.py +213 -0
- recall/processor/session.py +142 -0
- recall/query/__init__.py +1 -0
- recall/query/context.py +130 -0
- recall/query/llm.py +85 -0
- recall/query/retriever.py +147 -0
- recall/query/timeparser.py +188 -0
- recall/storage/__init__.py +1 -0
- recall/storage/db.py +528 -0
- recall/storage/vectors.py +166 -0
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"
|