runtime-memory 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,852 @@
1
+ """Daemon for automatic Claude Code session monitoring.
2
+
3
+ .. deprecated:: 2.0.0
4
+ The daemon-based architecture is deprecated in favor of the native
5
+ Claude Code 2.1.1+ plugin system. Use native hooks (hooks/hooks.json)
6
+ and Agent Skills (skills/*.md) instead.
7
+
8
+ Migration guide:
9
+ - SessionStart hook: Use hooks/hooks.json SessionStart
10
+ - SessionEnd hook: Use hooks/hooks.json SessionEnd
11
+ - PreCompact hook: Use hooks/hooks.json PreCompact
12
+ - Auto-extraction: Use PreCompact hook with 'mem extract --auto'
13
+ - CLAUDE.md updates: Use 'mem context --inject' in SessionStart hook
14
+
15
+ Watches Claude Code session files and automatically:
16
+ - Extracts learnings from completed sessions
17
+ - Updates CLAUDE.md with relevant context
18
+ - Triggers hooks at appropriate lifecycle points
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import warnings
24
+
25
+ # Emit deprecation warning when module is imported
26
+ warnings.warn(
27
+ "The daemon module is deprecated as of v2.0.0. "
28
+ "Use the native plugin system with hooks/hooks.json instead. "
29
+ "See the repository hooks/hooks.json for the v2 hook configuration.",
30
+ DeprecationWarning,
31
+ stacklevel=2,
32
+ )
33
+
34
+ import asyncio
35
+ import json
36
+ import os
37
+ import signal
38
+ import sys
39
+ import tempfile
40
+ from dataclasses import dataclass, field
41
+ from datetime import UTC, datetime
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING, Any, Callable
44
+
45
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
46
+ from watchdog.observers import Observer
47
+
48
+ from runtime_memory.core.logging import get_logger
49
+
50
+ if TYPE_CHECKING:
51
+ from runtime_memory.core.engine import MemoryEngine
52
+ from runtime_memory.extraction.extractor import MemoryExtractor
53
+
54
+ logger = get_logger(__name__)
55
+
56
+ # Default paths
57
+ DEFAULT_CLAUDE_DIR = Path.home() / ".claude"
58
+ DEFAULT_SESSION_DIR = DEFAULT_CLAUDE_DIR / "session-memory"
59
+ DEFAULT_PID_FILE = Path(tempfile.gettempdir()) / "runtime-memory-daemon.pid"
60
+
61
+
62
+ @dataclass
63
+ class DaemonConfig:
64
+ """Configuration for the daemon."""
65
+
66
+ # Watch directories
67
+ claude_dir: Path = field(default_factory=lambda: DEFAULT_CLAUDE_DIR)
68
+ """Claude Code configuration directory."""
69
+
70
+ session_dir: Path = field(default_factory=lambda: DEFAULT_SESSION_DIR)
71
+ """Directory containing session files."""
72
+
73
+ # PID file
74
+ pid_file: Path = field(default_factory=lambda: DEFAULT_PID_FILE)
75
+ """Path to PID file for daemon management."""
76
+
77
+ # Processing settings
78
+ process_on_modify: bool = True
79
+ """Process sessions on file modification."""
80
+
81
+ process_on_create: bool = False
82
+ """Process sessions on file creation."""
83
+
84
+ debounce_seconds: float = 2.0
85
+ """Debounce time to avoid duplicate processing."""
86
+
87
+ # Session file patterns
88
+ session_patterns: list[str] = field(default_factory=lambda: ["*.json", "*.jsonl"])
89
+ """File patterns to watch for session data."""
90
+
91
+ # Auto-extraction
92
+ auto_extract: bool = True
93
+ """Automatically extract memories from sessions."""
94
+
95
+ # CLAUDE.md management
96
+ update_claude_md: bool = True
97
+ """Automatically update CLAUDE.md with context."""
98
+
99
+ claude_md_path: Path | None = None
100
+ """Path to CLAUDE.md (default: project root)."""
101
+
102
+ # Privilege settings
103
+ drop_privileges: bool = False
104
+ """Drop to unprivileged user after binding."""
105
+
106
+ target_uid: int | None = None
107
+ """UID to drop to (if drop_privileges is True)."""
108
+
109
+ target_gid: int | None = None
110
+ """GID to drop to (if drop_privileges is True)."""
111
+
112
+ def __post_init__(self) -> None:
113
+ """Ensure paths are Path objects."""
114
+ if isinstance(self.claude_dir, str):
115
+ self.claude_dir = Path(self.claude_dir)
116
+ if isinstance(self.session_dir, str):
117
+ self.session_dir = Path(self.session_dir)
118
+ if isinstance(self.pid_file, str):
119
+ self.pid_file = Path(self.pid_file)
120
+
121
+
122
+ @dataclass
123
+ class SessionInfo:
124
+ """Information about a Claude Code session."""
125
+
126
+ session_id: str
127
+ """Unique session identifier."""
128
+
129
+ file_path: Path
130
+ """Path to the session file."""
131
+
132
+ project_path: Path | None
133
+ """Path to the project directory."""
134
+
135
+ started_at: datetime | None
136
+ """When the session started."""
137
+
138
+ ended_at: datetime | None
139
+ """When the session ended."""
140
+
141
+ is_active: bool
142
+ """Whether the session is currently active."""
143
+
144
+ transcript: str = ""
145
+ """Session transcript content."""
146
+
147
+ metadata: dict[str, Any] = field(default_factory=dict)
148
+ """Additional session metadata."""
149
+
150
+
151
+ class DaemonError(Exception):
152
+ """Base exception for daemon errors."""
153
+
154
+ pass
155
+
156
+
157
+ class DaemonAlreadyRunningError(DaemonError):
158
+ """Raised when daemon is already running."""
159
+
160
+ pass
161
+
162
+
163
+ class SessionHandler(FileSystemEventHandler):
164
+ """Handles file system events for Claude Code sessions."""
165
+
166
+ def __init__(
167
+ self,
168
+ daemon: MemoryLayerDaemon,
169
+ config: DaemonConfig,
170
+ ) -> None:
171
+ """Initialize the session handler.
172
+
173
+ Args:
174
+ daemon: Parent daemon instance.
175
+ config: Daemon configuration.
176
+ """
177
+ super().__init__()
178
+ self.daemon = daemon
179
+ self.config = config
180
+ self._last_processed: dict[str, float] = {}
181
+ self._processing_lock = asyncio.Lock()
182
+
183
+ def _should_process(self, path: str) -> bool:
184
+ """Check if a path should be processed.
185
+
186
+ Args:
187
+ path: File path to check.
188
+
189
+ Returns:
190
+ True if file should be processed.
191
+ """
192
+ path_obj = Path(path)
193
+
194
+ # Check if file matches patterns
195
+ matched = any(
196
+ path_obj.match(pattern)
197
+ for pattern in self.config.session_patterns
198
+ )
199
+ if not matched:
200
+ return False
201
+
202
+ # Debounce
203
+ now = datetime.now(UTC).timestamp()
204
+ last = self._last_processed.get(path, 0)
205
+ if now - last < self.config.debounce_seconds:
206
+ return False
207
+
208
+ self._last_processed[path] = now
209
+ return True
210
+
211
+ def on_modified(self, event: FileSystemEvent) -> None:
212
+ """Handle file modification events.
213
+
214
+ Args:
215
+ event: The file system event.
216
+ """
217
+ if event.is_directory:
218
+ return
219
+
220
+ if not self.config.process_on_modify:
221
+ return
222
+
223
+ if self._should_process(event.src_path):
224
+ logger.debug(f"Session file modified: {event.src_path}")
225
+ asyncio.run(self.daemon._process_session_file(Path(event.src_path)))
226
+
227
+ def on_created(self, event: FileSystemEvent) -> None:
228
+ """Handle file creation events.
229
+
230
+ Args:
231
+ event: The file system event.
232
+ """
233
+ if event.is_directory:
234
+ return
235
+
236
+ if not self.config.process_on_create:
237
+ return
238
+
239
+ if self._should_process(event.src_path):
240
+ logger.debug(f"Session file created: {event.src_path}")
241
+ asyncio.run(self.daemon._process_session_file(Path(event.src_path)))
242
+
243
+
244
+ class MemoryLayerDaemon:
245
+ """Daemon for automatic memory extraction from Claude Code sessions."""
246
+
247
+ def __init__(
248
+ self,
249
+ config: DaemonConfig | None = None,
250
+ engine: MemoryEngine | None = None,
251
+ extractor: MemoryExtractor | None = None,
252
+ ) -> None:
253
+ """Initialize the daemon.
254
+
255
+ Args:
256
+ config: Daemon configuration.
257
+ engine: Memory engine instance.
258
+ extractor: Memory extractor instance.
259
+ """
260
+ self.config = config or DaemonConfig()
261
+ self._engine = engine
262
+ self._extractor = extractor
263
+ self._observer: Observer | None = None
264
+ self._running = False
265
+ self._shutdown_event = asyncio.Event()
266
+
267
+ # Callbacks
268
+ self._on_session_start: list[Callable[[SessionInfo], None]] = []
269
+ self._on_session_end: list[Callable[[SessionInfo], None]] = []
270
+ self._on_extraction: list[Callable[[SessionInfo, int], None]] = []
271
+
272
+ @property
273
+ def is_running(self) -> bool:
274
+ """Check if daemon is running."""
275
+ return self._running
276
+
277
+ # =========================================================================
278
+ # PID File Management
279
+ # =========================================================================
280
+
281
+ def _write_pid_file(self) -> None:
282
+ """Write current process PID to file."""
283
+ pid = os.getpid()
284
+ self.config.pid_file.parent.mkdir(parents=True, exist_ok=True)
285
+ self.config.pid_file.write_text(str(pid))
286
+ logger.debug(f"Wrote PID {pid} to {self.config.pid_file}")
287
+
288
+ def _remove_pid_file(self) -> None:
289
+ """Remove PID file."""
290
+ if self.config.pid_file.exists():
291
+ self.config.pid_file.unlink()
292
+ logger.debug(f"Removed PID file {self.config.pid_file}")
293
+
294
+ def _check_existing_daemon(self) -> int | None:
295
+ """Check if another daemon is running.
296
+
297
+ Returns:
298
+ PID of existing daemon, or None if not running.
299
+ """
300
+ if not self.config.pid_file.exists():
301
+ return None
302
+
303
+ try:
304
+ pid = int(self.config.pid_file.read_text().strip())
305
+ # Check if process is running
306
+ os.kill(pid, 0)
307
+ return pid
308
+ except (ValueError, ProcessLookupError, PermissionError):
309
+ # Invalid PID or process not running
310
+ self._remove_pid_file()
311
+ return None
312
+
313
+ # =========================================================================
314
+ # Privilege Management
315
+ # =========================================================================
316
+
317
+ def _drop_privileges(self) -> None:
318
+ """Drop to unprivileged user/group."""
319
+ if not self.config.drop_privileges:
320
+ return
321
+
322
+ if os.name != "posix":
323
+ logger.warning("Privilege dropping only supported on POSIX systems")
324
+ return
325
+
326
+ try:
327
+ if self.config.target_gid is not None:
328
+ os.setgid(self.config.target_gid)
329
+ logger.info(f"Dropped to GID {self.config.target_gid}")
330
+
331
+ if self.config.target_uid is not None:
332
+ os.setuid(self.config.target_uid)
333
+ logger.info(f"Dropped to UID {self.config.target_uid}")
334
+ except PermissionError as e:
335
+ logger.error(f"Failed to drop privileges: {e}")
336
+ raise DaemonError(f"Cannot drop privileges: {e}") from e
337
+
338
+ # =========================================================================
339
+ # Session Processing
340
+ # =========================================================================
341
+
342
+ async def _process_session_file(self, file_path: Path) -> None:
343
+ """Process a session file.
344
+
345
+ Args:
346
+ file_path: Path to the session file.
347
+ """
348
+ try:
349
+ session = self._parse_session_file(file_path)
350
+ if session is None:
351
+ return
352
+
353
+ logger.info(f"Processing session: {session.session_id}")
354
+
355
+ # Check if session ended
356
+ if not session.is_active and session.transcript:
357
+ # Extract memories if enabled
358
+ if self.config.auto_extract and self._extractor:
359
+ await self._extract_from_session(session)
360
+
361
+ # Notify callbacks
362
+ for callback in self._on_session_end:
363
+ try:
364
+ callback(session)
365
+ except Exception as e:
366
+ logger.error(f"Session end callback failed: {e}")
367
+
368
+ except Exception as e:
369
+ logger.error(f"Failed to process session file {file_path}: {e}")
370
+
371
+ def _parse_session_file(self, file_path: Path) -> SessionInfo | None:
372
+ """Parse a session file.
373
+
374
+ Args:
375
+ file_path: Path to the session file.
376
+
377
+ Returns:
378
+ SessionInfo or None if parsing fails.
379
+ """
380
+ try:
381
+ content = file_path.read_text()
382
+
383
+ # Try JSON format
384
+ if file_path.suffix == ".json":
385
+ data = json.loads(content)
386
+ return self._parse_json_session(file_path, data)
387
+
388
+ # Try JSONL format (multiple JSON objects per line)
389
+ if file_path.suffix == ".jsonl":
390
+ lines = [json.loads(line) for line in content.strip().split("\n") if line.strip()]
391
+ return self._parse_jsonl_session(file_path, lines)
392
+
393
+ # Plain text transcript
394
+ return SessionInfo(
395
+ session_id=file_path.stem,
396
+ file_path=file_path,
397
+ project_path=self._detect_project_path(file_path),
398
+ started_at=None,
399
+ ended_at=None,
400
+ is_active=False,
401
+ transcript=content,
402
+ )
403
+
404
+ except (json.JSONDecodeError, OSError) as e:
405
+ logger.warning(f"Failed to parse session file {file_path}: {e}")
406
+ return None
407
+
408
+ def _parse_json_session(self, file_path: Path, data: dict[str, Any]) -> SessionInfo:
409
+ """Parse JSON format session data.
410
+
411
+ Args:
412
+ file_path: Path to the session file.
413
+ data: Parsed JSON data.
414
+
415
+ Returns:
416
+ SessionInfo instance.
417
+ """
418
+ # Extract session ID
419
+ session_id = data.get("session_id", data.get("id", file_path.stem))
420
+
421
+ # Extract timestamps
422
+ started_at = None
423
+ ended_at = None
424
+ if "started_at" in data:
425
+ started_at = datetime.fromisoformat(data["started_at"])
426
+ if "ended_at" in data:
427
+ ended_at = datetime.fromisoformat(data["ended_at"])
428
+
429
+ # Extract transcript
430
+ transcript = ""
431
+ if "transcript" in data:
432
+ transcript = data["transcript"]
433
+ elif "messages" in data:
434
+ # Build transcript from messages
435
+ messages = data["messages"]
436
+ parts = []
437
+ for msg in messages:
438
+ role = msg.get("role", "unknown").capitalize()
439
+ content = msg.get("content", "")
440
+ parts.append(f"{role}: {content}")
441
+ transcript = "\n\n".join(parts)
442
+
443
+ return SessionInfo(
444
+ session_id=str(session_id),
445
+ file_path=file_path,
446
+ project_path=self._detect_project_path(file_path),
447
+ started_at=started_at,
448
+ ended_at=ended_at,
449
+ is_active=data.get("is_active", ended_at is None),
450
+ transcript=transcript,
451
+ metadata=data.get("metadata", {}),
452
+ )
453
+
454
+ def _parse_jsonl_session(
455
+ self, file_path: Path, lines: list[dict[str, Any]]
456
+ ) -> SessionInfo:
457
+ """Parse JSONL format session data.
458
+
459
+ Args:
460
+ file_path: Path to the session file.
461
+ lines: List of parsed JSON lines.
462
+
463
+ Returns:
464
+ SessionInfo instance.
465
+ """
466
+ # Build transcript from lines
467
+ parts = []
468
+ started_at = None
469
+ ended_at = None
470
+ session_id = file_path.stem
471
+ is_active = True
472
+
473
+ for line in lines:
474
+ # Extract timestamps
475
+ if "timestamp" in line:
476
+ ts = datetime.fromisoformat(line["timestamp"])
477
+ if started_at is None:
478
+ started_at = ts
479
+ ended_at = ts
480
+
481
+ # Extract messages
482
+ if "role" in line and "content" in line:
483
+ role = line["role"].capitalize()
484
+ content = line["content"]
485
+ parts.append(f"{role}: {content}")
486
+
487
+ # Check for session end marker
488
+ if line.get("type") == "session_end":
489
+ is_active = False
490
+
491
+ # Extract session ID if present
492
+ if "session_id" in line:
493
+ session_id = line["session_id"]
494
+
495
+ return SessionInfo(
496
+ session_id=str(session_id),
497
+ file_path=file_path,
498
+ project_path=self._detect_project_path(file_path),
499
+ started_at=started_at,
500
+ ended_at=ended_at,
501
+ is_active=is_active,
502
+ transcript="\n\n".join(parts),
503
+ )
504
+
505
+ def _detect_project_path(self, session_file: Path) -> Path | None:
506
+ """Detect project path from session file location.
507
+
508
+ Args:
509
+ session_file: Path to the session file.
510
+
511
+ Returns:
512
+ Project path or None.
513
+ """
514
+ # Session files might contain project path in name or location
515
+ # For now, try to find a project marker
516
+ current = session_file.parent
517
+ while current != current.parent:
518
+ # Look for common project markers
519
+ markers = [".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod"]
520
+ for marker in markers:
521
+ if (current / marker).exists():
522
+ return current
523
+ current = current.parent
524
+ return None
525
+
526
+ async def _extract_from_session(self, session: SessionInfo) -> int:
527
+ """Extract memories from a session.
528
+
529
+ Args:
530
+ session: Session information.
531
+
532
+ Returns:
533
+ Number of memories extracted.
534
+ """
535
+ if not self._extractor or not self._engine:
536
+ return 0
537
+
538
+ project = session.project_path.name if session.project_path else None
539
+
540
+ result = await self._extractor.extract_and_store(
541
+ transcript=session.transcript,
542
+ engine=self._engine,
543
+ project=project,
544
+ )
545
+
546
+ if result.success:
547
+ logger.info(f"Extracted {result.memory_count} memories from session {session.session_id}")
548
+
549
+ # Notify callbacks
550
+ for callback in self._on_extraction:
551
+ try:
552
+ callback(session, result.memory_count)
553
+ except Exception as e:
554
+ logger.error(f"Extraction callback failed: {e}")
555
+
556
+ # Update CLAUDE.md if enabled
557
+ if self.config.update_claude_md and session.project_path:
558
+ await self._update_claude_md(session.project_path, project)
559
+
560
+ return result.memory_count
561
+
562
+ logger.warning(f"Extraction failed for session {session.session_id}: {result.error}")
563
+ return 0
564
+
565
+ # =========================================================================
566
+ # CLAUDE.md Management
567
+ # =========================================================================
568
+
569
+ async def _update_claude_md(self, project_path: Path, project: str | None) -> None:
570
+ """Update CLAUDE.md with memory context.
571
+
572
+ Args:
573
+ project_path: Path to the project directory.
574
+ project: Project name for memory filtering.
575
+ """
576
+ if not self._engine:
577
+ return
578
+
579
+ claude_md_path = self.config.claude_md_path or (project_path / "CLAUDE.md")
580
+
581
+ # Get context from engine
582
+ context = await self._engine.get_context(project=project, max_memories=20)
583
+
584
+ # Build memory section
585
+ memory_section = self._build_claude_md_section(context)
586
+
587
+ # Read existing file or create new
588
+ existing_content = ""
589
+ if claude_md_path.exists():
590
+ existing_content = claude_md_path.read_text()
591
+
592
+ # Update or append memory section
593
+ new_content = self._merge_claude_md_content(existing_content, memory_section)
594
+ claude_md_path.write_text(new_content)
595
+
596
+ logger.info(f"Updated {claude_md_path} with {context.included_count} memories")
597
+
598
+ def _build_claude_md_section(self, context: Any) -> str:
599
+ """Build CLAUDE.md memory section.
600
+
601
+ Args:
602
+ context: ContextResponse from engine.
603
+
604
+ Returns:
605
+ Formatted markdown section.
606
+ """
607
+ lines = [
608
+ "<!-- MEMORY-LAYER-START -->",
609
+ "## Project Knowledge (Auto-Generated)",
610
+ "",
611
+ ]
612
+
613
+ # Group by category
614
+ by_category: dict[str, list[Any]] = {}
615
+ for memory in context.memories:
616
+ cat = memory.category.value.title()
617
+ if cat not in by_category:
618
+ by_category[cat] = []
619
+ by_category[cat].append(memory)
620
+
621
+ # Format each category
622
+ for category, memories in sorted(by_category.items()):
623
+ lines.append(f"### {category}")
624
+ for memory in memories:
625
+ # Use checkmark for high-confidence, question mark for low
626
+ if memory.outcome_score > 0.3:
627
+ prefix = "- ✓"
628
+ elif memory.outcome_score < -0.2:
629
+ prefix = "- ?"
630
+ else:
631
+ prefix = "-"
632
+ lines.append(f"{prefix} {memory.content}")
633
+ lines.append("")
634
+
635
+ lines.append(f"*Last updated: {datetime.now(UTC).isoformat()}*")
636
+ lines.append("<!-- MEMORY-LAYER-END -->")
637
+
638
+ return "\n".join(lines)
639
+
640
+ def _merge_claude_md_content(self, existing: str, memory_section: str) -> str:
641
+ """Merge memory section into existing CLAUDE.md content.
642
+
643
+ Args:
644
+ existing: Existing file content.
645
+ memory_section: New memory section.
646
+
647
+ Returns:
648
+ Merged content.
649
+ """
650
+ start_marker = "<!-- MEMORY-LAYER-START -->"
651
+ end_marker = "<!-- MEMORY-LAYER-END -->"
652
+
653
+ # Check if markers exist
654
+ if start_marker in existing and end_marker in existing:
655
+ # Replace existing section
656
+ start_idx = existing.index(start_marker)
657
+ end_idx = existing.index(end_marker) + len(end_marker)
658
+ return existing[:start_idx] + memory_section + existing[end_idx:]
659
+
660
+ # Append to end
661
+ if existing and not existing.endswith("\n"):
662
+ existing += "\n"
663
+ return existing + "\n" + memory_section
664
+
665
+ # =========================================================================
666
+ # Daemon Lifecycle
667
+ # =========================================================================
668
+
669
+ def start(self) -> None:
670
+ """Start the daemon.
671
+
672
+ Raises:
673
+ DaemonAlreadyRunningError: If daemon is already running.
674
+ DaemonError: If daemon cannot start.
675
+ """
676
+ # Check for existing daemon
677
+ existing_pid = self._check_existing_daemon()
678
+ if existing_pid:
679
+ raise DaemonAlreadyRunningError(f"Daemon already running with PID {existing_pid}")
680
+
681
+ # Ensure session directory exists
682
+ if not self.config.session_dir.exists():
683
+ logger.warning(f"Session directory does not exist: {self.config.session_dir}")
684
+ self.config.session_dir.mkdir(parents=True, exist_ok=True)
685
+
686
+ # Write PID file
687
+ self._write_pid_file()
688
+
689
+ # Drop privileges if configured
690
+ self._drop_privileges()
691
+
692
+ # Set up signal handlers
693
+ self._setup_signal_handlers()
694
+
695
+ # Create and start observer
696
+ self._observer = Observer()
697
+ handler = SessionHandler(self, self.config)
698
+ self._observer.schedule(handler, str(self.config.session_dir), recursive=True)
699
+ self._observer.start()
700
+
701
+ self._running = True
702
+ logger.info(f"Daemon started, watching {self.config.session_dir}")
703
+
704
+ def stop(self) -> None:
705
+ """Stop the daemon gracefully."""
706
+ if not self._running:
707
+ return
708
+
709
+ logger.info("Stopping daemon...")
710
+ self._running = False
711
+
712
+ if self._observer:
713
+ self._observer.stop()
714
+ self._observer.join(timeout=5.0)
715
+ self._observer = None
716
+
717
+ self._remove_pid_file()
718
+ self._shutdown_event.set()
719
+ logger.info("Daemon stopped")
720
+
721
+ def _setup_signal_handlers(self) -> None:
722
+ """Set up signal handlers for graceful shutdown."""
723
+ if os.name == "posix":
724
+ signal.signal(signal.SIGTERM, self._signal_handler)
725
+ signal.signal(signal.SIGINT, self._signal_handler)
726
+ signal.signal(signal.SIGHUP, self._signal_handler)
727
+
728
+ def _signal_handler(self, signum: int, frame: Any) -> None:
729
+ """Handle shutdown signals.
730
+
731
+ Args:
732
+ signum: Signal number.
733
+ frame: Current stack frame.
734
+ """
735
+ logger.info(f"Received signal {signum}, shutting down...")
736
+ self.stop()
737
+
738
+ async def run_forever(self) -> None:
739
+ """Run the daemon until stopped."""
740
+ self.start()
741
+ try:
742
+ await self._shutdown_event.wait()
743
+ finally:
744
+ self.stop()
745
+
746
+ def run(self) -> None:
747
+ """Run the daemon synchronously."""
748
+ asyncio.run(self.run_forever())
749
+
750
+ # =========================================================================
751
+ # Health Check
752
+ # =========================================================================
753
+
754
+ def health_check(self) -> dict[str, Any]:
755
+ """Check daemon health.
756
+
757
+ Returns:
758
+ Health status dictionary.
759
+ """
760
+ return {
761
+ "status": "healthy" if self._running else "stopped",
762
+ "running": self._running,
763
+ "pid": os.getpid() if self._running else None,
764
+ "watch_dir": str(self.config.session_dir),
765
+ "watch_dir_exists": self.config.session_dir.exists(),
766
+ "observer_alive": self._observer.is_alive() if self._observer else False,
767
+ }
768
+
769
+ # =========================================================================
770
+ # Callback Registration
771
+ # =========================================================================
772
+
773
+ def on_session_start(self, callback: Callable[[SessionInfo], None]) -> None:
774
+ """Register callback for session start.
775
+
776
+ Args:
777
+ callback: Function to call when session starts.
778
+ """
779
+ self._on_session_start.append(callback)
780
+
781
+ def on_session_end(self, callback: Callable[[SessionInfo], None]) -> None:
782
+ """Register callback for session end.
783
+
784
+ Args:
785
+ callback: Function to call when session ends.
786
+ """
787
+ self._on_session_end.append(callback)
788
+
789
+ def on_extraction(self, callback: Callable[[SessionInfo, int], None]) -> None:
790
+ """Register callback for memory extraction.
791
+
792
+ Args:
793
+ callback: Function to call after extraction (session, count).
794
+ """
795
+ self._on_extraction.append(callback)
796
+
797
+
798
+ # =============================================================================
799
+ # Convenience Functions
800
+ # =============================================================================
801
+
802
+ def get_daemon_status(pid_file: Path | None = None) -> dict[str, Any]:
803
+ """Get status of the daemon.
804
+
805
+ Args:
806
+ pid_file: Path to PID file.
807
+
808
+ Returns:
809
+ Status dictionary.
810
+ """
811
+ pid_file = pid_file or DEFAULT_PID_FILE
812
+
813
+ if not pid_file.exists():
814
+ return {"running": False, "pid": None}
815
+
816
+ try:
817
+ pid = int(pid_file.read_text().strip())
818
+ os.kill(pid, 0) # Check if process exists
819
+ return {"running": True, "pid": pid}
820
+ except (ValueError, ProcessLookupError, PermissionError):
821
+ return {"running": False, "pid": None}
822
+
823
+
824
+ def stop_daemon(pid_file: Path | None = None) -> bool:
825
+ """Stop a running daemon.
826
+
827
+ Args:
828
+ pid_file: Path to PID file.
829
+
830
+ Returns:
831
+ True if daemon was stopped, False if not running.
832
+ """
833
+ pid_file = pid_file or DEFAULT_PID_FILE
834
+ status = get_daemon_status(pid_file)
835
+
836
+ if not status["running"]:
837
+ return False
838
+
839
+ pid = status["pid"]
840
+ try:
841
+ os.kill(pid, signal.SIGTERM)
842
+ # Wait for process to stop
843
+ import time
844
+ for _ in range(10):
845
+ time.sleep(0.5)
846
+ try:
847
+ os.kill(pid, 0)
848
+ except ProcessLookupError:
849
+ break
850
+ return True
851
+ except (ProcessLookupError, PermissionError):
852
+ return False