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.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. 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()