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/daemon.py ADDED
@@ -0,0 +1,466 @@
1
+ """Recall background daemon — orchestrates all collectors and background threads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import signal
8
+ import sys
9
+ import threading
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from recall.config import Config
16
+ from recall.models import Event
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _SYSTEMD_UNIT_TEMPLATE = """\
21
+ [Unit]
22
+ Description=Recall developer activity daemon
23
+ After=default.target
24
+
25
+ [Service]
26
+ Type=simple
27
+ ExecStart={recall_bin} daemon start --foreground
28
+ Restart=on-failure
29
+ RestartSec=5
30
+
31
+ [Install]
32
+ WantedBy=default.target
33
+ """
34
+
35
+ _DAILY_SUMMARIZER_HOUR = 23
36
+ _DAILY_SUMMARIZER_MINUTE = 30
37
+
38
+
39
+ class Daemon:
40
+ """
41
+ The main daemon process.
42
+
43
+ Responsibilities:
44
+ - Runs Flask HTTP server for VS Code extension events
45
+ - Runs watchdog observers for shell.tsv and git.tsv
46
+ - Runs AI chat log watcher
47
+ - Runs background threads: embedder, git poller, daily summarizer,
48
+ retention cleaner
49
+ """
50
+
51
+ def __init__(self, config: Config) -> None:
52
+ self._config = config
53
+ self._stop_event = threading.Event()
54
+
55
+ # Lazy imports to keep startup fast
56
+ from recall.storage.db import DB
57
+ from recall.storage.vectors import VectorStore
58
+ from recall.processor.embedder import EmbedderQueue
59
+ from recall.processor.enricher import Enricher
60
+ from recall.processor.session import SessionDetector
61
+
62
+ self._db = DB(config.db_path)
63
+ self._vectors = VectorStore.from_file(config.faiss_path, dim=config.embedding_dim)
64
+
65
+ # Embedder
66
+ self._embedder = EmbedderQueue(
67
+ db=self._db,
68
+ vectors=self._vectors,
69
+ model_name=config.embedding_model,
70
+ faiss_save_path=str(config.faiss_path),
71
+ get_next_embedding_id=lambda: int(self._db.get_kv("next_embedding_id") or 1),
72
+ set_next_embedding_id=lambda v: self._db.set_kv("next_embedding_id", str(v)),
73
+ )
74
+
75
+ # Enricher
76
+ privacy = config.privacy
77
+ self._enricher = Enricher(
78
+ cmd_ignore_patterns=privacy.get("cmd_ignore_patterns", []),
79
+ file_ignore_patterns=privacy.get("file_ignore_patterns", []),
80
+ repo_ignore_patterns=privacy.get("repo_ignore_patterns", []),
81
+ upsert_repo=self._db.upsert_repo,
82
+ )
83
+
84
+ # Session detector
85
+ self._session_detector = SessionDetector(
86
+ session_idle_minutes=config.search.get("session_idle_minutes", 30),
87
+ get_latest_session=self._db.get_latest_session,
88
+ upsert_session=self._db.upsert_session,
89
+ )
90
+
91
+ # Collectors (initialised in start())
92
+ self._shell_collector = None
93
+ self._git_collector = None
94
+ self._ai_collector = None
95
+ self._flask_thread = None
96
+ self._window_collector = None
97
+ self._session_event_collector = None
98
+ self._container_collector = None
99
+ self._process_collector = None
100
+
101
+ # ------------------------------------------------------------------
102
+ # Event pipeline
103
+ # ------------------------------------------------------------------
104
+
105
+ def _handle_raw_event(self, event: Event) -> None:
106
+ """
107
+ Called by all collectors for each raw event.
108
+ Runs enrichment → session → DB insert → embedder queue.
109
+ """
110
+ enriched = self._enricher.enrich(event)
111
+ if enriched is None:
112
+ return # dropped by privacy filter
113
+
114
+ enriched = self._session_detector.assign(enriched)
115
+
116
+ try:
117
+ event_id = self._db.insert_event(enriched)
118
+ except Exception:
119
+ logger.exception("DB insert_event failed")
120
+ return
121
+
122
+ enriched.id = event_id
123
+ self._embedder.enqueue([event_id])
124
+
125
+ # ------------------------------------------------------------------
126
+ # Start / stop
127
+ # ------------------------------------------------------------------
128
+
129
+ def start(self, foreground: bool = False) -> None:
130
+ """Start the daemon (blocks if foreground=True)."""
131
+ logger.info("Recall daemon starting (PID %d)", os.getpid())
132
+
133
+ if not foreground:
134
+ self._write_pid()
135
+
136
+ # Start embedder worker
137
+ self._embedder.start()
138
+
139
+ # Embed anything that wasn't embedded before (crash recovery)
140
+ threading.Thread(
141
+ target=self._embedder.embed_pending,
142
+ daemon=True,
143
+ name="dev-recall-embed-pending",
144
+ ).start()
145
+
146
+ # Shell collector
147
+ from recall.collectors.shell import ShellCollector
148
+
149
+ self._shell_collector = ShellCollector(
150
+ shell_tsv=self._config.shell_tsv_path,
151
+ cmd_ignore_patterns=self._config.privacy.get("cmd_ignore_patterns", []),
152
+ event_callback=self._handle_raw_event,
153
+ get_offset=lambda: self._db.get_kv("shell_tsv_offset"),
154
+ set_offset=lambda v: self._db.set_kv("shell_tsv_offset", v),
155
+ )
156
+ self._shell_collector.start()
157
+
158
+ # Git collector
159
+ from recall.collectors.git import GitCollector
160
+
161
+ self._git_collector = GitCollector(
162
+ git_tsv=self._config.git_tsv_path,
163
+ event_callback=self._handle_raw_event,
164
+ get_kv=self._db.get_kv,
165
+ set_kv=self._db.set_kv,
166
+ repo_ignore_patterns=self._config.privacy.get("repo_ignore_patterns", []),
167
+ )
168
+ self._git_collector.start()
169
+
170
+ # AI chat collector
171
+ if self._config.capture.get("ai_chat", True):
172
+ from recall.collectors.ai_chat import AIChatCollector
173
+
174
+ self._ai_collector = AIChatCollector(
175
+ event_callback=self._handle_raw_event,
176
+ get_kv=self._db.get_kv,
177
+ set_kv=self._db.set_kv,
178
+ ai_chat_max_chars=self._config.privacy.get("ai_chat_max_chars", 200),
179
+ )
180
+ self._ai_collector.start()
181
+
182
+ # Flask HTTP server for VS Code extension
183
+ if self._config.capture.get("vscode", True):
184
+ self._flask_thread = threading.Thread(
185
+ target=self._run_flask,
186
+ daemon=True,
187
+ name="dev-recall-flask",
188
+ )
189
+ self._flask_thread.start()
190
+
191
+ # Window tracking (Linux X11/XWayland via libwnck, optional)
192
+ if self._config.capture.get("window_tracking", True):
193
+ from recall.collectors.linux_window import LinuxWindowCollector
194
+
195
+ self._window_collector = LinuxWindowCollector(event_callback=self._handle_raw_event)
196
+ self._window_collector.start()
197
+
198
+ # Session events (Linux D-Bus logind, optional)
199
+ if self._config.capture.get("session_events", True):
200
+ from recall.collectors.linux_session import LinuxSessionCollector
201
+
202
+ self._session_event_collector = LinuxSessionCollector(
203
+ event_callback=self._handle_raw_event
204
+ )
205
+ self._session_event_collector.start()
206
+
207
+ # Container events (Docker/Podman, optional)
208
+ if self._config.capture.get("container_events", True):
209
+ from recall.collectors.containers import ContainerCollector
210
+
211
+ self._container_collector = ContainerCollector(event_callback=self._handle_raw_event)
212
+ self._container_collector.start()
213
+
214
+ # Process/port tracking (psutil, optional)
215
+ if self._config.capture.get("process_tracking", True):
216
+ from recall.collectors.linux_process import ProcessCollector
217
+
218
+ self._process_collector = ProcessCollector(event_callback=self._handle_raw_event)
219
+ self._process_collector.start()
220
+
221
+ # Daily summarizer
222
+ threading.Thread(
223
+ target=self._daily_summarizer_loop,
224
+ daemon=True,
225
+ name="devmem-summarizer",
226
+ ).start()
227
+
228
+ # Retention cleaner
229
+ threading.Thread(
230
+ target=self._retention_cleaner_loop,
231
+ daemon=True,
232
+ name="devmem-retention",
233
+ ).start()
234
+
235
+ # Signal handlers for graceful shutdown
236
+ signal.signal(signal.SIGTERM, self._on_signal)
237
+ signal.signal(signal.SIGINT, self._on_signal)
238
+
239
+ logger.info("Recall daemon running")
240
+
241
+ if foreground:
242
+ self._stop_event.wait()
243
+ self._shutdown()
244
+
245
+ def stop(self) -> None:
246
+ self._stop_event.set()
247
+
248
+ def _shutdown(self) -> None:
249
+ logger.info("Recall daemon shutting down…")
250
+ if self._shell_collector:
251
+ self._shell_collector.stop()
252
+ if self._git_collector:
253
+ self._git_collector.stop()
254
+ if self._ai_collector:
255
+ self._ai_collector.stop()
256
+ if self._window_collector:
257
+ self._window_collector.stop()
258
+ if self._session_event_collector:
259
+ self._session_event_collector.stop()
260
+ if self._container_collector:
261
+ self._container_collector.stop()
262
+ if self._process_collector:
263
+ self._process_collector.stop()
264
+ self._embedder.stop()
265
+ # Final FAISS save
266
+ try:
267
+ self._vectors.save(self._config.faiss_path)
268
+ except Exception:
269
+ logger.exception("Final FAISS save failed")
270
+ self._db.close()
271
+ self._remove_pid()
272
+ logger.info("Recall daemon stopped")
273
+
274
+ def _on_signal(self, signum, frame) -> None:
275
+ logger.info("Received signal %d — stopping", signum)
276
+ self._stop_event.set()
277
+
278
+ # ------------------------------------------------------------------
279
+ # Flask HTTP server
280
+ # ------------------------------------------------------------------
281
+
282
+ def _run_flask(self) -> None:
283
+ from flask import Flask, request, jsonify
284
+ from recall.collectors.vscode import parse_vscode_event
285
+
286
+ app = Flask(__name__)
287
+
288
+ @app.route("/event", methods=["POST"])
289
+ def receive_event():
290
+ data = request.get_json(silent=True) or {}
291
+ parse_vscode_event(data, self._handle_raw_event)
292
+ return jsonify({"ok": True}), 200
293
+
294
+ @app.route("/status", methods=["GET"])
295
+ def status():
296
+ return jsonify({
297
+ "status": "running",
298
+ "pid": os.getpid(),
299
+ "events": self._db.get_event_count(),
300
+ "vectors": self._vectors.size(),
301
+ }), 200
302
+
303
+ import logging as _log
304
+ _log.getLogger("werkzeug").setLevel(logging.WARNING)
305
+
306
+ app.run(
307
+ host="127.0.0.1",
308
+ port=self._config.daemon_port,
309
+ threaded=True,
310
+ use_reloader=False,
311
+ )
312
+
313
+ # ------------------------------------------------------------------
314
+ # Daily summarizer
315
+ # ------------------------------------------------------------------
316
+
317
+ def _daily_summarizer_loop(self) -> None:
318
+ while not self._stop_event.is_set():
319
+ self._stop_event.wait(60) # check every minute
320
+ if self._stop_event.is_set():
321
+ break
322
+ now = datetime.now(timezone.utc)
323
+ if now.hour == _DAILY_SUMMARIZER_HOUR and now.minute == _DAILY_SUMMARIZER_MINUTE:
324
+ if self._config.summary.get("auto_generate", True):
325
+ self._generate_daily_summary(now.strftime("%Y-%m-%d"))
326
+ # Sleep 2 min so we don't fire twice in the same minute
327
+ self._stop_event.wait(120)
328
+
329
+ def _generate_daily_summary(self, date: str) -> None:
330
+ existing = self._db.get_daily_summary(date)
331
+ if existing:
332
+ return # already generated
333
+
334
+ events = self._db.get_events_by_date(date)
335
+ if not events:
336
+ return
337
+
338
+ from recall.query.context import build_prompt_summary
339
+ from recall.query.llm import ask as llm_ask, is_available
340
+
341
+ if not is_available():
342
+ logger.debug("No LLM key — skipping daily summary for %s", date)
343
+ return
344
+
345
+ try:
346
+ messages = build_prompt_summary(date, events)
347
+ summary = llm_ask(messages)
348
+ repos = list({e.repo_name for e in events if e.repo_name})
349
+ highlights = [e.content for e in events if "commit" in e.event_type.value][:10]
350
+ self._db.upsert_daily_summary(date, summary, repos, highlights, len(events))
351
+ logger.info("Generated daily summary for %s", date)
352
+ except Exception:
353
+ logger.exception("Daily summary generation failed for %s", date)
354
+
355
+ # ------------------------------------------------------------------
356
+ # Retention cleaner
357
+ # ------------------------------------------------------------------
358
+
359
+ def _retention_cleaner_loop(self) -> None:
360
+ """Run once per day to delete events older than retention_days."""
361
+ # Run once on startup, then every 24 hours
362
+ self._run_retention_cleanup()
363
+ while not self._stop_event.is_set():
364
+ self._stop_event.wait(3600 * 24)
365
+ if self._stop_event.is_set():
366
+ break
367
+ self._run_retention_cleanup()
368
+
369
+ def _run_retention_cleanup(self) -> None:
370
+ from datetime import timedelta
371
+
372
+ retention_days = self._config.retention_days
373
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).strftime("%Y-%m-%d")
374
+ try:
375
+ n = self._db.delete_events_before(cutoff)
376
+ if n:
377
+ logger.info("Retention cleaner deleted %d events before %s", n, cutoff)
378
+ except Exception:
379
+ logger.exception("Retention cleaner failed")
380
+
381
+ # ------------------------------------------------------------------
382
+ # PID file
383
+ # ------------------------------------------------------------------
384
+
385
+ def _write_pid(self) -> None:
386
+ try:
387
+ self._config.pid_path.parent.mkdir(parents=True, exist_ok=True)
388
+ self._config.pid_path.write_text(str(os.getpid()))
389
+ except OSError as exc:
390
+ logger.warning("Could not write PID file: %s", exc)
391
+
392
+ def _remove_pid(self) -> None:
393
+ try:
394
+ self._config.pid_path.unlink(missing_ok=True)
395
+ except OSError:
396
+ pass
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Process management helpers (used by CLI)
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ def read_pid(config: Config) -> Optional[int]:
405
+ """Return the daemon PID from the PID file, or None."""
406
+ try:
407
+ pid_str = config.pid_path.read_text().strip()
408
+ return int(pid_str)
409
+ except (OSError, ValueError):
410
+ return None
411
+
412
+
413
+ def is_running(config: Config) -> bool:
414
+ """Return True if the daemon process is alive."""
415
+ pid = read_pid(config)
416
+ if pid is None:
417
+ return False
418
+ try:
419
+ os.kill(pid, 0) # signal 0: just check existence
420
+ return True
421
+ except (ProcessLookupError, PermissionError):
422
+ return False
423
+
424
+
425
+ def stop_daemon(config: Config) -> bool:
426
+ """Send SIGTERM to the daemon. Returns True if the signal was sent."""
427
+ pid = read_pid(config)
428
+ if pid is None:
429
+ return False
430
+ try:
431
+ os.kill(pid, signal.SIGTERM)
432
+ return True
433
+ except (ProcessLookupError, PermissionError):
434
+ return False
435
+
436
+
437
+ def write_systemd_unit(config: Config) -> Path:
438
+ """Write the systemd user unit file and return its path."""
439
+ import shutil
440
+
441
+ recall_bin = shutil.which("recall") or sys.executable + " -m devmem"
442
+ unit_content = _SYSTEMD_UNIT_TEMPLATE.format(recall_bin=recall_bin)
443
+
444
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
445
+ unit_dir.mkdir(parents=True, exist_ok=True)
446
+ unit_path = unit_dir / "dev-recall.service"
447
+ unit_path.write_text(unit_content)
448
+ return unit_path
449
+
450
+
451
+ def start_daemon_background(config: Config) -> int:
452
+ """Fork the daemon to the background and return its PID."""
453
+ import subprocess
454
+
455
+ log_fh = open(str(config.log_path), "a") # noqa: SIM115
456
+ proc = subprocess.Popen(
457
+ [sys.executable, "-m", "recall.daemon_main"],
458
+ start_new_session=True,
459
+ stdout=log_fh,
460
+ stderr=subprocess.STDOUT,
461
+ env={**os.environ, "DEVMEM_FOREGROUND": "1"},
462
+ )
463
+ log_fh.close() # child inherits the fd; parent can close
464
+ # Wait briefly to ensure the process started
465
+ time.sleep(0.5)
466
+ return proc.pid
recall/daemon_main.py ADDED
@@ -0,0 +1,25 @@
1
+ """Entry point for running the daemon as a module: python -m recall.daemon_main"""
2
+
3
+ import logging
4
+ import os
5
+ from recall.config import load_config
6
+ from recall.daemon import Daemon
7
+
8
+ logging.basicConfig(
9
+ level=logging.INFO,
10
+ format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
11
+ datefmt="%Y-%m-%dT%H:%M:%S",
12
+ )
13
+
14
+ if __name__ == "__main__":
15
+ config = load_config()
16
+ config.data_dir.mkdir(parents=True, exist_ok=True)
17
+ config.config_dir.mkdir(parents=True, exist_ok=True)
18
+ # Write PID file so `dev-recall daemon status/stop` can find this process.
19
+ # start(foreground=True) skips _write_pid(), so we do it here.
20
+ try:
21
+ config.pid_path.write_text(str(os.getpid()))
22
+ except OSError as exc:
23
+ logging.warning("Could not write PID file: %s", exc)
24
+ daemon = Daemon(config)
25
+ daemon.start(foreground=True)