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