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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- 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
|