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,415 @@
|
|
|
1
|
+
"""Beads CLI bridge for interacting with the bd command.
|
|
2
|
+
|
|
3
|
+
This module provides a wrapper around the Beads CLI (`bd` command),
|
|
4
|
+
offering an alternative to direct file parsing. Useful when:
|
|
5
|
+
- You want real-time task status (CLI may have fresher data)
|
|
6
|
+
- You need to leverage bd's dependency resolution
|
|
7
|
+
- File parsing is insufficient for complex operations
|
|
8
|
+
|
|
9
|
+
Falls back to file parsing if bd command is unavailable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import shutil
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from runtime_memory.tasks.models import BeadsTask, BeadsTaskStatus
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Cache duration for CLI results (seconds)
|
|
30
|
+
CLI_CACHE_DURATION = 30
|
|
31
|
+
|
|
32
|
+
# Command timeout (seconds)
|
|
33
|
+
CLI_TIMEOUT = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CLIResult:
|
|
38
|
+
"""Result from a CLI command execution."""
|
|
39
|
+
|
|
40
|
+
success: bool
|
|
41
|
+
stdout: str
|
|
42
|
+
stderr: str
|
|
43
|
+
return_code: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BeadsCLI:
|
|
47
|
+
"""Wrapper for the Beads CLI (bd command).
|
|
48
|
+
|
|
49
|
+
Provides methods to interact with Beads via its command-line interface.
|
|
50
|
+
Results are cached for performance.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> cli = BeadsCLI()
|
|
54
|
+
>>> if cli.is_available():
|
|
55
|
+
... tasks = await cli.ready()
|
|
56
|
+
... for task in tasks:
|
|
57
|
+
... print(task.title)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, timeout: float = CLI_TIMEOUT) -> None:
|
|
61
|
+
"""Initialize the CLI bridge.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
timeout: Command timeout in seconds.
|
|
65
|
+
"""
|
|
66
|
+
self._timeout = timeout
|
|
67
|
+
self._bd_path: str | None = None
|
|
68
|
+
self._available: bool | None = None
|
|
69
|
+
self._version: str | None = None
|
|
70
|
+
|
|
71
|
+
# Cache
|
|
72
|
+
self._cache: dict[str, tuple[datetime, any]] = {}
|
|
73
|
+
self._cache_duration = CLI_CACHE_DURATION
|
|
74
|
+
|
|
75
|
+
def is_available(self) -> bool:
|
|
76
|
+
"""Check if the bd command is available.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if bd command exists and is executable.
|
|
80
|
+
"""
|
|
81
|
+
if self._available is None:
|
|
82
|
+
self._bd_path = shutil.which("bd")
|
|
83
|
+
self._available = self._bd_path is not None
|
|
84
|
+
if self._available:
|
|
85
|
+
logger.debug(f"Found bd at: {self._bd_path}")
|
|
86
|
+
else:
|
|
87
|
+
logger.debug("bd command not found in PATH")
|
|
88
|
+
return self._available
|
|
89
|
+
|
|
90
|
+
async def get_version(self) -> str | None:
|
|
91
|
+
"""Get the Beads version.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Version string or None if unavailable.
|
|
95
|
+
"""
|
|
96
|
+
if self._version is not None:
|
|
97
|
+
return self._version
|
|
98
|
+
|
|
99
|
+
if not self.is_available():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
result = await self._run_command(["bd", "--version"])
|
|
103
|
+
if result.success:
|
|
104
|
+
self._version = result.stdout.strip()
|
|
105
|
+
return self._version
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
async def ready(self) -> list[BeadsTask]:
|
|
109
|
+
"""Get tasks that are ready to work on.
|
|
110
|
+
|
|
111
|
+
Calls `bd ready --json` to get unblocked tasks.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of ready BeadsTask objects.
|
|
115
|
+
"""
|
|
116
|
+
return await self._cached_command("ready", ["bd", "ready", "--json"])
|
|
117
|
+
|
|
118
|
+
async def show(self, task_id: str) -> BeadsTask | None:
|
|
119
|
+
"""Get details for a specific task.
|
|
120
|
+
|
|
121
|
+
Calls `bd show <id> --json`.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
task_id: The task ID to look up.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
BeadsTask or None if not found.
|
|
128
|
+
"""
|
|
129
|
+
cache_key = f"show:{task_id}"
|
|
130
|
+
cached = self._get_cached(cache_key)
|
|
131
|
+
if cached is not None:
|
|
132
|
+
return cached
|
|
133
|
+
|
|
134
|
+
if not self.is_available():
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
result = await self._run_command(["bd", "show", task_id, "--json"])
|
|
138
|
+
if not result.success:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(result.stdout)
|
|
143
|
+
task = self._parse_task(data)
|
|
144
|
+
self._set_cached(cache_key, task)
|
|
145
|
+
return task
|
|
146
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
147
|
+
logger.warning(f"Failed to parse bd show output: {e}")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
async def list_all(self) -> list[BeadsTask]:
|
|
151
|
+
"""Get all tasks.
|
|
152
|
+
|
|
153
|
+
Calls `bd export --json` to get complete task list.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of all BeadsTask objects.
|
|
157
|
+
"""
|
|
158
|
+
return await self._cached_command("list_all", ["bd", "export", "--json"])
|
|
159
|
+
|
|
160
|
+
async def get_current(self) -> BeadsTask | None:
|
|
161
|
+
"""Get the currently active task.
|
|
162
|
+
|
|
163
|
+
Attempts to detect the current task from:
|
|
164
|
+
1. Tasks with in_progress status
|
|
165
|
+
2. Git branch naming convention (if applicable)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Current task or None.
|
|
169
|
+
"""
|
|
170
|
+
if not self.is_available():
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# Get all in-progress tasks
|
|
174
|
+
all_tasks = await self.list_all()
|
|
175
|
+
in_progress = [
|
|
176
|
+
t for t in all_tasks if t.status == BeadsTaskStatus.IN_PROGRESS
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
if in_progress:
|
|
180
|
+
return in_progress[0]
|
|
181
|
+
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def _cached_command(
|
|
185
|
+
self,
|
|
186
|
+
cache_key: str,
|
|
187
|
+
command: list[str],
|
|
188
|
+
) -> list[BeadsTask]:
|
|
189
|
+
"""Run a command with caching.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
cache_key: Key for cache lookup.
|
|
193
|
+
command: Command to execute.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
List of parsed tasks.
|
|
197
|
+
"""
|
|
198
|
+
cached = self._get_cached(cache_key)
|
|
199
|
+
if cached is not None:
|
|
200
|
+
return cached
|
|
201
|
+
|
|
202
|
+
if not self.is_available():
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
result = await self._run_command(command)
|
|
206
|
+
if not result.success:
|
|
207
|
+
logger.warning(f"Command failed: {' '.join(command)}")
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
tasks = self._parse_tasks_output(result.stdout)
|
|
211
|
+
self._set_cached(cache_key, tasks)
|
|
212
|
+
return tasks
|
|
213
|
+
|
|
214
|
+
async def _run_command(self, command: list[str]) -> CLIResult:
|
|
215
|
+
"""Run a shell command asynchronously.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
command: Command and arguments to execute.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
CLIResult with output and status.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
process = await asyncio.create_subprocess_exec(
|
|
225
|
+
*command,
|
|
226
|
+
stdout=asyncio.subprocess.PIPE,
|
|
227
|
+
stderr=asyncio.subprocess.PIPE,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
stdout, stderr = await asyncio.wait_for(
|
|
232
|
+
process.communicate(),
|
|
233
|
+
timeout=self._timeout,
|
|
234
|
+
)
|
|
235
|
+
except asyncio.TimeoutError:
|
|
236
|
+
process.kill()
|
|
237
|
+
await process.wait()
|
|
238
|
+
return CLIResult(
|
|
239
|
+
success=False,
|
|
240
|
+
stdout="",
|
|
241
|
+
stderr=f"Command timed out after {self._timeout}s",
|
|
242
|
+
return_code=-1,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return CLIResult(
|
|
246
|
+
success=process.returncode == 0,
|
|
247
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
248
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
249
|
+
return_code=process.returncode or 0,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
return CLIResult(
|
|
254
|
+
success=False,
|
|
255
|
+
stdout="",
|
|
256
|
+
stderr="Command not found",
|
|
257
|
+
return_code=-1,
|
|
258
|
+
)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return CLIResult(
|
|
261
|
+
success=False,
|
|
262
|
+
stdout="",
|
|
263
|
+
stderr=str(e),
|
|
264
|
+
return_code=-1,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _parse_tasks_output(self, output: str) -> list[BeadsTask]:
|
|
268
|
+
"""Parse JSON output containing multiple tasks.
|
|
269
|
+
|
|
270
|
+
Handles both JSON array and JSONL formats.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
output: Raw command output.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of parsed tasks.
|
|
277
|
+
"""
|
|
278
|
+
output = output.strip()
|
|
279
|
+
if not output:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
tasks = []
|
|
283
|
+
|
|
284
|
+
# Try JSON array first
|
|
285
|
+
try:
|
|
286
|
+
data = json.loads(output)
|
|
287
|
+
if isinstance(data, list):
|
|
288
|
+
for item in data:
|
|
289
|
+
try:
|
|
290
|
+
tasks.append(self._parse_task(item))
|
|
291
|
+
except (KeyError, ValueError) as e:
|
|
292
|
+
logger.warning(f"Failed to parse task: {e}")
|
|
293
|
+
return tasks
|
|
294
|
+
elif isinstance(data, dict):
|
|
295
|
+
# Single task wrapped in object
|
|
296
|
+
if "tasks" in data:
|
|
297
|
+
for item in data["tasks"]:
|
|
298
|
+
try:
|
|
299
|
+
tasks.append(self._parse_task(item))
|
|
300
|
+
except (KeyError, ValueError) as e:
|
|
301
|
+
logger.warning(f"Failed to parse task: {e}")
|
|
302
|
+
return tasks
|
|
303
|
+
else:
|
|
304
|
+
return [self._parse_task(data)]
|
|
305
|
+
except json.JSONDecodeError:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
# Try JSONL (one JSON object per line)
|
|
309
|
+
for line in output.split("\n"):
|
|
310
|
+
line = line.strip()
|
|
311
|
+
if not line:
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
data = json.loads(line)
|
|
315
|
+
tasks.append(self._parse_task(data))
|
|
316
|
+
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
|
317
|
+
logger.debug(f"Failed to parse line: {e}")
|
|
318
|
+
|
|
319
|
+
return tasks
|
|
320
|
+
|
|
321
|
+
def _parse_task(self, data: dict) -> BeadsTask:
|
|
322
|
+
"""Parse a single task from JSON data.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
data: Task data dictionary.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
BeadsTask object.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
KeyError: If required fields are missing.
|
|
332
|
+
ValueError: If data is invalid.
|
|
333
|
+
"""
|
|
334
|
+
# Map common field names from Beads output
|
|
335
|
+
task_id = data.get("id") or data.get("task_id") or data.get("ID")
|
|
336
|
+
if not task_id:
|
|
337
|
+
raise KeyError("Task ID not found")
|
|
338
|
+
|
|
339
|
+
title = data.get("title") or data.get("name") or data.get("summary") or ""
|
|
340
|
+
|
|
341
|
+
# Map status
|
|
342
|
+
status_str = data.get("status", "pending").lower()
|
|
343
|
+
status_map = {
|
|
344
|
+
"pending": BeadsTaskStatus.PENDING,
|
|
345
|
+
"todo": BeadsTaskStatus.PENDING,
|
|
346
|
+
"ready": BeadsTaskStatus.PENDING,
|
|
347
|
+
"in_progress": BeadsTaskStatus.IN_PROGRESS,
|
|
348
|
+
"in-progress": BeadsTaskStatus.IN_PROGRESS,
|
|
349
|
+
"active": BeadsTaskStatus.IN_PROGRESS,
|
|
350
|
+
"working": BeadsTaskStatus.IN_PROGRESS,
|
|
351
|
+
"done": BeadsTaskStatus.DONE,
|
|
352
|
+
"completed": BeadsTaskStatus.DONE,
|
|
353
|
+
"finished": BeadsTaskStatus.DONE,
|
|
354
|
+
"blocked": BeadsTaskStatus.BLOCKED,
|
|
355
|
+
"waiting": BeadsTaskStatus.BLOCKED,
|
|
356
|
+
"cancelled": BeadsTaskStatus.CANCELLED,
|
|
357
|
+
"canceled": BeadsTaskStatus.CANCELLED,
|
|
358
|
+
"abandoned": BeadsTaskStatus.CANCELLED,
|
|
359
|
+
}
|
|
360
|
+
status = status_map.get(status_str, BeadsTaskStatus.PENDING)
|
|
361
|
+
|
|
362
|
+
return BeadsTask(
|
|
363
|
+
id=task_id,
|
|
364
|
+
title=title,
|
|
365
|
+
description=data.get("description", ""),
|
|
366
|
+
status=status,
|
|
367
|
+
parent_id=data.get("parent_id") or data.get("parent"),
|
|
368
|
+
dependencies=data.get("dependencies", []) or data.get("blocked_by", []),
|
|
369
|
+
tags=data.get("tags", []) or data.get("labels", []),
|
|
370
|
+
metadata=data.get("metadata", {}),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _get_cached(self, key: str) -> any:
|
|
374
|
+
"""Get a cached value if still valid.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
key: Cache key.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Cached value or None if expired/missing.
|
|
381
|
+
"""
|
|
382
|
+
if key not in self._cache:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
cached_time, value = self._cache[key]
|
|
386
|
+
age = (datetime.now(UTC) - cached_time).total_seconds()
|
|
387
|
+
|
|
388
|
+
if age > self._cache_duration:
|
|
389
|
+
del self._cache[key]
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
def _set_cached(self, key: str, value: any) -> None:
|
|
395
|
+
"""Set a cached value.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
key: Cache key.
|
|
399
|
+
value: Value to cache.
|
|
400
|
+
"""
|
|
401
|
+
self._cache[key] = (datetime.now(UTC), value)
|
|
402
|
+
|
|
403
|
+
def invalidate_cache(self) -> None:
|
|
404
|
+
"""Clear all cached values."""
|
|
405
|
+
self._cache.clear()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Convenience function
|
|
409
|
+
def get_beads_cli() -> BeadsCLI:
|
|
410
|
+
"""Get a BeadsCLI instance.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
BeadsCLI instance.
|
|
414
|
+
"""
|
|
415
|
+
return BeadsCLI()
|