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,1936 @@
|
|
|
1
|
+
"""Runtime Memory CLI.
|
|
2
|
+
|
|
3
|
+
Command-line interface for interacting with the Runtime Memory.
|
|
4
|
+
Provides commands for memory management, context injection, and hook support.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
mem add <content> [-c category]
|
|
8
|
+
mem search <query> [--limit N]
|
|
9
|
+
mem context [--inject] [--format FMT]
|
|
10
|
+
mem outcome <id> worked|failed|partial
|
|
11
|
+
mem stats
|
|
12
|
+
mem serve --mcp|--rest
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from runtime_memory import __version__
|
|
27
|
+
from runtime_memory.core.logging import get_logger, setup_logging
|
|
28
|
+
from runtime_memory.core.models import MemoryCategory, MemoryScope, MemorySource, Outcome
|
|
29
|
+
from runtime_memory.core.paths import default_db_path, store_dir
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_DB_PATH = default_db_path()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_db_path() -> str:
|
|
38
|
+
"""Resolve the database path from the environment.
|
|
39
|
+
|
|
40
|
+
Accepts either RUNTIME_MEMORY_DB or the settings-style
|
|
41
|
+
RUNTIME_MEMORY_DATABASE__PATH, in that order, before falling back to the
|
|
42
|
+
default location. Both names are honoured so that isolating the database
|
|
43
|
+
for a test run works regardless of which one the caller picked up from the
|
|
44
|
+
docs.
|
|
45
|
+
"""
|
|
46
|
+
return os.environ.get(
|
|
47
|
+
"RUNTIME_MEMORY_DB",
|
|
48
|
+
os.environ.get("RUNTIME_MEMORY_DATABASE__PATH", str(DEFAULT_DB_PATH)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_engine():
|
|
53
|
+
"""Get or create an initialized MemoryEngine instance."""
|
|
54
|
+
from runtime_memory.core.engine import EngineConfig, MemoryEngine
|
|
55
|
+
|
|
56
|
+
db_path = resolve_db_path()
|
|
57
|
+
# Ensure directory exists
|
|
58
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
config = EngineConfig(db_path=db_path)
|
|
60
|
+
engine = MemoryEngine(config=config)
|
|
61
|
+
# Initialize the engine synchronously
|
|
62
|
+
asyncio.get_event_loop().run_until_complete(engine.initialize())
|
|
63
|
+
return engine
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_async(coro):
|
|
67
|
+
"""Run an async coroutine."""
|
|
68
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_error(error: Exception, verbose: bool = False) -> str:
|
|
72
|
+
"""Convert exception to user-friendly error message.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
error: The exception to format
|
|
76
|
+
verbose: If True, include technical details
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
User-friendly error message
|
|
80
|
+
"""
|
|
81
|
+
error_str = str(error)
|
|
82
|
+
|
|
83
|
+
# Handle Pydantic validation errors
|
|
84
|
+
if "validation error" in error_str.lower():
|
|
85
|
+
# Extract the key issue
|
|
86
|
+
if "content" in error_str.lower() and "at least 1 character" in error_str.lower():
|
|
87
|
+
return "Content cannot be empty."
|
|
88
|
+
if "content" in error_str.lower() and "string_too_short" in error_str.lower():
|
|
89
|
+
return "Content cannot be empty."
|
|
90
|
+
if "importance" in error_str.lower():
|
|
91
|
+
return "Importance must be a number between 0.0 and 1.0."
|
|
92
|
+
if "category" in error_str.lower():
|
|
93
|
+
return "Invalid category. Use: architecture, convention, decision, pattern, gotcha, workaround, troubleshooting, command, preference, general."
|
|
94
|
+
# Generic validation error
|
|
95
|
+
if verbose:
|
|
96
|
+
return error_str
|
|
97
|
+
return "Invalid input. Use -v flag for details."
|
|
98
|
+
|
|
99
|
+
# Handle common errors
|
|
100
|
+
if "No memory found" in error_str:
|
|
101
|
+
return error_str # Already user-friendly
|
|
102
|
+
if "not found" in error_str.lower():
|
|
103
|
+
return error_str
|
|
104
|
+
if "database" in error_str.lower() and "locked" in error_str.lower():
|
|
105
|
+
return "Database is locked. Another process may be using it."
|
|
106
|
+
if "permission denied" in error_str.lower():
|
|
107
|
+
return "Permission denied. Check file permissions."
|
|
108
|
+
|
|
109
|
+
# Default: return original message (but strip Pydantic URLs)
|
|
110
|
+
if "pydantic" in error_str.lower() and "https://" in error_str:
|
|
111
|
+
# Remove the URL part
|
|
112
|
+
lines = error_str.split('\n')
|
|
113
|
+
filtered = [l for l in lines if "https://errors.pydantic.dev" not in l]
|
|
114
|
+
return '\n'.join(filtered).strip()
|
|
115
|
+
|
|
116
|
+
return error_str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# =============================================================================
|
|
120
|
+
# Main CLI Group
|
|
121
|
+
# =============================================================================
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@click.group()
|
|
125
|
+
@click.version_option(version=__version__, prog_name="Runtime Memory")
|
|
126
|
+
@click.option("--verbose", "-v", count=True, help="Increase verbosity (-v for INFO, -vv for DEBUG)")
|
|
127
|
+
@click.option("--json-output", is_flag=True, help="Output in JSON format")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def cli(ctx: click.Context, verbose: int, json_output: bool) -> None:
|
|
130
|
+
"""Runtime Memory - Persistent memory for AI coding agents.
|
|
131
|
+
|
|
132
|
+
Store, search, and manage memories with outcome-based learning.
|
|
133
|
+
"""
|
|
134
|
+
ctx.ensure_object(dict)
|
|
135
|
+
ctx.obj["verbose"] = verbose
|
|
136
|
+
ctx.obj["json_output"] = json_output
|
|
137
|
+
|
|
138
|
+
# Default to WARNING (silent), -v for INFO, -vv for DEBUG
|
|
139
|
+
if verbose >= 2:
|
|
140
|
+
setup_logging(level="DEBUG")
|
|
141
|
+
elif verbose == 1:
|
|
142
|
+
setup_logging(level="INFO")
|
|
143
|
+
else:
|
|
144
|
+
setup_logging(level="WARNING")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# =============================================================================
|
|
148
|
+
# Core Commands
|
|
149
|
+
# =============================================================================
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@cli.command("add")
|
|
153
|
+
@click.argument("content")
|
|
154
|
+
@click.option(
|
|
155
|
+
"-c", "--category",
|
|
156
|
+
type=click.Choice([c.value for c in MemoryCategory], case_sensitive=False),
|
|
157
|
+
default="general",
|
|
158
|
+
help="Memory category"
|
|
159
|
+
)
|
|
160
|
+
@click.option("-p", "--project", default=None, help="Project name/path")
|
|
161
|
+
@click.option("--tags", default="", help="Comma-separated tags")
|
|
162
|
+
@click.option("--importance", type=float, default=0.5, help="Importance (0.0-1.0)")
|
|
163
|
+
@click.pass_context
|
|
164
|
+
def add_memory(
|
|
165
|
+
ctx: click.Context,
|
|
166
|
+
content: str,
|
|
167
|
+
category: str,
|
|
168
|
+
project: Optional[str],
|
|
169
|
+
tags: str,
|
|
170
|
+
importance: float,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Add a new memory.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
mem add "Use snake_case for Python variables" -c convention
|
|
176
|
+
"""
|
|
177
|
+
engine = get_engine()
|
|
178
|
+
|
|
179
|
+
# Parse tags
|
|
180
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
|
|
181
|
+
|
|
182
|
+
# Use current directory as project if not specified
|
|
183
|
+
if project is None:
|
|
184
|
+
project = Path.cwd().name
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
memory = run_async(engine.add(
|
|
188
|
+
content=content,
|
|
189
|
+
category=MemoryCategory(category),
|
|
190
|
+
project=project,
|
|
191
|
+
tags=tag_list,
|
|
192
|
+
importance=importance,
|
|
193
|
+
source=MemorySource.EXPLICIT,
|
|
194
|
+
))
|
|
195
|
+
|
|
196
|
+
if ctx.obj.get("json_output"):
|
|
197
|
+
click.echo(json.dumps(memory.to_dict(), indent=2, default=str))
|
|
198
|
+
else:
|
|
199
|
+
click.echo(f"Added memory [{memory.id[:8]}] ({category})")
|
|
200
|
+
click.echo(f" {content[:80]}{'...' if len(content) > 80 else ''}")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f"Failed to add memory: {e}")
|
|
203
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli.command("search")
|
|
207
|
+
@click.argument("query")
|
|
208
|
+
@click.option("-l", "--limit", default=5, help="Maximum results")
|
|
209
|
+
@click.option(
|
|
210
|
+
"-c", "--category",
|
|
211
|
+
type=click.Choice([c.value for c in MemoryCategory], case_sensitive=False),
|
|
212
|
+
default=None,
|
|
213
|
+
help="Filter by category"
|
|
214
|
+
)
|
|
215
|
+
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
216
|
+
@click.option("--min-score", type=float, default=-1.0, help="Minimum outcome score")
|
|
217
|
+
@click.option("--format", "output_format", default="brief", help="Output format (brief/detailed/context)")
|
|
218
|
+
@click.option("--full", is_flag=True, help="Show full content (no truncation)")
|
|
219
|
+
@click.pass_context
|
|
220
|
+
def search_memories(
|
|
221
|
+
ctx: click.Context,
|
|
222
|
+
query: str,
|
|
223
|
+
limit: int,
|
|
224
|
+
category: Optional[str],
|
|
225
|
+
project: Optional[str],
|
|
226
|
+
min_score: float,
|
|
227
|
+
output_format: str,
|
|
228
|
+
full: bool,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Search memories.
|
|
231
|
+
|
|
232
|
+
Example:
|
|
233
|
+
mem search "authentication" --limit 10
|
|
234
|
+
"""
|
|
235
|
+
engine = get_engine()
|
|
236
|
+
|
|
237
|
+
cat = MemoryCategory(category) if category else None
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
results = run_async(engine.search(
|
|
241
|
+
query=query,
|
|
242
|
+
limit=limit,
|
|
243
|
+
category=cat,
|
|
244
|
+
project=project,
|
|
245
|
+
min_score=min_score,
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
if ctx.obj.get("json_output"):
|
|
249
|
+
click.echo(json.dumps([r.to_dict() for r in results], indent=2, default=str))
|
|
250
|
+
elif output_format == "context":
|
|
251
|
+
# Format for context injection
|
|
252
|
+
from runtime_memory.plugin import ContextFormatter
|
|
253
|
+
memories = [r.memory for r in results]
|
|
254
|
+
click.echo(ContextFormatter.format_for_injection(memories, style="markdown"))
|
|
255
|
+
elif output_format == "detailed":
|
|
256
|
+
for r in results:
|
|
257
|
+
m = r.memory
|
|
258
|
+
project_tag = f"[{m.project}]" if m.project else "[global]"
|
|
259
|
+
click.echo(f"\n[{m.id[:8]}] {m.category.value.upper()} {project_tag} (score: {r.score:.2f})")
|
|
260
|
+
click.echo(f" {m.content}")
|
|
261
|
+
click.echo(f" Outcome: {m.outcome_score:.2f} | Used: {m.use_count}x")
|
|
262
|
+
else:
|
|
263
|
+
# Brief format
|
|
264
|
+
if not results:
|
|
265
|
+
click.echo("No memories found.")
|
|
266
|
+
for r in results:
|
|
267
|
+
m = r.memory
|
|
268
|
+
project_tag = f"[{m.project}]" if m.project else "[global]"
|
|
269
|
+
content = m.content if full else f"{m.content[:60]}..."
|
|
270
|
+
click.echo(f"[{m.id[:8]}] [{m.category.value}] {project_tag} {content}")
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.error(f"Search failed: {e}")
|
|
273
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@cli.command("show")
|
|
277
|
+
@click.argument("memory_id")
|
|
278
|
+
@click.pass_context
|
|
279
|
+
def show_memory(ctx: click.Context, memory_id: str) -> None:
|
|
280
|
+
"""Show details of a specific memory.
|
|
281
|
+
|
|
282
|
+
Supports partial ID matching.
|
|
283
|
+
|
|
284
|
+
Example:
|
|
285
|
+
mem show abc12345
|
|
286
|
+
"""
|
|
287
|
+
engine = get_engine()
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
# Support partial ID matching
|
|
291
|
+
full_id = memory_id
|
|
292
|
+
if len(memory_id) < 32:
|
|
293
|
+
memories = run_async(engine.list(limit=1000))
|
|
294
|
+
matches = [m for m in memories if m.id.startswith(memory_id)]
|
|
295
|
+
if len(matches) == 0:
|
|
296
|
+
raise click.ClickException(f"No memory found matching: {memory_id}")
|
|
297
|
+
elif len(matches) > 1:
|
|
298
|
+
msg = f"Ambiguous ID '{memory_id}' matches {len(matches)} memories:\n"
|
|
299
|
+
for m in matches:
|
|
300
|
+
msg += f" [{m.id[:16]}] {m.content[:40]}...\n"
|
|
301
|
+
msg += "Use more characters to disambiguate."
|
|
302
|
+
raise click.ClickException(msg)
|
|
303
|
+
full_id = matches[0].id
|
|
304
|
+
|
|
305
|
+
memory = run_async(engine.get(full_id))
|
|
306
|
+
if memory is None:
|
|
307
|
+
raise click.ClickException(f"Memory not found: {memory_id}")
|
|
308
|
+
|
|
309
|
+
if ctx.obj.get("json_output"):
|
|
310
|
+
click.echo(json.dumps(memory.to_dict(), indent=2, default=str))
|
|
311
|
+
else:
|
|
312
|
+
click.echo(f"ID: {memory.id}")
|
|
313
|
+
click.echo(f"Category: {memory.category.value}")
|
|
314
|
+
click.echo(f"Content: {memory.content}")
|
|
315
|
+
click.echo(f"Outcome Score: {memory.outcome_score:.2f}")
|
|
316
|
+
click.echo(f"Use Count: {memory.use_count}")
|
|
317
|
+
click.echo(f"Confidence: {memory.confidence:.2f}")
|
|
318
|
+
click.echo(f"Project: {memory.project or 'global'}")
|
|
319
|
+
click.echo(f"Tags: {', '.join(memory.tags) if memory.tags else 'none'}")
|
|
320
|
+
click.echo(f"Created: {memory.created_at}")
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.error(f"Failed to show memory: {e}")
|
|
323
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@cli.command("list")
|
|
327
|
+
@click.option("-l", "--limit", default=20, help="Maximum results")
|
|
328
|
+
@click.option(
|
|
329
|
+
"-c", "--category",
|
|
330
|
+
type=click.Choice([c.value for c in MemoryCategory], case_sensitive=False),
|
|
331
|
+
default=None,
|
|
332
|
+
help="Filter by category"
|
|
333
|
+
)
|
|
334
|
+
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
335
|
+
@click.option("--archived", is_flag=True, help="Include archived memories")
|
|
336
|
+
@click.option("--full", is_flag=True, help="Show full content (no truncation)")
|
|
337
|
+
@click.pass_context
|
|
338
|
+
def list_memories(
|
|
339
|
+
ctx: click.Context,
|
|
340
|
+
limit: int,
|
|
341
|
+
category: Optional[str],
|
|
342
|
+
project: Optional[str],
|
|
343
|
+
archived: bool,
|
|
344
|
+
full: bool,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""List memories with optional filters.
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
mem list -c convention --limit 10
|
|
350
|
+
"""
|
|
351
|
+
engine = get_engine()
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
memories = run_async(engine.list(
|
|
355
|
+
limit=limit,
|
|
356
|
+
category=MemoryCategory(category) if category else None,
|
|
357
|
+
project=project,
|
|
358
|
+
include_archived=archived,
|
|
359
|
+
))
|
|
360
|
+
|
|
361
|
+
if ctx.obj.get("json_output"):
|
|
362
|
+
click.echo(json.dumps([m.to_dict() for m in memories], indent=2, default=str))
|
|
363
|
+
else:
|
|
364
|
+
if not memories:
|
|
365
|
+
click.echo("No memories found.")
|
|
366
|
+
for m in memories:
|
|
367
|
+
score_indicator = ""
|
|
368
|
+
if m.outcome_score > 0.3:
|
|
369
|
+
score_indicator = " [+]"
|
|
370
|
+
elif m.outcome_score < -0.2:
|
|
371
|
+
score_indicator = " [-]"
|
|
372
|
+
project_tag = f"[{m.project}]" if m.project else "[global]"
|
|
373
|
+
content = m.content if full else f"{m.content[:50]}..."
|
|
374
|
+
click.echo(f"[{m.id[:8]}] [{m.category.value}] {project_tag} {content}{score_indicator}")
|
|
375
|
+
except Exception as e:
|
|
376
|
+
logger.error(f"Failed to list memories: {e}")
|
|
377
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@cli.command("delete")
|
|
381
|
+
@click.argument("memory_id")
|
|
382
|
+
@click.option("--confirm", is_flag=True, help="Skip confirmation")
|
|
383
|
+
@click.pass_context
|
|
384
|
+
def delete_memory(ctx: click.Context, memory_id: str, confirm: bool) -> None:
|
|
385
|
+
"""Archive (soft delete) a memory.
|
|
386
|
+
|
|
387
|
+
Supports partial ID matching.
|
|
388
|
+
|
|
389
|
+
Example:
|
|
390
|
+
mem delete abc12345 --confirm
|
|
391
|
+
"""
|
|
392
|
+
engine = get_engine()
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
# Support partial ID matching
|
|
396
|
+
full_id = memory_id
|
|
397
|
+
if len(memory_id) < 32:
|
|
398
|
+
memories = run_async(engine.list(limit=1000))
|
|
399
|
+
matches = [m for m in memories if m.id.startswith(memory_id)]
|
|
400
|
+
if len(matches) == 0:
|
|
401
|
+
raise click.ClickException(f"No memory found matching: {memory_id}")
|
|
402
|
+
elif len(matches) > 1:
|
|
403
|
+
msg = f"Ambiguous ID '{memory_id}' matches {len(matches)} memories:\n"
|
|
404
|
+
for m in matches:
|
|
405
|
+
msg += f" [{m.id[:16]}] {m.content[:40]}...\n"
|
|
406
|
+
msg += "Use more characters to disambiguate."
|
|
407
|
+
raise click.ClickException(msg)
|
|
408
|
+
full_id = matches[0].id
|
|
409
|
+
|
|
410
|
+
if not confirm:
|
|
411
|
+
if not click.confirm(f"Archive memory {memory_id}?"):
|
|
412
|
+
click.echo("Cancelled.")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
run_async(engine.delete(full_id))
|
|
416
|
+
click.echo(f"Archived memory {memory_id}")
|
|
417
|
+
except click.ClickException:
|
|
418
|
+
raise
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.error(f"Failed to delete memory: {e}")
|
|
421
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@cli.command("outcome")
|
|
425
|
+
@click.argument("memory_id")
|
|
426
|
+
@click.argument("result", type=click.Choice(["worked", "failed", "partial"]))
|
|
427
|
+
@click.pass_context
|
|
428
|
+
def record_outcome(ctx: click.Context, memory_id: str, result: str) -> None:
|
|
429
|
+
"""Record outcome feedback for a memory.
|
|
430
|
+
|
|
431
|
+
Supports partial ID matching (first 8 chars shown by other commands).
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
mem outcome abc12345 worked
|
|
435
|
+
"""
|
|
436
|
+
engine = get_engine()
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
# Support partial ID matching
|
|
440
|
+
full_id = memory_id
|
|
441
|
+
if len(memory_id) < 32: # Partial ID provided
|
|
442
|
+
memories = run_async(engine.list(limit=1000))
|
|
443
|
+
matches = [m for m in memories if m.id.startswith(memory_id)]
|
|
444
|
+
if len(matches) == 0:
|
|
445
|
+
raise click.ClickException(f"No memory found matching: {memory_id}")
|
|
446
|
+
elif len(matches) > 1:
|
|
447
|
+
msg = f"Ambiguous ID '{memory_id}' matches {len(matches)} memories:\n"
|
|
448
|
+
for m in matches:
|
|
449
|
+
msg += f" [{m.id[:16]}] {m.content[:40]}...\n"
|
|
450
|
+
msg += "Use more characters to disambiguate."
|
|
451
|
+
raise click.ClickException(msg)
|
|
452
|
+
full_id = matches[0].id
|
|
453
|
+
|
|
454
|
+
success = run_async(engine.record_outcome(
|
|
455
|
+
memory_ids=[full_id],
|
|
456
|
+
outcome=Outcome(result),
|
|
457
|
+
))
|
|
458
|
+
if success:
|
|
459
|
+
adjustment = {
|
|
460
|
+
"worked": "+0.2",
|
|
461
|
+
"failed": "-0.3",
|
|
462
|
+
"partial": "+0.05",
|
|
463
|
+
}[result]
|
|
464
|
+
click.echo(f"Recorded '{result}' for {memory_id} ({adjustment})")
|
|
465
|
+
else:
|
|
466
|
+
raise click.ClickException(f"Memory not found: {memory_id}")
|
|
467
|
+
except click.ClickException:
|
|
468
|
+
raise
|
|
469
|
+
except Exception as e:
|
|
470
|
+
logger.error(f"Failed to record outcome: {e}")
|
|
471
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# =============================================================================
|
|
475
|
+
# Context Commands (for hooks)
|
|
476
|
+
# =============================================================================
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@cli.command("context")
|
|
480
|
+
@click.option("-p", "--project", default=None, help="Project path")
|
|
481
|
+
@click.option("--inject", is_flag=True, help="Format for injection (used by hooks)")
|
|
482
|
+
@click.option("-l", "--limit", default=10, help="Maximum memories")
|
|
483
|
+
@click.option(
|
|
484
|
+
"--format", "output_format",
|
|
485
|
+
type=click.Choice(["brief", "detailed", "structured", "markdown", "silent", "json"]),
|
|
486
|
+
default="brief",
|
|
487
|
+
help="Output format"
|
|
488
|
+
)
|
|
489
|
+
@click.pass_context
|
|
490
|
+
def get_context(
|
|
491
|
+
ctx: click.Context,
|
|
492
|
+
project: Optional[str],
|
|
493
|
+
inject: bool,
|
|
494
|
+
limit: int,
|
|
495
|
+
output_format: str,
|
|
496
|
+
) -> None:
|
|
497
|
+
"""Get project memory context.
|
|
498
|
+
|
|
499
|
+
Used by SessionStart hook to inject context.
|
|
500
|
+
|
|
501
|
+
Example:
|
|
502
|
+
mem context --project /path/to/project --inject --limit 10
|
|
503
|
+
"""
|
|
504
|
+
engine = get_engine()
|
|
505
|
+
|
|
506
|
+
# Use current directory as project if not specified
|
|
507
|
+
if project is None:
|
|
508
|
+
project = os.environ.get("PWD", str(Path.cwd()))
|
|
509
|
+
|
|
510
|
+
# Extract project name from path
|
|
511
|
+
project_name = Path(project).name
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
context_response = run_async(engine.get_context(
|
|
515
|
+
project=project_name,
|
|
516
|
+
max_memories=limit,
|
|
517
|
+
))
|
|
518
|
+
|
|
519
|
+
if output_format == "silent":
|
|
520
|
+
# For hook usage - inject into context without output
|
|
521
|
+
if context_response.memories:
|
|
522
|
+
from runtime_memory.plugin import ContextFormatter
|
|
523
|
+
# Write to a context file that Claude Code can read
|
|
524
|
+
context_dir = store_dir() / "context"
|
|
525
|
+
context_dir.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
context_file = context_dir / f"{project_name}.context"
|
|
527
|
+
formatted = ContextFormatter.format_for_injection(
|
|
528
|
+
context_response.memories,
|
|
529
|
+
style="markdown"
|
|
530
|
+
)
|
|
531
|
+
context_file.write_text(formatted)
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
if output_format == "json" or ctx.obj.get("json_output"):
|
|
535
|
+
click.echo(json.dumps(context_response.to_dict(), indent=2, default=str))
|
|
536
|
+
elif inject:
|
|
537
|
+
from runtime_memory.plugin import ContextFormatter
|
|
538
|
+
click.echo(ContextFormatter.format_for_injection(
|
|
539
|
+
context_response.memories,
|
|
540
|
+
style="markdown"
|
|
541
|
+
))
|
|
542
|
+
else:
|
|
543
|
+
from runtime_memory.plugin import ContextFormatter
|
|
544
|
+
click.echo(ContextFormatter.format_for_injection(
|
|
545
|
+
context_response.memories,
|
|
546
|
+
style=output_format
|
|
547
|
+
))
|
|
548
|
+
except Exception as e:
|
|
549
|
+
logger.error(f"Failed to get context: {e}")
|
|
550
|
+
if output_format != "silent":
|
|
551
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@cli.command("extract")
|
|
555
|
+
@click.option("--auto", "auto_extract", is_flag=True, help="Auto-extract from context")
|
|
556
|
+
@click.option("--session", "session_id", default=None, help="Claude session ID")
|
|
557
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
|
|
558
|
+
@click.option("--from-context", is_flag=True, help="Read from current context")
|
|
559
|
+
@click.option("--stdin", "from_stdin", is_flag=True, help="Read transcript from stdin")
|
|
560
|
+
@click.pass_context
|
|
561
|
+
def extract_memories(
|
|
562
|
+
ctx: click.Context,
|
|
563
|
+
auto_extract: bool,
|
|
564
|
+
session_id: Optional[str],
|
|
565
|
+
quiet: bool,
|
|
566
|
+
from_context: bool,
|
|
567
|
+
from_stdin: bool,
|
|
568
|
+
) -> None:
|
|
569
|
+
"""Extract memories from context/transcript.
|
|
570
|
+
|
|
571
|
+
Used by PreCompact hook to extract learnings before compaction.
|
|
572
|
+
|
|
573
|
+
Examples:
|
|
574
|
+
mem extract --auto --session $CLAUDE_SESSION_ID --quiet
|
|
575
|
+
cat transcript.txt | mem extract --stdin -p myproject
|
|
576
|
+
echo "We decided to use PostgreSQL for the database" | mem extract --stdin
|
|
577
|
+
"""
|
|
578
|
+
# Get session ID from environment if not provided
|
|
579
|
+
if session_id is None:
|
|
580
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
581
|
+
|
|
582
|
+
# Check for stdin input
|
|
583
|
+
stdin_content = None
|
|
584
|
+
if from_stdin or (not sys.stdin.isatty()):
|
|
585
|
+
try:
|
|
586
|
+
# Read from stdin if available and not a TTY
|
|
587
|
+
if not sys.stdin.isatty():
|
|
588
|
+
stdin_content = sys.stdin.read().strip()
|
|
589
|
+
except Exception:
|
|
590
|
+
pass
|
|
591
|
+
|
|
592
|
+
if stdin_content:
|
|
593
|
+
# We have content from stdin - could extract from it
|
|
594
|
+
if not quiet:
|
|
595
|
+
click.echo(f"Received {len(stdin_content)} characters from stdin")
|
|
596
|
+
click.echo(f"Session: {session_id or 'unknown'}")
|
|
597
|
+
|
|
598
|
+
# TODO: Use the extractor module to extract memories from stdin_content
|
|
599
|
+
# For now, just acknowledge receipt
|
|
600
|
+
if not quiet:
|
|
601
|
+
click.echo("Note: Extraction from stdin content is pending extractor integration.")
|
|
602
|
+
else:
|
|
603
|
+
# No stdin content - standard extraction trigger
|
|
604
|
+
if not quiet:
|
|
605
|
+
click.echo(f"Extraction triggered for session: {session_id or 'unknown'}")
|
|
606
|
+
click.echo("Note: Auto-extraction requires conversation context access.")
|
|
607
|
+
|
|
608
|
+
# TODO: Implement actual extraction when Claude Code provides transcript access
|
|
609
|
+
# This would use the extractor module to extract memories from the transcript
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# =============================================================================
|
|
613
|
+
# Session Commands (for hooks)
|
|
614
|
+
# =============================================================================
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@cli.group("session")
|
|
618
|
+
def session_group() -> None:
|
|
619
|
+
"""Session management commands."""
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@session_group.command("end")
|
|
624
|
+
@click.option("--session", "session_id", default=None, help="Claude session ID")
|
|
625
|
+
@click.option("--summarize", is_flag=True, help="Generate session summary")
|
|
626
|
+
@click.pass_context
|
|
627
|
+
def end_session(
|
|
628
|
+
ctx: click.Context,
|
|
629
|
+
session_id: Optional[str],
|
|
630
|
+
summarize: bool,
|
|
631
|
+
) -> None:
|
|
632
|
+
"""End a memory session.
|
|
633
|
+
|
|
634
|
+
Used by SessionEnd hook.
|
|
635
|
+
|
|
636
|
+
Example:
|
|
637
|
+
mem session end --session $CLAUDE_SESSION_ID --summarize
|
|
638
|
+
"""
|
|
639
|
+
from runtime_memory.plugin import SessionManager
|
|
640
|
+
|
|
641
|
+
# Get session ID from environment if not provided
|
|
642
|
+
if session_id is None:
|
|
643
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
644
|
+
|
|
645
|
+
session_manager = SessionManager()
|
|
646
|
+
session_manager._current_session = session_id
|
|
647
|
+
|
|
648
|
+
summary = session_manager.end_session(summarize=summarize)
|
|
649
|
+
|
|
650
|
+
if summary and not ctx.obj.get("json_output"):
|
|
651
|
+
click.echo(f"Session ended: {summary.get('session_id', 'unknown')}")
|
|
652
|
+
click.echo(f" Memories used: {summary.get('memories_used', 0)}")
|
|
653
|
+
elif summary:
|
|
654
|
+
click.echo(json.dumps(summary, indent=2, default=str))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@session_group.command("start")
|
|
658
|
+
@click.option("--session", "session_id", default=None, help="Claude session ID")
|
|
659
|
+
@click.pass_context
|
|
660
|
+
def start_session(ctx: click.Context, session_id: Optional[str]) -> None:
|
|
661
|
+
"""Start a memory session.
|
|
662
|
+
|
|
663
|
+
Example:
|
|
664
|
+
mem session start --session $CLAUDE_SESSION_ID
|
|
665
|
+
"""
|
|
666
|
+
from runtime_memory.plugin import SessionManager
|
|
667
|
+
|
|
668
|
+
if session_id is None:
|
|
669
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
670
|
+
|
|
671
|
+
session_manager = SessionManager()
|
|
672
|
+
active_id = session_manager.start_session(session_id)
|
|
673
|
+
|
|
674
|
+
if ctx.obj.get("json_output"):
|
|
675
|
+
click.echo(json.dumps({"session_id": active_id}))
|
|
676
|
+
else:
|
|
677
|
+
click.echo(f"Session started: {active_id}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# =============================================================================
|
|
681
|
+
# File Tracking (for PostToolUse hook)
|
|
682
|
+
# =============================================================================
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@cli.command("track-file")
|
|
686
|
+
@click.argument("file_path")
|
|
687
|
+
@click.option("--session", "session_id", default=None, help="Claude session ID")
|
|
688
|
+
@click.pass_context
|
|
689
|
+
def track_file(
|
|
690
|
+
ctx: click.Context,
|
|
691
|
+
file_path: str,
|
|
692
|
+
session_id: Optional[str],
|
|
693
|
+
) -> None:
|
|
694
|
+
"""Track a modified file.
|
|
695
|
+
|
|
696
|
+
Used by PostToolUse hook for Write/Edit operations.
|
|
697
|
+
Silently succeeds/fails for hook usage.
|
|
698
|
+
|
|
699
|
+
Example:
|
|
700
|
+
mem track-file /path/to/file.py --session $CLAUDE_SESSION_ID
|
|
701
|
+
"""
|
|
702
|
+
# Get session ID from environment if not provided
|
|
703
|
+
if session_id is None:
|
|
704
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
705
|
+
|
|
706
|
+
# TODO: Implement file tracking
|
|
707
|
+
# This would record which files were modified during the session
|
|
708
|
+
# for context-aware memory retrieval
|
|
709
|
+
pass # Silent success for hook usage
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# =============================================================================
|
|
713
|
+
# Statistics
|
|
714
|
+
# =============================================================================
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@cli.command("stats")
|
|
718
|
+
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
719
|
+
@click.pass_context
|
|
720
|
+
def show_stats(ctx: click.Context, project: Optional[str]) -> None:
|
|
721
|
+
"""Show memory statistics.
|
|
722
|
+
|
|
723
|
+
Example:
|
|
724
|
+
mem stats
|
|
725
|
+
"""
|
|
726
|
+
engine = get_engine()
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
stats = run_async(engine.stats(project=project))
|
|
730
|
+
storage = stats.storage_stats
|
|
731
|
+
|
|
732
|
+
if ctx.obj.get("json_output"):
|
|
733
|
+
from dataclasses import asdict
|
|
734
|
+
click.echo(json.dumps(asdict(stats), indent=2, default=str))
|
|
735
|
+
else:
|
|
736
|
+
click.echo("Runtime Memory Statistics")
|
|
737
|
+
click.echo("=" * 40)
|
|
738
|
+
click.echo(f"Total memories: {storage.total_memories}")
|
|
739
|
+
click.echo(f"Active: {storage.active_memories}")
|
|
740
|
+
click.echo(f"Archived: {storage.archived_memories}")
|
|
741
|
+
click.echo(f"Average outcome score: {storage.avg_outcome_score:.2f}")
|
|
742
|
+
click.echo(f"Total uses: {storage.total_uses}")
|
|
743
|
+
click.echo(f"Indexed in retriever: {stats.indexed_memories}")
|
|
744
|
+
click.echo()
|
|
745
|
+
if storage.by_category:
|
|
746
|
+
click.echo("By Category:")
|
|
747
|
+
for cat, count in storage.by_category.items():
|
|
748
|
+
click.echo(f" {cat}: {count}")
|
|
749
|
+
except Exception as e:
|
|
750
|
+
logger.error(f"Failed to get stats: {e}")
|
|
751
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@cli.command("check")
|
|
755
|
+
@click.option("--fix", is_flag=True, help="Attempt to fix issues")
|
|
756
|
+
@click.pass_context
|
|
757
|
+
def check_health(ctx: click.Context, fix: bool) -> None:
|
|
758
|
+
"""Check Runtime Memory health and configuration.
|
|
759
|
+
|
|
760
|
+
Verifies database, embeddings, and configuration are working correctly.
|
|
761
|
+
|
|
762
|
+
Example:
|
|
763
|
+
mem check
|
|
764
|
+
mem check --fix
|
|
765
|
+
"""
|
|
766
|
+
issues = []
|
|
767
|
+
fixes_applied = []
|
|
768
|
+
|
|
769
|
+
click.echo("Runtime Memory Health Check")
|
|
770
|
+
click.echo("=" * 40)
|
|
771
|
+
|
|
772
|
+
# Check 1: Database location and access
|
|
773
|
+
db_path = resolve_db_path()
|
|
774
|
+
db_exists = Path(db_path).exists()
|
|
775
|
+
db_dir_exists = Path(db_path).parent.exists()
|
|
776
|
+
|
|
777
|
+
if db_dir_exists:
|
|
778
|
+
click.echo(f"[OK] Database directory: {Path(db_path).parent}")
|
|
779
|
+
else:
|
|
780
|
+
if fix:
|
|
781
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
782
|
+
fixes_applied.append("Created database directory")
|
|
783
|
+
click.echo(f"[FIXED] Created database directory: {Path(db_path).parent}")
|
|
784
|
+
else:
|
|
785
|
+
issues.append(f"Database directory missing: {Path(db_path).parent}")
|
|
786
|
+
click.echo(f"[ISSUE] Database directory missing: {Path(db_path).parent}")
|
|
787
|
+
|
|
788
|
+
if db_exists:
|
|
789
|
+
click.echo(f"[OK] Database file: {db_path}")
|
|
790
|
+
else:
|
|
791
|
+
click.echo(f"[INFO] Database file will be created on first use: {db_path}")
|
|
792
|
+
|
|
793
|
+
# Check 2: Engine initialization
|
|
794
|
+
try:
|
|
795
|
+
engine = get_engine()
|
|
796
|
+
click.echo("[OK] Engine initialization")
|
|
797
|
+
|
|
798
|
+
# Check 3: Memory count
|
|
799
|
+
stats = run_async(engine.stats())
|
|
800
|
+
total = stats.storage_stats.total_memories
|
|
801
|
+
click.echo(f"[OK] Memory count: {total} memories")
|
|
802
|
+
|
|
803
|
+
# Check 4: Retriever
|
|
804
|
+
if hasattr(engine, '_retriever') and engine._retriever:
|
|
805
|
+
click.echo("[OK] Retriever initialized")
|
|
806
|
+
else:
|
|
807
|
+
click.echo("[INFO] Retriever not yet initialized (will init on first search)")
|
|
808
|
+
|
|
809
|
+
# Check 5: Embedding backend. Not an issue when absent, since keyword
|
|
810
|
+
# retrieval needs no extra packages, but silence here would leave a
|
|
811
|
+
# base install wondering why search behaves the way it does.
|
|
812
|
+
provider = engine.embedding_provider
|
|
813
|
+
if provider.available:
|
|
814
|
+
click.echo(f"[OK] Embedding backend: {provider.model_name}")
|
|
815
|
+
else:
|
|
816
|
+
click.echo(
|
|
817
|
+
"[INFO] No embedding backend, so search uses keyword matching only. "
|
|
818
|
+
"For semantic search: pip install 'runtime-memory[embedding]'"
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
except Exception as e:
|
|
822
|
+
issues.append(f"Engine initialization failed: {e}")
|
|
823
|
+
click.echo(f"[ISSUE] Engine initialization failed: {e}")
|
|
824
|
+
|
|
825
|
+
# Check 6: Environment variables
|
|
826
|
+
click.echo()
|
|
827
|
+
click.echo("Configuration:")
|
|
828
|
+
click.echo(f" RUNTIME_MEMORY_DB: {os.environ.get('RUNTIME_MEMORY_DB', '(default)')}")
|
|
829
|
+
click.echo(f" ANTHROPIC_API_KEY: {'set' if os.environ.get('ANTHROPIC_API_KEY') else 'not set'}")
|
|
830
|
+
|
|
831
|
+
# Summary
|
|
832
|
+
click.echo()
|
|
833
|
+
click.echo("-" * 40)
|
|
834
|
+
if issues:
|
|
835
|
+
click.echo(f"Issues found: {len(issues)}")
|
|
836
|
+
for issue in issues:
|
|
837
|
+
click.echo(f" - {issue}")
|
|
838
|
+
if not fix:
|
|
839
|
+
click.echo("\nRun 'mem check --fix' to attempt automatic fixes.")
|
|
840
|
+
else:
|
|
841
|
+
click.echo("All checks passed!")
|
|
842
|
+
|
|
843
|
+
if fixes_applied:
|
|
844
|
+
click.echo(f"\nFixes applied: {len(fixes_applied)}")
|
|
845
|
+
for f in fixes_applied:
|
|
846
|
+
click.echo(f" - {f}")
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
# =============================================================================
|
|
850
|
+
# Server Commands
|
|
851
|
+
# =============================================================================
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@cli.command("serve")
|
|
855
|
+
@click.option("--mcp", "serve_mcp", is_flag=True, help="Start MCP server")
|
|
856
|
+
@click.option("--rest", "serve_rest", is_flag=True, help="Start REST API server")
|
|
857
|
+
@click.option("--port", default=8080, help="Port for REST server")
|
|
858
|
+
@click.option("--host", default="127.0.0.1", help="Host for REST server")
|
|
859
|
+
@click.pass_context
|
|
860
|
+
def serve(
|
|
861
|
+
ctx: click.Context,
|
|
862
|
+
serve_mcp: bool,
|
|
863
|
+
serve_rest: bool,
|
|
864
|
+
port: int,
|
|
865
|
+
host: str,
|
|
866
|
+
) -> None:
|
|
867
|
+
"""Start a memory server.
|
|
868
|
+
|
|
869
|
+
Example:
|
|
870
|
+
mem serve --mcp
|
|
871
|
+
mem serve --rest --port 8080
|
|
872
|
+
"""
|
|
873
|
+
if not serve_mcp and not serve_rest:
|
|
874
|
+
raise click.ClickException("Specify --mcp or --rest")
|
|
875
|
+
|
|
876
|
+
if serve_mcp:
|
|
877
|
+
click.echo("Starting MCP server on stdio...", err=True)
|
|
878
|
+
from runtime_memory.server import run_mcp_server
|
|
879
|
+
asyncio.run(run_mcp_server())
|
|
880
|
+
elif serve_rest:
|
|
881
|
+
click.echo(f"Starting REST server on {host}:{port}...", err=True)
|
|
882
|
+
from runtime_memory.server import APIConfig, run_server
|
|
883
|
+
|
|
884
|
+
# Get API key from environment (optional)
|
|
885
|
+
api_key = os.environ.get("RUNTIME_MEMORY_API_KEY")
|
|
886
|
+
|
|
887
|
+
# Get engine
|
|
888
|
+
engine = get_engine()
|
|
889
|
+
|
|
890
|
+
# Create config
|
|
891
|
+
config = APIConfig(api_key=api_key)
|
|
892
|
+
|
|
893
|
+
# Run the server
|
|
894
|
+
run_server(config=config, engine=engine, host=host, port=port)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# =============================================================================
|
|
898
|
+
# Utility Commands
|
|
899
|
+
# =============================================================================
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@cli.command("ingest")
|
|
903
|
+
@click.argument("file_path", type=click.Path(exists=True))
|
|
904
|
+
@click.option("-p", "--project", default=None, help="Project name")
|
|
905
|
+
@click.pass_context
|
|
906
|
+
def ingest_file(ctx: click.Context, file_path: str, project: Optional[str]) -> None:
|
|
907
|
+
"""Ingest memories from a file.
|
|
908
|
+
|
|
909
|
+
Example:
|
|
910
|
+
mem ingest transcript.txt -p myproject
|
|
911
|
+
"""
|
|
912
|
+
click.echo(f"Ingesting from {file_path}...")
|
|
913
|
+
# TODO: Implement file ingestion using extractor
|
|
914
|
+
click.echo("File ingestion not yet implemented")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@cli.command("export")
|
|
918
|
+
@click.argument("output_file", type=click.Path())
|
|
919
|
+
@click.option(
|
|
920
|
+
"--format", "output_format",
|
|
921
|
+
type=click.Choice(["json", "md", "markdown"]),
|
|
922
|
+
default="json",
|
|
923
|
+
help="Export format"
|
|
924
|
+
)
|
|
925
|
+
@click.option("-p", "--project", default=None, help="Filter by project")
|
|
926
|
+
@click.pass_context
|
|
927
|
+
def export_memories(
|
|
928
|
+
ctx: click.Context,
|
|
929
|
+
output_file: str,
|
|
930
|
+
output_format: str,
|
|
931
|
+
project: Optional[str],
|
|
932
|
+
) -> None:
|
|
933
|
+
"""Export memories to a file.
|
|
934
|
+
|
|
935
|
+
Example:
|
|
936
|
+
mem export memories.json --format json
|
|
937
|
+
"""
|
|
938
|
+
engine = get_engine()
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
memories = run_async(engine.list(limit=1000, project=project))
|
|
942
|
+
|
|
943
|
+
if output_format == "json":
|
|
944
|
+
data = [m.to_dict() for m in memories]
|
|
945
|
+
with open(output_file, "w") as f:
|
|
946
|
+
json.dump(data, f, indent=2, default=str)
|
|
947
|
+
else:
|
|
948
|
+
# Markdown format
|
|
949
|
+
from runtime_memory.plugin import ContextFormatter
|
|
950
|
+
formatted = ContextFormatter.format_for_injection(memories, style="markdown")
|
|
951
|
+
with open(output_file, "w") as f:
|
|
952
|
+
f.write(formatted)
|
|
953
|
+
|
|
954
|
+
click.echo(f"Exported {len(memories)} memories to {output_file}")
|
|
955
|
+
except Exception as e:
|
|
956
|
+
logger.error(f"Failed to export: {e}")
|
|
957
|
+
raise click.ClickException(format_error(e, ctx.obj.get("verbose", 0) > 0))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
# =============================================================================
|
|
961
|
+
# Plugin Installation
|
|
962
|
+
# =============================================================================
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
SETTINGS_JSON = """{
|
|
966
|
+
"enableAllProjectMcpServers": true,
|
|
967
|
+
"enabledMcpjsonServers": [
|
|
968
|
+
"runtime-memory"
|
|
969
|
+
],
|
|
970
|
+
"hooks": {
|
|
971
|
+
"SessionStart": [
|
|
972
|
+
{
|
|
973
|
+
"hooks": [
|
|
974
|
+
{
|
|
975
|
+
"type": "command",
|
|
976
|
+
"command": "mem context --project \\"$PWD\\" --limit 10 --format brief"
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
}
|
|
980
|
+
],
|
|
981
|
+
"SessionEnd": [
|
|
982
|
+
{
|
|
983
|
+
"hooks": [
|
|
984
|
+
{
|
|
985
|
+
"type": "command",
|
|
986
|
+
"command": "mem session end --summarize 2>/dev/null || true"
|
|
987
|
+
}
|
|
988
|
+
]
|
|
989
|
+
}
|
|
990
|
+
],
|
|
991
|
+
"PostToolUse": [
|
|
992
|
+
{
|
|
993
|
+
"matcher": "Write|Edit",
|
|
994
|
+
"hooks": [
|
|
995
|
+
{
|
|
996
|
+
"type": "command",
|
|
997
|
+
"command": "mem track-file \\"$TOOL_INPUT_FILE_PATH\\" 2>/dev/null || true"
|
|
998
|
+
}
|
|
999
|
+
]
|
|
1000
|
+
}
|
|
1001
|
+
]
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
"""
|
|
1005
|
+
|
|
1006
|
+
PLUGIN_JSON = """{
|
|
1007
|
+
"name": "runtime-memory",
|
|
1008
|
+
"description": "Persistent memory with outcome-based learning for AI coding agents.",
|
|
1009
|
+
"version": "__VERSION__",
|
|
1010
|
+
"author": "runtimenoteslabs",
|
|
1011
|
+
"license": "MIT",
|
|
1012
|
+
"capabilities": {
|
|
1013
|
+
"hooks": true,
|
|
1014
|
+
"commands": true,
|
|
1015
|
+
"skills": true,
|
|
1016
|
+
"mcp": true
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
"""
|
|
1020
|
+
|
|
1021
|
+
MCP_JSON = """{
|
|
1022
|
+
"mcpServers": {
|
|
1023
|
+
"runtime-memory": {
|
|
1024
|
+
"command": "mem",
|
|
1025
|
+
"args": ["serve", "--mcp"],
|
|
1026
|
+
"env": {
|
|
1027
|
+
"RUNTIME_MEMORY_DB": "~/.runtime-memory/memories.db"
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
"""
|
|
1033
|
+
|
|
1034
|
+
REMEMBER_CMD = """---
|
|
1035
|
+
description: Store a memory for future reference
|
|
1036
|
+
allowed-tools: Bash
|
|
1037
|
+
---
|
|
1038
|
+
|
|
1039
|
+
Store information in Runtime Memory for future sessions.
|
|
1040
|
+
|
|
1041
|
+
## Usage
|
|
1042
|
+
|
|
1043
|
+
`/remember <content>` - Store with auto-detected category
|
|
1044
|
+
`/remember category:<cat> <content>` - Store with specific category
|
|
1045
|
+
|
|
1046
|
+
## Categories
|
|
1047
|
+
|
|
1048
|
+
architecture, convention, decision, pattern, gotcha, workaround, troubleshooting, command, preference
|
|
1049
|
+
|
|
1050
|
+
## Examples
|
|
1051
|
+
|
|
1052
|
+
- `/remember Always use async/await in this project`
|
|
1053
|
+
- `/remember category:gotcha Watch out for N+1 queries`
|
|
1054
|
+
|
|
1055
|
+
## Action
|
|
1056
|
+
|
|
1057
|
+
```bash
|
|
1058
|
+
mem add "<content>" -c <category>
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
Confirm storage with the memory ID shown.
|
|
1062
|
+
"""
|
|
1063
|
+
|
|
1064
|
+
RECALL_CMD = """---
|
|
1065
|
+
description: Search memories for relevant information
|
|
1066
|
+
allowed-tools: Bash
|
|
1067
|
+
---
|
|
1068
|
+
|
|
1069
|
+
Search Runtime Memory for relevant past knowledge.
|
|
1070
|
+
|
|
1071
|
+
## Usage
|
|
1072
|
+
|
|
1073
|
+
`/recall <query>` - Search memories
|
|
1074
|
+
`/recall <query> limit:N` - Limit results
|
|
1075
|
+
|
|
1076
|
+
## Action
|
|
1077
|
+
|
|
1078
|
+
```bash
|
|
1079
|
+
mem search "<query>" --limit 5 --format context
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
Present results naturally, noting which memories have high confidence (outcome score > 0.3).
|
|
1083
|
+
"""
|
|
1084
|
+
|
|
1085
|
+
OUTCOME_CMD = """---
|
|
1086
|
+
description: Record feedback on whether a memory helped
|
|
1087
|
+
allowed-tools: Bash
|
|
1088
|
+
---
|
|
1089
|
+
|
|
1090
|
+
Record outcome feedback to improve memory rankings.
|
|
1091
|
+
|
|
1092
|
+
## Usage
|
|
1093
|
+
|
|
1094
|
+
`/outcome <id> worked` - Memory was helpful (+0.2)
|
|
1095
|
+
`/outcome <id> failed` - Memory was wrong (-0.3)
|
|
1096
|
+
`/outcome <id> partial` - Partially helpful (+0.05)
|
|
1097
|
+
|
|
1098
|
+
## Action
|
|
1099
|
+
|
|
1100
|
+
```bash
|
|
1101
|
+
mem outcome <id> <result>
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
Confirm the score adjustment.
|
|
1105
|
+
"""
|
|
1106
|
+
|
|
1107
|
+
MEMORIES_CMD = """---
|
|
1108
|
+
description: List stored memories
|
|
1109
|
+
allowed-tools: Bash
|
|
1110
|
+
---
|
|
1111
|
+
|
|
1112
|
+
List all memories in Runtime Memory.
|
|
1113
|
+
|
|
1114
|
+
## Usage
|
|
1115
|
+
|
|
1116
|
+
`/memories` - List all
|
|
1117
|
+
`/memories -c <category>` - Filter by category
|
|
1118
|
+
|
|
1119
|
+
## Action
|
|
1120
|
+
|
|
1121
|
+
```bash
|
|
1122
|
+
mem list --limit 20
|
|
1123
|
+
```
|
|
1124
|
+
"""
|
|
1125
|
+
|
|
1126
|
+
FORGET_CMD = """---
|
|
1127
|
+
description: Archive a memory
|
|
1128
|
+
allowed-tools: Bash
|
|
1129
|
+
---
|
|
1130
|
+
|
|
1131
|
+
Archive (soft-delete) a memory that's no longer relevant.
|
|
1132
|
+
|
|
1133
|
+
## Usage
|
|
1134
|
+
|
|
1135
|
+
`/forget <id>`
|
|
1136
|
+
|
|
1137
|
+
## Action
|
|
1138
|
+
|
|
1139
|
+
```bash
|
|
1140
|
+
mem delete <id> --confirm
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
Confirm archival.
|
|
1144
|
+
"""
|
|
1145
|
+
|
|
1146
|
+
MEMORY_CONTEXT_CMD = """---
|
|
1147
|
+
description: Get project memory context
|
|
1148
|
+
allowed-tools: Bash
|
|
1149
|
+
---
|
|
1150
|
+
|
|
1151
|
+
Get formatted memory context for the current project.
|
|
1152
|
+
|
|
1153
|
+
## Usage
|
|
1154
|
+
|
|
1155
|
+
`/memory-context`
|
|
1156
|
+
|
|
1157
|
+
## Action
|
|
1158
|
+
|
|
1159
|
+
```bash
|
|
1160
|
+
mem context --format markdown
|
|
1161
|
+
```
|
|
1162
|
+
"""
|
|
1163
|
+
|
|
1164
|
+
MEMORY_RETRIEVAL_SKILL = """---
|
|
1165
|
+
description: Automatically retrieve relevant memories when user asks about past decisions
|
|
1166
|
+
allowed-tools: Bash, Read
|
|
1167
|
+
user-invocable: false
|
|
1168
|
+
---
|
|
1169
|
+
|
|
1170
|
+
# Memory Retrieval Skill
|
|
1171
|
+
|
|
1172
|
+
Activate when user asks about past decisions, conventions, or patterns:
|
|
1173
|
+
- "what did we decide about..."
|
|
1174
|
+
- "what's our convention for..."
|
|
1175
|
+
- "how do we handle..."
|
|
1176
|
+
- "last time we..."
|
|
1177
|
+
|
|
1178
|
+
## Action
|
|
1179
|
+
|
|
1180
|
+
Search memories and incorporate into response:
|
|
1181
|
+
|
|
1182
|
+
```bash
|
|
1183
|
+
mem search "<keywords>" --limit 5 --format context
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1186
|
+
Cite memory IDs for transparency. Suggest `/outcome <id> worked` if helpful.
|
|
1187
|
+
"""
|
|
1188
|
+
|
|
1189
|
+
OUTCOME_FEEDBACK_SKILL = """---
|
|
1190
|
+
description: Detect natural feedback signals and prompt for outcome recording
|
|
1191
|
+
allowed-tools: Bash
|
|
1192
|
+
user-invocable: false
|
|
1193
|
+
---
|
|
1194
|
+
|
|
1195
|
+
# Outcome Feedback Skill
|
|
1196
|
+
|
|
1197
|
+
Detect when user signals success or failure:
|
|
1198
|
+
|
|
1199
|
+
**Positive**: "thanks!", "that worked!", "perfect!", "solved it"
|
|
1200
|
+
**Negative**: "still not working", "didn't help", "same error"
|
|
1201
|
+
**Partial**: "kind of", "partially", "almost"
|
|
1202
|
+
|
|
1203
|
+
## Action
|
|
1204
|
+
|
|
1205
|
+
When detected, offer to record feedback:
|
|
1206
|
+
|
|
1207
|
+
```bash
|
|
1208
|
+
mem outcome <last-used-memory-id> worked|failed|partial
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
Be non-intrusive - only prompt once per memory per session.
|
|
1212
|
+
"""
|
|
1213
|
+
|
|
1214
|
+
CODING_PATTERNS_SKILL = """---
|
|
1215
|
+
description: Surface relevant coding patterns when implementing new features
|
|
1216
|
+
allowed-tools: Bash, Read
|
|
1217
|
+
user-invocable: false
|
|
1218
|
+
---
|
|
1219
|
+
|
|
1220
|
+
# Coding Patterns Skill
|
|
1221
|
+
|
|
1222
|
+
Activate when user is creating new code:
|
|
1223
|
+
- "create a new..."
|
|
1224
|
+
- "implement..."
|
|
1225
|
+
- "write a..."
|
|
1226
|
+
- "add a new..."
|
|
1227
|
+
|
|
1228
|
+
## Action
|
|
1229
|
+
|
|
1230
|
+
Search for relevant patterns:
|
|
1231
|
+
|
|
1232
|
+
```bash
|
|
1233
|
+
mem search "<feature-type> pattern" -c pattern --limit 3
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
Incorporate patterns into implementation suggestions.
|
|
1237
|
+
"""
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
@cli.command("install-plugin")
|
|
1241
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite existing files")
|
|
1242
|
+
@click.pass_context
|
|
1243
|
+
def install_plugin(ctx: click.Context, force: bool) -> None:
|
|
1244
|
+
"""Install Claude Code plugin files in the current directory.
|
|
1245
|
+
|
|
1246
|
+
Creates:
|
|
1247
|
+
- .claude/settings.json (hooks configuration)
|
|
1248
|
+
- .claude/commands/ (slash commands)
|
|
1249
|
+
- .claude/skills/ (agent skills)
|
|
1250
|
+
- .claude-plugin/plugin.json (plugin manifest)
|
|
1251
|
+
- .mcp.json (MCP server configuration)
|
|
1252
|
+
|
|
1253
|
+
Example:
|
|
1254
|
+
cd your-project
|
|
1255
|
+
mem install-plugin
|
|
1256
|
+
claude
|
|
1257
|
+
"""
|
|
1258
|
+
cwd = Path.cwd()
|
|
1259
|
+
|
|
1260
|
+
# Create directories
|
|
1261
|
+
claude_dir = cwd / ".claude"
|
|
1262
|
+
commands_dir = claude_dir / "commands"
|
|
1263
|
+
skills_dir = claude_dir / "skills"
|
|
1264
|
+
plugin_dir = cwd / ".claude-plugin"
|
|
1265
|
+
|
|
1266
|
+
dirs_to_create = [
|
|
1267
|
+
claude_dir,
|
|
1268
|
+
commands_dir,
|
|
1269
|
+
skills_dir / "memory-retrieval",
|
|
1270
|
+
skills_dir / "outcome-feedback",
|
|
1271
|
+
skills_dir / "coding-patterns",
|
|
1272
|
+
plugin_dir,
|
|
1273
|
+
]
|
|
1274
|
+
|
|
1275
|
+
for d in dirs_to_create:
|
|
1276
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
1277
|
+
|
|
1278
|
+
# Files to create
|
|
1279
|
+
files = [
|
|
1280
|
+
(claude_dir / "settings.json", SETTINGS_JSON),
|
|
1281
|
+
(plugin_dir / "plugin.json", PLUGIN_JSON),
|
|
1282
|
+
(cwd / ".mcp.json", MCP_JSON),
|
|
1283
|
+
(commands_dir / "remember.md", REMEMBER_CMD),
|
|
1284
|
+
(commands_dir / "recall.md", RECALL_CMD),
|
|
1285
|
+
(commands_dir / "outcome.md", OUTCOME_CMD),
|
|
1286
|
+
(commands_dir / "memories.md", MEMORIES_CMD),
|
|
1287
|
+
(commands_dir / "forget.md", FORGET_CMD),
|
|
1288
|
+
(commands_dir / "memory-context.md", MEMORY_CONTEXT_CMD),
|
|
1289
|
+
(skills_dir / "memory-retrieval" / "SKILL.md", MEMORY_RETRIEVAL_SKILL),
|
|
1290
|
+
(skills_dir / "outcome-feedback" / "SKILL.md", OUTCOME_FEEDBACK_SKILL),
|
|
1291
|
+
(skills_dir / "coding-patterns" / "SKILL.md", CODING_PATTERNS_SKILL),
|
|
1292
|
+
]
|
|
1293
|
+
|
|
1294
|
+
created = 0
|
|
1295
|
+
skipped = 0
|
|
1296
|
+
|
|
1297
|
+
for filepath, content in files:
|
|
1298
|
+
if filepath.exists() and not force:
|
|
1299
|
+
skipped += 1
|
|
1300
|
+
click.echo(f" Skipped (exists): {filepath.relative_to(cwd)}")
|
|
1301
|
+
else:
|
|
1302
|
+
filepath.write_text(content.strip().replace("__VERSION__", __version__) + "\n")
|
|
1303
|
+
created += 1
|
|
1304
|
+
click.echo(f" Created: {filepath.relative_to(cwd)}")
|
|
1305
|
+
|
|
1306
|
+
click.echo()
|
|
1307
|
+
click.echo(f"Plugin installed: {created} files created, {skipped} skipped")
|
|
1308
|
+
|
|
1309
|
+
if skipped > 0:
|
|
1310
|
+
click.echo("Use --force to overwrite existing files")
|
|
1311
|
+
|
|
1312
|
+
click.echo()
|
|
1313
|
+
click.echo("Next steps:")
|
|
1314
|
+
click.echo(" 1. Start Claude Code: claude")
|
|
1315
|
+
click.echo(" 2. Try: /remember Always use async/await")
|
|
1316
|
+
click.echo(" 3. Try: /recall async")
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
# =============================================================================
|
|
1320
|
+
# Beads Integration Commands
|
|
1321
|
+
# =============================================================================
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
@cli.command("beads-sync")
|
|
1325
|
+
@click.option("--task", "-t", help="Sync specific task ID only")
|
|
1326
|
+
@click.option("--dry-run", is_flag=True, help="Show what would happen without making changes")
|
|
1327
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress output (for hooks)")
|
|
1328
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1329
|
+
@click.pass_context
|
|
1330
|
+
def beads_sync(
|
|
1331
|
+
ctx: click.Context,
|
|
1332
|
+
task: Optional[str],
|
|
1333
|
+
dry_run: bool,
|
|
1334
|
+
quiet: bool,
|
|
1335
|
+
json_output: bool,
|
|
1336
|
+
) -> None:
|
|
1337
|
+
"""Sync outcomes for completed Beads tasks.
|
|
1338
|
+
|
|
1339
|
+
Scans completed tasks and auto-records outcomes for linked memories.
|
|
1340
|
+
This is the core of Beads integration - when a task completes,
|
|
1341
|
+
memories that helped solve it get boosted.
|
|
1342
|
+
|
|
1343
|
+
Examples:
|
|
1344
|
+
mem beads-sync # Sync all completed tasks
|
|
1345
|
+
mem beads-sync --task bd-a3f8 # Sync specific task
|
|
1346
|
+
mem beads-sync --dry-run # Preview changes
|
|
1347
|
+
"""
|
|
1348
|
+
from runtime_memory.tasks import BeadsAdapter, BeadsSyncResult
|
|
1349
|
+
|
|
1350
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1351
|
+
|
|
1352
|
+
try:
|
|
1353
|
+
engine = get_engine()
|
|
1354
|
+
adapter = BeadsAdapter(engine)
|
|
1355
|
+
run_async(adapter.initialize())
|
|
1356
|
+
|
|
1357
|
+
if not adapter.is_available:
|
|
1358
|
+
if not quiet:
|
|
1359
|
+
if json_output:
|
|
1360
|
+
click.echo(json.dumps({"error": "Beads not found", "success": False}))
|
|
1361
|
+
else:
|
|
1362
|
+
click.echo("No .beads/ directory found in project hierarchy.")
|
|
1363
|
+
ctx.exit(1)
|
|
1364
|
+
|
|
1365
|
+
if task:
|
|
1366
|
+
# Sync specific task
|
|
1367
|
+
if dry_run:
|
|
1368
|
+
from runtime_memory.tasks import TaskMemoryLinker
|
|
1369
|
+
linker = adapter._linker
|
|
1370
|
+
links = run_async(linker.get_unresolved_links(task))
|
|
1371
|
+
if not quiet:
|
|
1372
|
+
if json_output:
|
|
1373
|
+
click.echo(json.dumps({
|
|
1374
|
+
"task_id": task,
|
|
1375
|
+
"memories_to_update": len(links),
|
|
1376
|
+
"dry_run": True,
|
|
1377
|
+
}))
|
|
1378
|
+
else:
|
|
1379
|
+
click.echo(f"Would update {len(links)} memories for task {task}")
|
|
1380
|
+
else:
|
|
1381
|
+
beads_task = adapter.get_task(task)
|
|
1382
|
+
if not beads_task:
|
|
1383
|
+
if not quiet:
|
|
1384
|
+
click.echo(f"Task {task} not found")
|
|
1385
|
+
ctx.exit(1)
|
|
1386
|
+
|
|
1387
|
+
from runtime_memory.tasks import BeadsTaskStatus
|
|
1388
|
+
if beads_task.status == BeadsTaskStatus.DONE:
|
|
1389
|
+
count = run_async(adapter.on_task_done(task))
|
|
1390
|
+
elif beads_task.status == BeadsTaskStatus.CANCELLED:
|
|
1391
|
+
count = run_async(adapter.on_task_cancelled(task))
|
|
1392
|
+
elif beads_task.status == BeadsTaskStatus.BLOCKED:
|
|
1393
|
+
count = run_async(adapter.on_task_blocked(task))
|
|
1394
|
+
else:
|
|
1395
|
+
count = 0
|
|
1396
|
+
if not quiet:
|
|
1397
|
+
click.echo(f"Task {task} is {beads_task.status.value}, no outcome to record")
|
|
1398
|
+
|
|
1399
|
+
if not quiet and count > 0:
|
|
1400
|
+
if json_output:
|
|
1401
|
+
click.echo(json.dumps({
|
|
1402
|
+
"task_id": task,
|
|
1403
|
+
"outcomes_recorded": count,
|
|
1404
|
+
}))
|
|
1405
|
+
else:
|
|
1406
|
+
click.echo(f"Recorded outcomes for {count} memories")
|
|
1407
|
+
else:
|
|
1408
|
+
# Sync all completed tasks
|
|
1409
|
+
result = run_async(adapter.sync())
|
|
1410
|
+
|
|
1411
|
+
if not quiet:
|
|
1412
|
+
if json_output:
|
|
1413
|
+
click.echo(json.dumps(result.to_dict()))
|
|
1414
|
+
else:
|
|
1415
|
+
click.echo(f"Tasks found: {result.tasks_found}")
|
|
1416
|
+
click.echo(f"Tasks synced: {result.tasks_synced}")
|
|
1417
|
+
click.echo(f"Outcomes recorded: {result.outcomes_recorded}")
|
|
1418
|
+
if result.errors:
|
|
1419
|
+
click.echo(f"Errors: {len(result.errors)}")
|
|
1420
|
+
if verbose:
|
|
1421
|
+
for err in result.errors:
|
|
1422
|
+
click.echo(f" - {err}")
|
|
1423
|
+
|
|
1424
|
+
except Exception as e:
|
|
1425
|
+
if not quiet:
|
|
1426
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1427
|
+
ctx.exit(1)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@cli.command("beads-context")
|
|
1431
|
+
@click.option("--task", "-t", help="Task ID to get context for (default: current)")
|
|
1432
|
+
@click.option("--limit", "-l", default=10, help="Max memories to include")
|
|
1433
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1434
|
+
@click.pass_context
|
|
1435
|
+
def beads_context(
|
|
1436
|
+
ctx: click.Context,
|
|
1437
|
+
task: Optional[str],
|
|
1438
|
+
limit: int,
|
|
1439
|
+
json_output: bool,
|
|
1440
|
+
) -> None:
|
|
1441
|
+
"""Get unified context for a Beads task.
|
|
1442
|
+
|
|
1443
|
+
Combines task info with relevant memories for context injection.
|
|
1444
|
+
|
|
1445
|
+
Examples:
|
|
1446
|
+
mem beads-context # Context for current task
|
|
1447
|
+
mem beads-context -t bd-a3f8 # Context for specific task
|
|
1448
|
+
mem beads-context --json # Output as JSON
|
|
1449
|
+
"""
|
|
1450
|
+
from runtime_memory.tasks import BeadsAdapter
|
|
1451
|
+
|
|
1452
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1453
|
+
|
|
1454
|
+
try:
|
|
1455
|
+
engine = get_engine()
|
|
1456
|
+
adapter = BeadsAdapter(engine)
|
|
1457
|
+
run_async(adapter.initialize())
|
|
1458
|
+
|
|
1459
|
+
if not adapter.is_available:
|
|
1460
|
+
if json_output:
|
|
1461
|
+
click.echo(json.dumps({"error": "Beads not found"}))
|
|
1462
|
+
else:
|
|
1463
|
+
click.echo("No .beads/ directory found in project hierarchy.")
|
|
1464
|
+
ctx.exit(1)
|
|
1465
|
+
|
|
1466
|
+
context = run_async(adapter.get_unified_context(task, limit))
|
|
1467
|
+
|
|
1468
|
+
if not context:
|
|
1469
|
+
if json_output:
|
|
1470
|
+
click.echo(json.dumps({"error": "No task found"}))
|
|
1471
|
+
else:
|
|
1472
|
+
click.echo("No active task found.")
|
|
1473
|
+
ctx.exit(1)
|
|
1474
|
+
|
|
1475
|
+
if json_output:
|
|
1476
|
+
click.echo(json.dumps({
|
|
1477
|
+
"task_id": context.task.id,
|
|
1478
|
+
"task_title": context.task.title,
|
|
1479
|
+
"task_status": context.task.status.value,
|
|
1480
|
+
"memories_count": len(context.memories),
|
|
1481
|
+
"formatted": context.formatted,
|
|
1482
|
+
}))
|
|
1483
|
+
else:
|
|
1484
|
+
click.echo(context.formatted)
|
|
1485
|
+
|
|
1486
|
+
except Exception as e:
|
|
1487
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1488
|
+
ctx.exit(1)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
@cli.command("beads-link")
|
|
1492
|
+
@click.argument("memory_id")
|
|
1493
|
+
@click.option("--task", "-t", help="Task ID to link to (default: current)")
|
|
1494
|
+
@click.option("--context", "-c", help="Context about how memory is used")
|
|
1495
|
+
@click.pass_context
|
|
1496
|
+
def beads_link(
|
|
1497
|
+
ctx: click.Context,
|
|
1498
|
+
memory_id: str,
|
|
1499
|
+
task: Optional[str],
|
|
1500
|
+
context: Optional[str],
|
|
1501
|
+
) -> None:
|
|
1502
|
+
"""Link a memory to a Beads task.
|
|
1503
|
+
|
|
1504
|
+
Manually link a memory to a task for outcome tracking.
|
|
1505
|
+
When the task completes, the memory's outcome will be recorded.
|
|
1506
|
+
|
|
1507
|
+
Examples:
|
|
1508
|
+
mem beads-link mem-abc123 # Link to current task
|
|
1509
|
+
mem beads-link mem-abc123 -t bd-a3f8 # Link to specific task
|
|
1510
|
+
mem beads-link mem-abc123 -c "used for auth" # With context
|
|
1511
|
+
"""
|
|
1512
|
+
from runtime_memory.tasks import BeadsAdapter
|
|
1513
|
+
|
|
1514
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
engine = get_engine()
|
|
1518
|
+
adapter = BeadsAdapter(engine)
|
|
1519
|
+
run_async(adapter.initialize())
|
|
1520
|
+
|
|
1521
|
+
if not adapter.is_available:
|
|
1522
|
+
click.echo("No .beads/ directory found in project hierarchy.")
|
|
1523
|
+
ctx.exit(1)
|
|
1524
|
+
|
|
1525
|
+
# Get task ID
|
|
1526
|
+
if task:
|
|
1527
|
+
target_task = adapter.get_task(task)
|
|
1528
|
+
if not target_task:
|
|
1529
|
+
click.echo(f"Task {task} not found")
|
|
1530
|
+
ctx.exit(1)
|
|
1531
|
+
task_id = task
|
|
1532
|
+
else:
|
|
1533
|
+
current = adapter.get_current_task()
|
|
1534
|
+
if not current:
|
|
1535
|
+
click.echo("No current task. Use --task to specify a task ID.")
|
|
1536
|
+
ctx.exit(1)
|
|
1537
|
+
task_id = current.id
|
|
1538
|
+
|
|
1539
|
+
# Verify memory exists
|
|
1540
|
+
try:
|
|
1541
|
+
memory = run_async(engine.get(memory_id))
|
|
1542
|
+
except Exception:
|
|
1543
|
+
click.echo(f"Memory {memory_id} not found")
|
|
1544
|
+
ctx.exit(1)
|
|
1545
|
+
|
|
1546
|
+
# Create link
|
|
1547
|
+
run_async(adapter.link_memory_to_task(task_id, memory_id, context))
|
|
1548
|
+
click.echo(f"Linked memory {memory_id} to task {task_id}")
|
|
1549
|
+
|
|
1550
|
+
except Exception as e:
|
|
1551
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1552
|
+
ctx.exit(1)
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
@cli.command("beads-stats")
|
|
1556
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1557
|
+
@click.pass_context
|
|
1558
|
+
def beads_stats(ctx: click.Context, json_output: bool) -> None:
|
|
1559
|
+
"""Show Beads integration statistics.
|
|
1560
|
+
|
|
1561
|
+
Displays info about tasks, links, and sync status.
|
|
1562
|
+
|
|
1563
|
+
Examples:
|
|
1564
|
+
mem beads-stats
|
|
1565
|
+
mem beads-stats --json
|
|
1566
|
+
"""
|
|
1567
|
+
from runtime_memory.tasks import BeadsAdapter
|
|
1568
|
+
|
|
1569
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1570
|
+
|
|
1571
|
+
try:
|
|
1572
|
+
engine = get_engine()
|
|
1573
|
+
adapter = BeadsAdapter(engine)
|
|
1574
|
+
run_async(adapter.initialize())
|
|
1575
|
+
|
|
1576
|
+
stats = run_async(adapter.get_stats())
|
|
1577
|
+
|
|
1578
|
+
if json_output:
|
|
1579
|
+
click.echo(json.dumps(stats, indent=2))
|
|
1580
|
+
else:
|
|
1581
|
+
click.echo("Beads Integration Stats")
|
|
1582
|
+
click.echo("=" * 40)
|
|
1583
|
+
click.echo(f"Beads available: {stats['beads_available']}")
|
|
1584
|
+
if stats['beads_dir']:
|
|
1585
|
+
click.echo(f"Beads directory: {stats['beads_dir']}")
|
|
1586
|
+
click.echo()
|
|
1587
|
+
|
|
1588
|
+
if stats['beads_available']:
|
|
1589
|
+
click.echo("Tasks:")
|
|
1590
|
+
click.echo(f" Total: {stats['tasks']['total_tasks']}")
|
|
1591
|
+
for status, count in stats['tasks']['by_status'].items():
|
|
1592
|
+
click.echo(f" {status}: {count}")
|
|
1593
|
+
click.echo()
|
|
1594
|
+
|
|
1595
|
+
click.echo("Task-Memory Links:")
|
|
1596
|
+
click.echo(f" Total links: {stats['links']['total_links']}")
|
|
1597
|
+
click.echo(f" Unique tasks: {stats['links']['unique_tasks']}")
|
|
1598
|
+
click.echo(f" Unique memories: {stats['links']['unique_memories']}")
|
|
1599
|
+
if stats['links']['by_outcome']:
|
|
1600
|
+
click.echo(" By outcome:")
|
|
1601
|
+
for outcome, count in stats['links']['by_outcome'].items():
|
|
1602
|
+
click.echo(f" {outcome}: {count}")
|
|
1603
|
+
click.echo()
|
|
1604
|
+
|
|
1605
|
+
click.echo("Configuration:")
|
|
1606
|
+
click.echo(f" Auto-outcome: {stats['auto_outcome_enabled']}")
|
|
1607
|
+
click.echo(f" Outcome on cancel: {stats['outcome_on_cancel']}")
|
|
1608
|
+
|
|
1609
|
+
except Exception as e:
|
|
1610
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1611
|
+
ctx.exit(1)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
# =============================================================================
|
|
1615
|
+
# Unified Tasks Commands (Phase 7 - Claude Code Tasks Adapter)
|
|
1616
|
+
# =============================================================================
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
@cli.command("tasks")
|
|
1620
|
+
@click.option("--source", "-s", type=click.Choice(["beads", "claude", "all"]), default="all",
|
|
1621
|
+
help="Task source filter")
|
|
1622
|
+
@click.option("--status", type=click.Choice(["pending", "in_progress", "done", "completed", "all"]),
|
|
1623
|
+
default="all", help="Status filter")
|
|
1624
|
+
@click.option("--limit", "-l", default=50, help="Maximum number of tasks to show")
|
|
1625
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1626
|
+
@click.pass_context
|
|
1627
|
+
def tasks_list(
|
|
1628
|
+
ctx: click.Context,
|
|
1629
|
+
source: str,
|
|
1630
|
+
status: str,
|
|
1631
|
+
limit: int,
|
|
1632
|
+
json_output: bool,
|
|
1633
|
+
) -> None:
|
|
1634
|
+
"""List tasks from all available sources.
|
|
1635
|
+
|
|
1636
|
+
Shows tasks from both Beads (.beads/) and Claude Code (~/.claude/todos/)
|
|
1637
|
+
with unified formatting.
|
|
1638
|
+
|
|
1639
|
+
Examples:
|
|
1640
|
+
mem tasks # List all tasks
|
|
1641
|
+
mem tasks --source claude # Only Claude Code tasks
|
|
1642
|
+
mem tasks --source beads # Only Beads tasks
|
|
1643
|
+
mem tasks --status pending # Only pending tasks
|
|
1644
|
+
mem tasks --json # Output as JSON
|
|
1645
|
+
"""
|
|
1646
|
+
from runtime_memory.tasks import TaskSource, UnifiedTaskAdapter
|
|
1647
|
+
|
|
1648
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1649
|
+
|
|
1650
|
+
try:
|
|
1651
|
+
engine = get_engine()
|
|
1652
|
+
adapter = UnifiedTaskAdapter(engine)
|
|
1653
|
+
run_async(adapter.initialize())
|
|
1654
|
+
|
|
1655
|
+
# Determine source filter
|
|
1656
|
+
source_filter = None
|
|
1657
|
+
if source == "beads":
|
|
1658
|
+
source_filter = TaskSource.BEADS
|
|
1659
|
+
elif source == "claude":
|
|
1660
|
+
source_filter = TaskSource.CLAUDE_CODE
|
|
1661
|
+
|
|
1662
|
+
# Determine status filter
|
|
1663
|
+
status_filter = None if status == "all" else status
|
|
1664
|
+
|
|
1665
|
+
# Get tasks
|
|
1666
|
+
tasks = adapter.list_tasks(source=source_filter, status=status_filter)
|
|
1667
|
+
tasks = tasks[:limit]
|
|
1668
|
+
|
|
1669
|
+
if json_output:
|
|
1670
|
+
output = {
|
|
1671
|
+
"tasks": [t.to_dict() for t in tasks],
|
|
1672
|
+
"total": len(tasks),
|
|
1673
|
+
"sources": [s.value for s in adapter.available_sources],
|
|
1674
|
+
}
|
|
1675
|
+
click.echo(json.dumps(output, indent=2, default=str))
|
|
1676
|
+
else:
|
|
1677
|
+
if not tasks:
|
|
1678
|
+
click.echo("No tasks found.")
|
|
1679
|
+
click.echo(f"Available sources: {[s.value for s in adapter.available_sources]}")
|
|
1680
|
+
return
|
|
1681
|
+
|
|
1682
|
+
click.echo(f"Tasks ({len(tasks)} found)")
|
|
1683
|
+
click.echo("=" * 60)
|
|
1684
|
+
|
|
1685
|
+
for task in tasks:
|
|
1686
|
+
# Status indicator
|
|
1687
|
+
status_icons = {
|
|
1688
|
+
"pending": "○",
|
|
1689
|
+
"in_progress": "►",
|
|
1690
|
+
"done": "✓",
|
|
1691
|
+
"completed": "✓",
|
|
1692
|
+
"blocked": "⊘",
|
|
1693
|
+
"cancelled": "✗",
|
|
1694
|
+
}
|
|
1695
|
+
icon = status_icons.get(task.status, "?")
|
|
1696
|
+
|
|
1697
|
+
# Source tag
|
|
1698
|
+
source_tag = "[B]" if task.source == TaskSource.BEADS else "[C]"
|
|
1699
|
+
|
|
1700
|
+
# Title (truncated)
|
|
1701
|
+
title = task.title[:50] + "..." if len(task.title) > 50 else task.title
|
|
1702
|
+
|
|
1703
|
+
click.echo(f"{icon} {source_tag} {task.id}: {title}")
|
|
1704
|
+
|
|
1705
|
+
click.echo()
|
|
1706
|
+
click.echo(f"Sources: {[s.value for s in adapter.available_sources]}")
|
|
1707
|
+
|
|
1708
|
+
except Exception as e:
|
|
1709
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1710
|
+
ctx.exit(1)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
@cli.command("tasks-sync")
|
|
1714
|
+
@click.option("--source", "-s", type=click.Choice(["beads", "claude", "all"]), default="all",
|
|
1715
|
+
help="Task source to sync")
|
|
1716
|
+
@click.option("--task", "-t", help="Sync specific task ID only")
|
|
1717
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1718
|
+
@click.pass_context
|
|
1719
|
+
def tasks_sync(
|
|
1720
|
+
ctx: click.Context,
|
|
1721
|
+
source: str,
|
|
1722
|
+
task: Optional[str],
|
|
1723
|
+
json_output: bool,
|
|
1724
|
+
) -> None:
|
|
1725
|
+
"""Sync outcomes for completed tasks from all sources.
|
|
1726
|
+
|
|
1727
|
+
Scans completed tasks and auto-records outcomes for linked memories.
|
|
1728
|
+
Works with both Beads and Claude Code tasks.
|
|
1729
|
+
|
|
1730
|
+
Examples:
|
|
1731
|
+
mem tasks-sync # Sync all sources
|
|
1732
|
+
mem tasks-sync --source claude # Only Claude Code
|
|
1733
|
+
mem tasks-sync --task cc-abc-0 # Sync specific task
|
|
1734
|
+
mem tasks-sync --json # Output as JSON
|
|
1735
|
+
"""
|
|
1736
|
+
from runtime_memory.tasks import TaskSource, UnifiedTaskAdapter
|
|
1737
|
+
|
|
1738
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1739
|
+
|
|
1740
|
+
try:
|
|
1741
|
+
engine = get_engine()
|
|
1742
|
+
adapter = UnifiedTaskAdapter(engine)
|
|
1743
|
+
run_async(adapter.initialize())
|
|
1744
|
+
|
|
1745
|
+
if task:
|
|
1746
|
+
# Sync specific task
|
|
1747
|
+
count = run_async(adapter.on_task_completed(task))
|
|
1748
|
+
if json_output:
|
|
1749
|
+
click.echo(json.dumps({
|
|
1750
|
+
"task_id": task,
|
|
1751
|
+
"outcomes_recorded": count,
|
|
1752
|
+
"success": True,
|
|
1753
|
+
}))
|
|
1754
|
+
else:
|
|
1755
|
+
click.echo(f"Recorded outcomes for {count} memories on task {task}")
|
|
1756
|
+
else:
|
|
1757
|
+
# Sync all or by source
|
|
1758
|
+
source_filter = None
|
|
1759
|
+
if source == "beads":
|
|
1760
|
+
source_filter = TaskSource.BEADS
|
|
1761
|
+
elif source == "claude":
|
|
1762
|
+
source_filter = TaskSource.CLAUDE_CODE
|
|
1763
|
+
|
|
1764
|
+
result = run_async(adapter.sync(source=source_filter))
|
|
1765
|
+
|
|
1766
|
+
if json_output:
|
|
1767
|
+
click.echo(json.dumps(result.to_dict(), indent=2))
|
|
1768
|
+
else:
|
|
1769
|
+
if hasattr(result, 'results'):
|
|
1770
|
+
# UnifiedSyncResult
|
|
1771
|
+
click.echo("Task Sync Results")
|
|
1772
|
+
click.echo("=" * 40)
|
|
1773
|
+
click.echo(f"Total tasks found: {result.total_tasks_found}")
|
|
1774
|
+
click.echo(f"Tasks synced: {result.total_tasks_synced}")
|
|
1775
|
+
click.echo(f"Outcomes recorded: {result.total_outcomes_recorded}")
|
|
1776
|
+
if result.errors:
|
|
1777
|
+
click.echo(f"Errors: {len(result.errors)}")
|
|
1778
|
+
for error in result.errors:
|
|
1779
|
+
click.echo(f" - {error}")
|
|
1780
|
+
else:
|
|
1781
|
+
# Single TaskSyncResult
|
|
1782
|
+
click.echo(f"Source: {result.source.value}")
|
|
1783
|
+
click.echo(f"Tasks found: {result.tasks_found}")
|
|
1784
|
+
click.echo(f"Tasks synced: {result.tasks_synced}")
|
|
1785
|
+
click.echo(f"Outcomes recorded: {result.outcomes_recorded}")
|
|
1786
|
+
|
|
1787
|
+
except Exception as e:
|
|
1788
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1789
|
+
ctx.exit(1)
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
@cli.command("tasks-context")
|
|
1793
|
+
@click.option("--task", "-t", help="Task ID to get context for (default: current)")
|
|
1794
|
+
@click.option("--source", "-s", type=click.Choice(["beads", "claude"]),
|
|
1795
|
+
help="Task source (auto-detected from ID)")
|
|
1796
|
+
@click.option("--limit", "-l", default=10, help="Max memories to include")
|
|
1797
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1798
|
+
@click.pass_context
|
|
1799
|
+
def tasks_context(
|
|
1800
|
+
ctx: click.Context,
|
|
1801
|
+
task: Optional[str],
|
|
1802
|
+
source: Optional[str],
|
|
1803
|
+
limit: int,
|
|
1804
|
+
json_output: bool,
|
|
1805
|
+
) -> None:
|
|
1806
|
+
"""Get unified context for a task from any source.
|
|
1807
|
+
|
|
1808
|
+
Combines task info with relevant memories for context injection.
|
|
1809
|
+
|
|
1810
|
+
Examples:
|
|
1811
|
+
mem tasks-context # Context for current task
|
|
1812
|
+
mem tasks-context -t cc-abc-0 # Context for Claude Code task
|
|
1813
|
+
mem tasks-context -t bd-a3f8 # Context for Beads task
|
|
1814
|
+
mem tasks-context --json # Output as JSON
|
|
1815
|
+
"""
|
|
1816
|
+
from runtime_memory.tasks import TaskSource, UnifiedTaskAdapter
|
|
1817
|
+
|
|
1818
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1819
|
+
|
|
1820
|
+
try:
|
|
1821
|
+
engine = get_engine()
|
|
1822
|
+
adapter = UnifiedTaskAdapter(engine)
|
|
1823
|
+
run_async(adapter.initialize())
|
|
1824
|
+
|
|
1825
|
+
# Determine source
|
|
1826
|
+
source_filter = None
|
|
1827
|
+
if source == "beads":
|
|
1828
|
+
source_filter = TaskSource.BEADS
|
|
1829
|
+
elif source == "claude":
|
|
1830
|
+
source_filter = TaskSource.CLAUDE_CODE
|
|
1831
|
+
|
|
1832
|
+
context = run_async(adapter.get_unified_context(task, source_filter, limit))
|
|
1833
|
+
|
|
1834
|
+
if not context:
|
|
1835
|
+
if json_output:
|
|
1836
|
+
click.echo(json.dumps({"error": "No task found"}))
|
|
1837
|
+
else:
|
|
1838
|
+
click.echo("No task found.")
|
|
1839
|
+
if not task:
|
|
1840
|
+
click.echo("Tip: Specify a task ID with --task")
|
|
1841
|
+
ctx.exit(1)
|
|
1842
|
+
|
|
1843
|
+
if json_output:
|
|
1844
|
+
output = {
|
|
1845
|
+
"task": context.task.to_dict(),
|
|
1846
|
+
"source": context.source.value,
|
|
1847
|
+
"memories": len(context.memories),
|
|
1848
|
+
"formatted": context.formatted,
|
|
1849
|
+
}
|
|
1850
|
+
click.echo(json.dumps(output, indent=2, default=str))
|
|
1851
|
+
else:
|
|
1852
|
+
click.echo(context.formatted)
|
|
1853
|
+
|
|
1854
|
+
except Exception as e:
|
|
1855
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1856
|
+
ctx.exit(1)
|
|
1857
|
+
|
|
1858
|
+
|
|
1859
|
+
@cli.command("tasks-stats")
|
|
1860
|
+
@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
|
|
1861
|
+
@click.pass_context
|
|
1862
|
+
def tasks_stats(ctx: click.Context, json_output: bool) -> None:
|
|
1863
|
+
"""Show statistics for all task integrations.
|
|
1864
|
+
|
|
1865
|
+
Displays info about Beads and Claude Code tasks, links, and sync status.
|
|
1866
|
+
|
|
1867
|
+
Examples:
|
|
1868
|
+
mem tasks-stats
|
|
1869
|
+
mem tasks-stats --json
|
|
1870
|
+
"""
|
|
1871
|
+
from runtime_memory.tasks import TaskSource, UnifiedTaskAdapter
|
|
1872
|
+
|
|
1873
|
+
verbose = ctx.obj.get("verbose", 0)
|
|
1874
|
+
|
|
1875
|
+
try:
|
|
1876
|
+
engine = get_engine()
|
|
1877
|
+
adapter = UnifiedTaskAdapter(engine)
|
|
1878
|
+
run_async(adapter.initialize())
|
|
1879
|
+
|
|
1880
|
+
stats = run_async(adapter.get_stats())
|
|
1881
|
+
|
|
1882
|
+
if json_output:
|
|
1883
|
+
click.echo(json.dumps(stats, indent=2))
|
|
1884
|
+
else:
|
|
1885
|
+
click.echo("Task Integration Statistics")
|
|
1886
|
+
click.echo("=" * 50)
|
|
1887
|
+
click.echo()
|
|
1888
|
+
click.echo(f"Available sources: {stats['available_sources']}")
|
|
1889
|
+
click.echo()
|
|
1890
|
+
|
|
1891
|
+
# Beads stats
|
|
1892
|
+
if stats['beads']:
|
|
1893
|
+
beads = stats['beads']
|
|
1894
|
+
click.echo("Beads Integration")
|
|
1895
|
+
click.echo("-" * 30)
|
|
1896
|
+
click.echo(f" Available: {beads.get('beads_available', False)}")
|
|
1897
|
+
if beads.get('beads_dir'):
|
|
1898
|
+
click.echo(f" Directory: {beads['beads_dir']}")
|
|
1899
|
+
if beads.get('tasks'):
|
|
1900
|
+
click.echo(f" Total tasks: {beads['tasks'].get('total_tasks', 0)}")
|
|
1901
|
+
for status, count in beads['tasks'].get('by_status', {}).items():
|
|
1902
|
+
click.echo(f" {status}: {count}")
|
|
1903
|
+
click.echo()
|
|
1904
|
+
|
|
1905
|
+
# Claude Code stats
|
|
1906
|
+
if stats['claude_code']:
|
|
1907
|
+
cc = stats['claude_code']
|
|
1908
|
+
click.echo("Claude Code Integration")
|
|
1909
|
+
click.echo("-" * 30)
|
|
1910
|
+
click.echo(f" Available: {cc.get('claude_code_available', False)}")
|
|
1911
|
+
if cc.get('todos_dir'):
|
|
1912
|
+
click.echo(f" Directory: {cc['todos_dir']}")
|
|
1913
|
+
if cc.get('tasks'):
|
|
1914
|
+
click.echo(f" Total tasks: {cc['tasks'].get('total_tasks', 0)}")
|
|
1915
|
+
click.echo(f" Sessions: {cc['tasks'].get('sessions', 0)}")
|
|
1916
|
+
for status, count in cc['tasks'].get('by_status', {}).items():
|
|
1917
|
+
click.echo(f" {status}: {count}")
|
|
1918
|
+
click.echo()
|
|
1919
|
+
|
|
1920
|
+
except Exception as e:
|
|
1921
|
+
click.echo(f"Error: {format_error(e, verbose > 0)}", err=True)
|
|
1922
|
+
ctx.exit(1)
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
# =============================================================================
|
|
1926
|
+
# Entry Point
|
|
1927
|
+
# =============================================================================
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
def main() -> None:
|
|
1931
|
+
"""Main entry point for the CLI."""
|
|
1932
|
+
cli(obj={})
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
if __name__ == "__main__":
|
|
1936
|
+
main()
|