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,320 @@
|
|
|
1
|
+
"""Automatic outcome capture for Beads task integration.
|
|
2
|
+
|
|
3
|
+
This module is THE key feature of Beads integration. It automatically
|
|
4
|
+
records outcomes for memories when tasks complete, closing the feedback
|
|
5
|
+
loop without requiring manual /outcome calls.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
Task "bd-a3f8" marked "done"
|
|
9
|
+
→ Find all memories linked to this task
|
|
10
|
+
→ Record "worked" outcome for each memory
|
|
11
|
+
→ Score boost (+0.2) applied automatically
|
|
12
|
+
→ Bad advice naturally sinks, good advice rises
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from runtime_memory.core.models import Outcome
|
|
21
|
+
from runtime_memory.tasks.linking import TaskMemoryLinker
|
|
22
|
+
from runtime_memory.tasks.models import (
|
|
23
|
+
CANCELLED_TASK_PENALTY,
|
|
24
|
+
TASK_STATUS_TO_OUTCOME,
|
|
25
|
+
BeadsSyncResult,
|
|
26
|
+
BeadsTask,
|
|
27
|
+
BeadsTaskStatus,
|
|
28
|
+
)
|
|
29
|
+
from runtime_memory.tasks.parser import BeadsParser
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from runtime_memory.core.engine import MemoryEngine
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OutcomeCapture:
|
|
38
|
+
"""Automatically captures outcomes when Beads tasks complete.
|
|
39
|
+
|
|
40
|
+
This class monitors task status and records outcomes for linked
|
|
41
|
+
memories when tasks reach terminal states (done, cancelled).
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> capture = OutcomeCapture(engine, linker, parser)
|
|
45
|
+
>>> result = await capture.on_task_completed("bd-a3f8")
|
|
46
|
+
>>> print(f"Updated {result} memories")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
engine: MemoryEngine,
|
|
52
|
+
linker: TaskMemoryLinker,
|
|
53
|
+
parser: BeadsParser | None = None,
|
|
54
|
+
auto_outcome_enabled: bool = True,
|
|
55
|
+
outcome_on_cancel: bool = False,
|
|
56
|
+
min_confidence_for_outcome: float = 0.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Initialize the outcome capture system.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
engine: The Runtime Memory engine for recording outcomes.
|
|
62
|
+
linker: The task-memory linker for finding linked memories.
|
|
63
|
+
parser: Optional Beads parser for task status lookups.
|
|
64
|
+
auto_outcome_enabled: Whether to automatically record outcomes.
|
|
65
|
+
outcome_on_cancel: Whether to record "failed" on cancelled tasks.
|
|
66
|
+
min_confidence_for_outcome: Minimum memory confidence to auto-record.
|
|
67
|
+
"""
|
|
68
|
+
self._engine = engine
|
|
69
|
+
self._linker = linker
|
|
70
|
+
self._parser = parser
|
|
71
|
+
self.auto_outcome_enabled = auto_outcome_enabled
|
|
72
|
+
self.outcome_on_cancel = outcome_on_cancel
|
|
73
|
+
self.min_confidence_for_outcome = min_confidence_for_outcome
|
|
74
|
+
|
|
75
|
+
async def on_task_completed(self, task_id: str) -> int:
|
|
76
|
+
"""Handle a task being marked as completed (done).
|
|
77
|
+
|
|
78
|
+
Records "worked" outcome for all unresolved linked memories.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
task_id: The Beads task ID that was completed.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Number of memories that had outcomes recorded.
|
|
85
|
+
"""
|
|
86
|
+
if not self.auto_outcome_enabled:
|
|
87
|
+
logger.debug(f"Auto-outcome disabled, skipping task {task_id}")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
return await self._record_outcome_for_task(task_id, Outcome.WORKED)
|
|
91
|
+
|
|
92
|
+
async def on_task_failed(self, task_id: str) -> int:
|
|
93
|
+
"""Handle a task being marked as failed/cancelled.
|
|
94
|
+
|
|
95
|
+
Records "failed" outcome for all unresolved linked memories
|
|
96
|
+
(if outcome_on_cancel is enabled).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
task_id: The Beads task ID that failed.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Number of memories that had outcomes recorded.
|
|
103
|
+
"""
|
|
104
|
+
if not self.auto_outcome_enabled:
|
|
105
|
+
logger.debug(f"Auto-outcome disabled, skipping task {task_id}")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
if not self.outcome_on_cancel:
|
|
109
|
+
logger.debug(f"Outcome on cancel disabled, skipping task {task_id}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
return await self._record_outcome_for_task(task_id, Outcome.FAILED)
|
|
113
|
+
|
|
114
|
+
async def on_task_blocked(self, task_id: str) -> int:
|
|
115
|
+
"""Handle a task being marked as blocked.
|
|
116
|
+
|
|
117
|
+
Records "partial" outcome for all unresolved linked memories.
|
|
118
|
+
This indicates the advice was on the right track but couldn't
|
|
119
|
+
fully solve the problem.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
task_id: The Beads task ID that was blocked.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Number of memories that had outcomes recorded.
|
|
126
|
+
"""
|
|
127
|
+
if not self.auto_outcome_enabled:
|
|
128
|
+
logger.debug(f"Auto-outcome disabled, skipping task {task_id}")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
return await self._record_outcome_for_task(task_id, Outcome.PARTIAL)
|
|
132
|
+
|
|
133
|
+
async def _record_outcome_for_task(
|
|
134
|
+
self,
|
|
135
|
+
task_id: str,
|
|
136
|
+
outcome: Outcome,
|
|
137
|
+
) -> int:
|
|
138
|
+
"""Record outcome for all unresolved memories linked to a task.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
task_id: The Beads task ID.
|
|
142
|
+
outcome: The outcome to record.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Number of memories updated.
|
|
146
|
+
"""
|
|
147
|
+
# Get unresolved links (memories without outcomes yet)
|
|
148
|
+
links = await self._linker.get_unresolved_links(task_id)
|
|
149
|
+
|
|
150
|
+
if not links:
|
|
151
|
+
logger.debug(f"No unresolved links for task {task_id}")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# Filter by minimum confidence if specified
|
|
155
|
+
memory_ids = []
|
|
156
|
+
for link in links:
|
|
157
|
+
if self.min_confidence_for_outcome > 0:
|
|
158
|
+
try:
|
|
159
|
+
memory = await self._engine.get(link.memory_id)
|
|
160
|
+
if memory.confidence < self.min_confidence_for_outcome:
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"Skipping memory {link.memory_id} "
|
|
163
|
+
f"(confidence {memory.confidence} < {self.min_confidence_for_outcome})"
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
except Exception:
|
|
167
|
+
# Memory might not exist anymore, skip it
|
|
168
|
+
continue
|
|
169
|
+
memory_ids.append(link.memory_id)
|
|
170
|
+
|
|
171
|
+
if not memory_ids:
|
|
172
|
+
logger.debug(f"No memories passed confidence filter for task {task_id}")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
# Record outcomes in the engine
|
|
176
|
+
try:
|
|
177
|
+
await self._engine.record_outcome(memory_ids, outcome)
|
|
178
|
+
logger.info(
|
|
179
|
+
f"Recorded {outcome.value} for {len(memory_ids)} memories "
|
|
180
|
+
f"linked to task {task_id}"
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Failed to record outcomes for task {task_id}: {e}")
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
# Mark links as having outcomes recorded
|
|
187
|
+
await self._linker.record_task_outcome(task_id, outcome.value)
|
|
188
|
+
|
|
189
|
+
return len(memory_ids)
|
|
190
|
+
|
|
191
|
+
async def process_task_status_change(
|
|
192
|
+
self,
|
|
193
|
+
task_id: str,
|
|
194
|
+
old_status: BeadsTaskStatus,
|
|
195
|
+
new_status: BeadsTaskStatus,
|
|
196
|
+
) -> int:
|
|
197
|
+
"""Process a task status change and record outcomes if appropriate.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
task_id: The Beads task ID.
|
|
201
|
+
old_status: Previous task status.
|
|
202
|
+
new_status: New task status.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Number of memories that had outcomes recorded.
|
|
206
|
+
"""
|
|
207
|
+
# Only process transitions to terminal states
|
|
208
|
+
if new_status == BeadsTaskStatus.DONE:
|
|
209
|
+
return await self.on_task_completed(task_id)
|
|
210
|
+
elif new_status == BeadsTaskStatus.CANCELLED:
|
|
211
|
+
return await self.on_task_failed(task_id)
|
|
212
|
+
elif new_status == BeadsTaskStatus.BLOCKED:
|
|
213
|
+
return await self.on_task_blocked(task_id)
|
|
214
|
+
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
async def sync_completed_tasks(self) -> BeadsSyncResult:
|
|
218
|
+
"""Sync outcomes for all completed tasks that have unresolved links.
|
|
219
|
+
|
|
220
|
+
Scans all completed tasks in Beads and records outcomes for any
|
|
221
|
+
that have memories without recorded outcomes.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
BeadsSyncResult with sync statistics.
|
|
225
|
+
"""
|
|
226
|
+
result = BeadsSyncResult()
|
|
227
|
+
|
|
228
|
+
if not self._parser:
|
|
229
|
+
result.errors.append("No parser available for task sync")
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
if not self._parser.is_available():
|
|
233
|
+
result.warnings.append("Beads directory not found")
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
# Get all completed tasks
|
|
237
|
+
completed_tasks = self._parser.get_completed_tasks()
|
|
238
|
+
result.tasks_found = len(completed_tasks)
|
|
239
|
+
|
|
240
|
+
for task in completed_tasks:
|
|
241
|
+
try:
|
|
242
|
+
# Check if task has unresolved links
|
|
243
|
+
unresolved = await self._linker.get_unresolved_links(task.id)
|
|
244
|
+
if not unresolved:
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Record outcome based on task status
|
|
248
|
+
if task.status == BeadsTaskStatus.DONE:
|
|
249
|
+
count = await self.on_task_completed(task.id)
|
|
250
|
+
elif task.status == BeadsTaskStatus.CANCELLED:
|
|
251
|
+
count = await self.on_task_failed(task.id)
|
|
252
|
+
else:
|
|
253
|
+
count = 0
|
|
254
|
+
|
|
255
|
+
result.outcomes_recorded += count
|
|
256
|
+
result.tasks_synced += 1
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
result.errors.append(f"Error processing task {task.id}: {e}")
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
async def check_and_record(self, task_id: str) -> int:
|
|
264
|
+
"""Check task status and record outcome if completed.
|
|
265
|
+
|
|
266
|
+
Convenience method that looks up the task status and calls
|
|
267
|
+
the appropriate outcome handler.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
task_id: The Beads task ID to check.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Number of memories that had outcomes recorded.
|
|
274
|
+
"""
|
|
275
|
+
if not self._parser:
|
|
276
|
+
logger.warning("No parser available, cannot check task status")
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
task = self._parser.get_task(task_id)
|
|
280
|
+
if not task:
|
|
281
|
+
logger.warning(f"Task {task_id} not found")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
if task.status == BeadsTaskStatus.DONE:
|
|
285
|
+
return await self.on_task_completed(task_id)
|
|
286
|
+
elif task.status == BeadsTaskStatus.CANCELLED:
|
|
287
|
+
return await self.on_task_failed(task_id)
|
|
288
|
+
elif task.status == BeadsTaskStatus.BLOCKED:
|
|
289
|
+
return await self.on_task_blocked(task_id)
|
|
290
|
+
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def auto_capture_outcome(
|
|
295
|
+
engine: MemoryEngine,
|
|
296
|
+
linker: TaskMemoryLinker,
|
|
297
|
+
task_id: str,
|
|
298
|
+
status: BeadsTaskStatus,
|
|
299
|
+
) -> int:
|
|
300
|
+
"""Convenience function to capture outcome for a task.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
engine: The Runtime Memory engine.
|
|
304
|
+
linker: The task-memory linker.
|
|
305
|
+
task_id: The Beads task ID.
|
|
306
|
+
status: The task's new status.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Number of memories that had outcomes recorded.
|
|
310
|
+
"""
|
|
311
|
+
capture = OutcomeCapture(engine, linker)
|
|
312
|
+
|
|
313
|
+
if status == BeadsTaskStatus.DONE:
|
|
314
|
+
return await capture.on_task_completed(task_id)
|
|
315
|
+
elif status == BeadsTaskStatus.CANCELLED:
|
|
316
|
+
return await capture.on_task_failed(task_id)
|
|
317
|
+
elif status == BeadsTaskStatus.BLOCKED:
|
|
318
|
+
return await capture.on_task_blocked(task_id)
|
|
319
|
+
|
|
320
|
+
return 0
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Beads file parser for reading task data from .beads/ directory.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- Discovery of .beads/ directory
|
|
5
|
+
- Parsing JSONL task files
|
|
6
|
+
- Building task index with parent-child relationships
|
|
7
|
+
- Graceful handling of malformed data
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from runtime_memory.tasks.models import BeadsTask, BeadsTaskStatus
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BeadsDirectoryNotFoundError(Exception):
|
|
27
|
+
"""Raised when .beads/ directory cannot be found."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BeadsParser:
|
|
33
|
+
"""Parses Beads task data from .beads/ directory.
|
|
34
|
+
|
|
35
|
+
Supports:
|
|
36
|
+
- JSONL files (one JSON object per line)
|
|
37
|
+
- Directory discovery (walks up from current dir)
|
|
38
|
+
- Environment variable override ($BEADS_DIR)
|
|
39
|
+
- Caching of parsed tasks
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> parser = BeadsParser()
|
|
43
|
+
>>> tasks = parser.list_tasks()
|
|
44
|
+
>>> ready = parser.get_ready_tasks()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, beads_dir: Path | str | None = None):
|
|
48
|
+
"""Initialize the parser.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
beads_dir: Explicit path to .beads/ directory.
|
|
52
|
+
If None, will auto-discover.
|
|
53
|
+
"""
|
|
54
|
+
self._beads_dir: Path | None = None
|
|
55
|
+
self._explicit_dir = Path(beads_dir) if beads_dir else None
|
|
56
|
+
self._task_cache: dict[str, BeadsTask] = {}
|
|
57
|
+
self._cache_valid = False
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def beads_dir(self) -> Path | None:
|
|
61
|
+
"""Get the .beads/ directory path, discovering if needed."""
|
|
62
|
+
if self._beads_dir is None:
|
|
63
|
+
self._beads_dir = self._discover_beads_dir()
|
|
64
|
+
return self._beads_dir
|
|
65
|
+
|
|
66
|
+
def _discover_beads_dir(self) -> Path | None:
|
|
67
|
+
"""Discover the .beads/ directory.
|
|
68
|
+
|
|
69
|
+
Search order:
|
|
70
|
+
1. Explicit path provided to constructor
|
|
71
|
+
2. $BEADS_DIR environment variable
|
|
72
|
+
3. Walk up from current directory
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Path to .beads/ directory or None if not found.
|
|
76
|
+
"""
|
|
77
|
+
# 1. Explicit path
|
|
78
|
+
if self._explicit_dir:
|
|
79
|
+
if self._explicit_dir.is_dir():
|
|
80
|
+
logger.debug(f"Using explicit beads dir: {self._explicit_dir}")
|
|
81
|
+
return self._explicit_dir
|
|
82
|
+
logger.warning(f"Explicit beads dir not found: {self._explicit_dir}")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# 2. Environment variable
|
|
86
|
+
env_dir = os.environ.get("BEADS_DIR")
|
|
87
|
+
if env_dir:
|
|
88
|
+
env_path = Path(env_dir)
|
|
89
|
+
if env_path.is_dir():
|
|
90
|
+
logger.debug(f"Using $BEADS_DIR: {env_path}")
|
|
91
|
+
return env_path
|
|
92
|
+
logger.warning(f"$BEADS_DIR not found: {env_path}")
|
|
93
|
+
|
|
94
|
+
# 3. Walk up from current directory
|
|
95
|
+
current = Path.cwd()
|
|
96
|
+
for parent in [current, *current.parents]:
|
|
97
|
+
beads_path = parent / ".beads"
|
|
98
|
+
if beads_path.is_dir():
|
|
99
|
+
logger.debug(f"Found .beads/ at: {beads_path}")
|
|
100
|
+
return beads_path
|
|
101
|
+
|
|
102
|
+
logger.debug("No .beads/ directory found")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def is_available(self) -> bool:
|
|
106
|
+
"""Check if Beads is available (directory exists)."""
|
|
107
|
+
return self.beads_dir is not None
|
|
108
|
+
|
|
109
|
+
def invalidate_cache(self) -> None:
|
|
110
|
+
"""Invalidate the task cache, forcing a re-parse on next access."""
|
|
111
|
+
self._task_cache.clear()
|
|
112
|
+
self._cache_valid = False
|
|
113
|
+
|
|
114
|
+
def _ensure_cache(self) -> None:
|
|
115
|
+
"""Ensure the task cache is populated."""
|
|
116
|
+
if self._cache_valid:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self._task_cache.clear()
|
|
120
|
+
|
|
121
|
+
if not self.beads_dir:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Parse all JSONL files in .beads/
|
|
125
|
+
for task in self._parse_all_files():
|
|
126
|
+
self._task_cache[task.id] = task
|
|
127
|
+
|
|
128
|
+
self._cache_valid = True
|
|
129
|
+
logger.debug(f"Cached {len(self._task_cache)} tasks")
|
|
130
|
+
|
|
131
|
+
def _parse_all_files(self) -> Iterator[BeadsTask]:
|
|
132
|
+
"""Parse all JSONL files in the .beads/ directory.
|
|
133
|
+
|
|
134
|
+
Yields:
|
|
135
|
+
BeadsTask objects for each valid task entry.
|
|
136
|
+
"""
|
|
137
|
+
if not self.beads_dir:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Find all .jsonl files
|
|
141
|
+
jsonl_files = list(self.beads_dir.glob("*.jsonl"))
|
|
142
|
+
logger.debug(f"Found {len(jsonl_files)} JSONL files")
|
|
143
|
+
|
|
144
|
+
for jsonl_file in jsonl_files:
|
|
145
|
+
yield from self._parse_jsonl_file(jsonl_file)
|
|
146
|
+
|
|
147
|
+
def _parse_jsonl_file(self, file_path: Path) -> Iterator[BeadsTask]:
|
|
148
|
+
"""Parse a single JSONL file.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
file_path: Path to the JSONL file.
|
|
152
|
+
|
|
153
|
+
Yields:
|
|
154
|
+
BeadsTask objects for each valid line.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
with open(file_path, encoding="utf-8") as f:
|
|
158
|
+
for line_num, line in enumerate(f, 1):
|
|
159
|
+
line = line.strip()
|
|
160
|
+
if not line:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(line)
|
|
165
|
+
task = BeadsTask.from_dict(data)
|
|
166
|
+
yield task
|
|
167
|
+
except json.JSONDecodeError as e:
|
|
168
|
+
logger.warning(
|
|
169
|
+
f"Malformed JSON at {file_path}:{line_num}: {e}"
|
|
170
|
+
)
|
|
171
|
+
except (KeyError, ValueError) as e:
|
|
172
|
+
logger.warning(
|
|
173
|
+
f"Invalid task data at {file_path}:{line_num}: {e}"
|
|
174
|
+
)
|
|
175
|
+
except OSError as e:
|
|
176
|
+
logger.error(f"Failed to read {file_path}: {e}")
|
|
177
|
+
|
|
178
|
+
def get_task(self, task_id: str) -> BeadsTask | None:
|
|
179
|
+
"""Get a task by ID.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
task_id: The task ID (e.g., "bd-a3f8").
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The task or None if not found.
|
|
186
|
+
"""
|
|
187
|
+
self._ensure_cache()
|
|
188
|
+
return self._task_cache.get(task_id)
|
|
189
|
+
|
|
190
|
+
def list_tasks(
|
|
191
|
+
self,
|
|
192
|
+
status: BeadsTaskStatus | None = None,
|
|
193
|
+
parent_id: str | None = None,
|
|
194
|
+
) -> list[BeadsTask]:
|
|
195
|
+
"""List all tasks, optionally filtered.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
status: Filter by status (e.g., IN_PROGRESS).
|
|
199
|
+
parent_id: Filter by parent task (for subtasks).
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of matching tasks.
|
|
203
|
+
"""
|
|
204
|
+
self._ensure_cache()
|
|
205
|
+
|
|
206
|
+
tasks = list(self._task_cache.values())
|
|
207
|
+
|
|
208
|
+
if status is not None:
|
|
209
|
+
tasks = [t for t in tasks if t.status == status]
|
|
210
|
+
|
|
211
|
+
if parent_id is not None:
|
|
212
|
+
tasks = [t for t in tasks if t.parent_id == parent_id]
|
|
213
|
+
|
|
214
|
+
return tasks
|
|
215
|
+
|
|
216
|
+
def get_ready_tasks(self) -> list[BeadsTask]:
|
|
217
|
+
"""Get tasks that are ready to work on.
|
|
218
|
+
|
|
219
|
+
A task is ready if:
|
|
220
|
+
- Status is PENDING
|
|
221
|
+
- No unresolved dependencies
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
List of ready tasks.
|
|
225
|
+
"""
|
|
226
|
+
self._ensure_cache()
|
|
227
|
+
|
|
228
|
+
ready = []
|
|
229
|
+
for task in self._task_cache.values():
|
|
230
|
+
if task.status != BeadsTaskStatus.PENDING:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Check if all dependencies are done
|
|
234
|
+
deps_resolved = all(
|
|
235
|
+
self._task_cache.get(dep_id, BeadsTask(id=dep_id, title="")).status
|
|
236
|
+
== BeadsTaskStatus.DONE
|
|
237
|
+
for dep_id in task.dependencies
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if deps_resolved:
|
|
241
|
+
ready.append(task)
|
|
242
|
+
|
|
243
|
+
return ready
|
|
244
|
+
|
|
245
|
+
def get_in_progress_tasks(self) -> list[BeadsTask]:
|
|
246
|
+
"""Get tasks currently being worked on.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
List of in-progress tasks.
|
|
250
|
+
"""
|
|
251
|
+
return self.list_tasks(status=BeadsTaskStatus.IN_PROGRESS)
|
|
252
|
+
|
|
253
|
+
def get_subtasks(self, parent_id: str) -> list[BeadsTask]:
|
|
254
|
+
"""Get subtasks for a parent task.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
parent_id: The parent task ID.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
List of subtasks.
|
|
261
|
+
"""
|
|
262
|
+
return self.list_tasks(parent_id=parent_id)
|
|
263
|
+
|
|
264
|
+
def get_task_hierarchy(self, task_id: str) -> dict[str, BeadsTask | list]:
|
|
265
|
+
"""Get a task with its subtasks in a hierarchical structure.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
task_id: The task ID.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Dict with 'task' and 'subtasks' keys.
|
|
272
|
+
"""
|
|
273
|
+
task = self.get_task(task_id)
|
|
274
|
+
if not task:
|
|
275
|
+
return {"task": None, "subtasks": []}
|
|
276
|
+
|
|
277
|
+
subtasks = self.get_subtasks(task_id)
|
|
278
|
+
return {"task": task, "subtasks": subtasks}
|
|
279
|
+
|
|
280
|
+
def get_completed_tasks(self) -> list[BeadsTask]:
|
|
281
|
+
"""Get tasks that have been completed (done or cancelled).
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
List of completed tasks.
|
|
285
|
+
"""
|
|
286
|
+
self._ensure_cache()
|
|
287
|
+
return [t for t in self._task_cache.values() if t.is_completed]
|
|
288
|
+
|
|
289
|
+
def get_blocked_tasks(self) -> list[BeadsTask]:
|
|
290
|
+
"""Get tasks that are blocked.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of blocked tasks.
|
|
294
|
+
"""
|
|
295
|
+
return self.list_tasks(status=BeadsTaskStatus.BLOCKED)
|
|
296
|
+
|
|
297
|
+
def refresh(self) -> int:
|
|
298
|
+
"""Refresh the task cache from disk.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Number of tasks loaded.
|
|
302
|
+
"""
|
|
303
|
+
self.invalidate_cache()
|
|
304
|
+
self._ensure_cache()
|
|
305
|
+
return len(self._task_cache)
|