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,339 @@
|
|
|
1
|
+
"""Claude Code todos parser for reading task data from ~/.claude/todos/.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Discovery of ~/.claude/todos/ directory
|
|
5
|
+
- Parsing JSON array task files
|
|
6
|
+
- Building task index with session/agent relationships
|
|
7
|
+
- Graceful handling of malformed data
|
|
8
|
+
- Support for CLAUDE_CODE_TASK_LIST_ID environment variable
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from runtime_memory.tasks.models import ClaudeCodeTask, ClaudeCodeTaskStatus
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClaudeCodeDirectoryNotFoundError(Exception):
|
|
28
|
+
"""Raised when ~/.claude/todos/ directory cannot be found."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeCodeParser:
|
|
34
|
+
"""Parses Claude Code task data from ~/.claude/todos/ directory.
|
|
35
|
+
|
|
36
|
+
Supports:
|
|
37
|
+
- JSON array files (each file is a session's task list)
|
|
38
|
+
- CLAUDE_CODE_TASK_LIST_ID environment variable for filtering
|
|
39
|
+
- Caching of parsed tasks
|
|
40
|
+
|
|
41
|
+
File naming convention:
|
|
42
|
+
{session-uuid}-agent-{agent-uuid}.json
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> parser = ClaudeCodeParser()
|
|
46
|
+
>>> tasks = parser.list_tasks()
|
|
47
|
+
>>> ready = parser.get_ready_tasks()
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Default location for Claude Code todos
|
|
51
|
+
DEFAULT_TODOS_DIR = Path.home() / ".claude" / "todos"
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
todos_dir: Path | str | None = None,
|
|
56
|
+
task_list_id: str | None = None,
|
|
57
|
+
):
|
|
58
|
+
"""Initialize the parser.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
todos_dir: Explicit path to todos directory.
|
|
62
|
+
If None, uses ~/.claude/todos/.
|
|
63
|
+
task_list_id: Filter to specific task list ID (session/agent).
|
|
64
|
+
If None, checks CLAUDE_CODE_TASK_LIST_ID env var.
|
|
65
|
+
"""
|
|
66
|
+
self._todos_dir: Path | None = None
|
|
67
|
+
self._explicit_dir = Path(todos_dir) if todos_dir else None
|
|
68
|
+
self._task_list_id = task_list_id or os.environ.get("CLAUDE_CODE_TASK_LIST_ID")
|
|
69
|
+
self._task_cache: dict[str, ClaudeCodeTask] = {}
|
|
70
|
+
self._cache_valid = False
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def todos_dir(self) -> Path | None:
|
|
74
|
+
"""Get the todos directory path, discovering if needed."""
|
|
75
|
+
if self._todos_dir is None:
|
|
76
|
+
self._todos_dir = self._discover_todos_dir()
|
|
77
|
+
return self._todos_dir
|
|
78
|
+
|
|
79
|
+
def _discover_todos_dir(self) -> Path | None:
|
|
80
|
+
"""Discover the todos directory.
|
|
81
|
+
|
|
82
|
+
Search order:
|
|
83
|
+
1. Explicit path provided to constructor
|
|
84
|
+
2. $CLAUDE_CODE_TODOS_DIR environment variable
|
|
85
|
+
3. Default ~/.claude/todos/
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Path to todos directory or None if not found.
|
|
89
|
+
"""
|
|
90
|
+
# 1. Explicit path
|
|
91
|
+
if self._explicit_dir:
|
|
92
|
+
if self._explicit_dir.is_dir():
|
|
93
|
+
logger.debug(f"Using explicit todos dir: {self._explicit_dir}")
|
|
94
|
+
return self._explicit_dir
|
|
95
|
+
logger.warning(f"Explicit todos dir not found: {self._explicit_dir}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# 2. Environment variable
|
|
99
|
+
env_dir = os.environ.get("CLAUDE_CODE_TODOS_DIR")
|
|
100
|
+
if env_dir:
|
|
101
|
+
env_path = Path(env_dir)
|
|
102
|
+
if env_path.is_dir():
|
|
103
|
+
logger.debug(f"Using $CLAUDE_CODE_TODOS_DIR: {env_path}")
|
|
104
|
+
return env_path
|
|
105
|
+
logger.warning(f"$CLAUDE_CODE_TODOS_DIR not found: {env_path}")
|
|
106
|
+
|
|
107
|
+
# 3. Default location
|
|
108
|
+
default_path = self.DEFAULT_TODOS_DIR
|
|
109
|
+
if default_path.is_dir():
|
|
110
|
+
logger.debug(f"Using default todos dir: {default_path}")
|
|
111
|
+
return default_path
|
|
112
|
+
|
|
113
|
+
logger.debug("No Claude Code todos directory found")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def is_available(self) -> bool:
|
|
117
|
+
"""Check if Claude Code todos are available (directory exists)."""
|
|
118
|
+
return self.todos_dir is not None
|
|
119
|
+
|
|
120
|
+
def invalidate_cache(self) -> None:
|
|
121
|
+
"""Invalidate the task cache, forcing a re-parse on next access."""
|
|
122
|
+
self._task_cache.clear()
|
|
123
|
+
self._cache_valid = False
|
|
124
|
+
|
|
125
|
+
def _ensure_cache(self) -> None:
|
|
126
|
+
"""Ensure the task cache is populated."""
|
|
127
|
+
if self._cache_valid:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._task_cache.clear()
|
|
131
|
+
|
|
132
|
+
if not self.todos_dir:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# Parse all JSON files in todos dir
|
|
136
|
+
for task in self._parse_all_files():
|
|
137
|
+
self._task_cache[task.id] = task
|
|
138
|
+
|
|
139
|
+
self._cache_valid = True
|
|
140
|
+
logger.debug(f"Cached {len(self._task_cache)} Claude Code tasks")
|
|
141
|
+
|
|
142
|
+
def _parse_all_files(self) -> Iterator[ClaudeCodeTask]:
|
|
143
|
+
"""Parse all JSON files in the todos directory.
|
|
144
|
+
|
|
145
|
+
Yields:
|
|
146
|
+
ClaudeCodeTask objects for each valid task entry.
|
|
147
|
+
"""
|
|
148
|
+
if not self.todos_dir:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
# Find all .json files
|
|
152
|
+
json_files = list(self.todos_dir.glob("*.json"))
|
|
153
|
+
logger.debug(f"Found {len(json_files)} JSON files in todos dir")
|
|
154
|
+
|
|
155
|
+
for json_file in json_files:
|
|
156
|
+
# Filter by task list ID if specified
|
|
157
|
+
if self._task_list_id and self._task_list_id not in json_file.name:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
yield from self._parse_json_file(json_file)
|
|
161
|
+
|
|
162
|
+
def _parse_json_file(self, file_path: Path) -> Iterator[ClaudeCodeTask]:
|
|
163
|
+
"""Parse a single JSON file containing tasks.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
file_path: Path to the JSON file.
|
|
167
|
+
|
|
168
|
+
Yields:
|
|
169
|
+
ClaudeCodeTask objects for each valid task.
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
# Extract session and agent IDs from filename
|
|
173
|
+
# Format: {session-uuid}-agent-{agent-uuid}.json
|
|
174
|
+
filename = file_path.stem # Remove .json
|
|
175
|
+
session_id = ""
|
|
176
|
+
agent_id = ""
|
|
177
|
+
|
|
178
|
+
if "-agent-" in filename:
|
|
179
|
+
parts = filename.split("-agent-")
|
|
180
|
+
session_id = parts[0]
|
|
181
|
+
agent_id = parts[1] if len(parts) > 1 else ""
|
|
182
|
+
else:
|
|
183
|
+
session_id = filename
|
|
184
|
+
|
|
185
|
+
with open(file_path, encoding="utf-8") as f:
|
|
186
|
+
data = json.load(f)
|
|
187
|
+
|
|
188
|
+
# Handle empty files or empty arrays
|
|
189
|
+
if not data:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# File should be a JSON array
|
|
193
|
+
if not isinstance(data, list):
|
|
194
|
+
logger.warning(f"Expected JSON array in {file_path}, got {type(data)}")
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
for index, task_data in enumerate(data):
|
|
198
|
+
try:
|
|
199
|
+
if not isinstance(task_data, dict):
|
|
200
|
+
logger.warning(
|
|
201
|
+
f"Expected dict at index {index} in {file_path}"
|
|
202
|
+
)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
task = ClaudeCodeTask.from_dict(
|
|
206
|
+
task_data,
|
|
207
|
+
session_id=session_id,
|
|
208
|
+
agent_id=agent_id,
|
|
209
|
+
index=index,
|
|
210
|
+
file_path=str(file_path),
|
|
211
|
+
)
|
|
212
|
+
yield task
|
|
213
|
+
|
|
214
|
+
except (KeyError, ValueError) as e:
|
|
215
|
+
logger.warning(
|
|
216
|
+
f"Invalid task data at {file_path}[{index}]: {e}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
except json.JSONDecodeError as e:
|
|
220
|
+
logger.warning(f"Malformed JSON in {file_path}: {e}")
|
|
221
|
+
except OSError as e:
|
|
222
|
+
logger.error(f"Failed to read {file_path}: {e}")
|
|
223
|
+
|
|
224
|
+
def get_task(self, task_id: str) -> ClaudeCodeTask | None:
|
|
225
|
+
"""Get a task by ID.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
task_id: The task ID (e.g., "cc-abc12345-0").
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The task or None if not found.
|
|
232
|
+
"""
|
|
233
|
+
self._ensure_cache()
|
|
234
|
+
return self._task_cache.get(task_id)
|
|
235
|
+
|
|
236
|
+
def list_tasks(
|
|
237
|
+
self,
|
|
238
|
+
status: ClaudeCodeTaskStatus | None = None,
|
|
239
|
+
session_id: str | None = None,
|
|
240
|
+
) -> list[ClaudeCodeTask]:
|
|
241
|
+
"""List all tasks, optionally filtered.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
status: Filter by status (e.g., COMPLETED).
|
|
245
|
+
session_id: Filter by session ID.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of matching tasks.
|
|
249
|
+
"""
|
|
250
|
+
self._ensure_cache()
|
|
251
|
+
|
|
252
|
+
tasks = list(self._task_cache.values())
|
|
253
|
+
|
|
254
|
+
if status is not None:
|
|
255
|
+
tasks = [t for t in tasks if t.status == status]
|
|
256
|
+
|
|
257
|
+
if session_id is not None:
|
|
258
|
+
tasks = [t for t in tasks if t.session_id == session_id]
|
|
259
|
+
|
|
260
|
+
return tasks
|
|
261
|
+
|
|
262
|
+
def get_ready_tasks(self) -> list[ClaudeCodeTask]:
|
|
263
|
+
"""Get tasks that are ready to work on (pending status).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of ready tasks.
|
|
267
|
+
"""
|
|
268
|
+
return self.list_tasks(status=ClaudeCodeTaskStatus.PENDING)
|
|
269
|
+
|
|
270
|
+
def get_in_progress_tasks(self) -> list[ClaudeCodeTask]:
|
|
271
|
+
"""Get tasks currently being worked on.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
List of in-progress tasks.
|
|
275
|
+
"""
|
|
276
|
+
return self.list_tasks(status=ClaudeCodeTaskStatus.IN_PROGRESS)
|
|
277
|
+
|
|
278
|
+
def get_completed_tasks(self) -> list[ClaudeCodeTask]:
|
|
279
|
+
"""Get tasks that have been completed.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of completed tasks.
|
|
283
|
+
"""
|
|
284
|
+
return self.list_tasks(status=ClaudeCodeTaskStatus.COMPLETED)
|
|
285
|
+
|
|
286
|
+
def get_tasks_by_session(self, session_id: str) -> list[ClaudeCodeTask]:
|
|
287
|
+
"""Get all tasks for a specific session.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
session_id: The Claude session ID.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of tasks for that session.
|
|
294
|
+
"""
|
|
295
|
+
return self.list_tasks(session_id=session_id)
|
|
296
|
+
|
|
297
|
+
def get_sessions(self) -> list[str]:
|
|
298
|
+
"""Get list of unique session IDs.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of session IDs that have tasks.
|
|
302
|
+
"""
|
|
303
|
+
self._ensure_cache()
|
|
304
|
+
sessions = set()
|
|
305
|
+
for task in self._task_cache.values():
|
|
306
|
+
if task.session_id:
|
|
307
|
+
sessions.add(task.session_id)
|
|
308
|
+
return sorted(sessions)
|
|
309
|
+
|
|
310
|
+
def refresh(self) -> int:
|
|
311
|
+
"""Refresh the task cache from disk.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Number of tasks loaded.
|
|
315
|
+
"""
|
|
316
|
+
self.invalidate_cache()
|
|
317
|
+
self._ensure_cache()
|
|
318
|
+
return len(self._task_cache)
|
|
319
|
+
|
|
320
|
+
def get_stats(self) -> dict:
|
|
321
|
+
"""Get statistics about Claude Code tasks.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Dict with task statistics.
|
|
325
|
+
"""
|
|
326
|
+
self._ensure_cache()
|
|
327
|
+
|
|
328
|
+
tasks = list(self._task_cache.values())
|
|
329
|
+
by_status = {}
|
|
330
|
+
for task in tasks:
|
|
331
|
+
status = task.status.value
|
|
332
|
+
by_status[status] = by_status.get(status, 0) + 1
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
"total_tasks": len(tasks),
|
|
336
|
+
"by_status": by_status,
|
|
337
|
+
"sessions": len(self.get_sessions()),
|
|
338
|
+
"todos_dir": str(self.todos_dir) if self.todos_dir else None,
|
|
339
|
+
}
|