memctrl 1.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.
memctrl/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """MemCtrl — Rule-governed memory layer for AI coding assistants.
2
+
3
+ Inspired by PageIndex's tree-based retrieval and Graphify's install pattern.
4
+ Uses hierarchical reasoning (not vectors) for explainable memory retrieval.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ from memctrl.store import Memory, MemoryStore, TriggerLog, TreeNode
10
+ from memctrl.retriever import RetrievalResult
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "Memory",
15
+ "MemoryStore",
16
+ "TreeNode",
17
+ "TriggerLog",
18
+ "RetrievalResult",
19
+ ]
memctrl/cli.py ADDED
@@ -0,0 +1,443 @@
1
+ """MemCtrl — Typer CLI with rich formatting.
2
+
3
+ Commands:
4
+ install, init, add, query, list, tree, forget, clear,
5
+ trigger, audit, serve, --version
6
+
7
+ Uses typer + rich for beautiful terminal output.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+ from rich.tree import Tree as RichTree
23
+
24
+ from memctrl import __version__
25
+
26
+ app = typer.Typer(
27
+ name="memctrl",
28
+ help="Rule-governed memory layer for AI coding assistants",
29
+ add_completion=False,
30
+ )
31
+ console = Console()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _get_store():
39
+ """Get MemoryStore instance with default DB path."""
40
+ from memctrl.store import MemoryStore
41
+ db_path = os.environ.get("MEMCTRL_DB_PATH")
42
+ return MemoryStore(db_path)
43
+
44
+
45
+ def _get_engine():
46
+ """Get RuleEngine instance."""
47
+ from memctrl.rules import RuleEngine
48
+ return RuleEngine()
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Callback (version)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @app.callback(invoke_without_command=True)
56
+ def main(
57
+ ctx: typer.Context,
58
+ version: bool = typer.Option(False, "--version", help="Show version"),
59
+ ):
60
+ if version:
61
+ console.print(f"MemCtrl v{__version__}")
62
+ raise typer.Exit()
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Commands
67
+ # ---------------------------------------------------------------------------
68
+
69
+ @app.command()
70
+ def install(
71
+ tool: Optional[str] = typer.Option(None, help="Specific tool to install for"),
72
+ project: bool = typer.Option(False, help="Install at project level (.claude/ etc.)"),
73
+ ):
74
+ """Register SKILL.md with AI coding tools (Claude Code, Cursor, etc.)"""
75
+ from memctrl.installer import install_skill
76
+ paths = install_skill(tool=tool, project=project, verbose=True)
77
+ if paths:
78
+ console.print(f"\n[green]Installed to {len(paths)} location(s)[/green]")
79
+ else:
80
+ console.print("\n[yellow]No tools installed. See paths above.[/yellow]")
81
+
82
+
83
+ @app.command()
84
+ def init(
85
+ force: bool = typer.Option(False, help="Overwrite existing .memoryrc"),
86
+ ):
87
+ """Create .memoryrc in current directory"""
88
+ dest = Path(".memoryrc")
89
+ if dest.exists() and not force:
90
+ console.print(f"[yellow]{dest} already exists. Use --force to overwrite.[/yellow]")
91
+ raise typer.Exit(1)
92
+
93
+ example = Path(__file__).parent / ".memoryrc.example"
94
+ if example.exists():
95
+ content = example.read_text()
96
+ else:
97
+ content = _default_memoryrc()
98
+
99
+ dest.write_text(content)
100
+ console.print(f"[green]Created {dest}[/green]")
101
+
102
+
103
+ @app.command()
104
+ def add(
105
+ content: str = typer.Argument(..., help="Memory content to store"),
106
+ layer: str = typer.Option("session", help="Layer: project/session/user"),
107
+ source: str = typer.Option("manual", help="Source of this memory"),
108
+ ):
109
+ """Manually add a memory"""
110
+ store = _get_store()
111
+ mid = store.insert_memory(layer=layer, content=content, source=source)
112
+ console.print(f"[green]Added memory[/green] [dim]{mid}[/dim] to [bold]{layer}[/bold]")
113
+
114
+
115
+ @app.command()
116
+ def query(
117
+ query_text: str = typer.Argument(..., help="Query to search memory"),
118
+ layer: Optional[str] = typer.Option(None, help="Filter by layer"),
119
+ ):
120
+ """Retrieve relevant memories with reasoning trace"""
121
+ store = _get_store()
122
+ engine = _get_engine()
123
+ rules = engine.load()
124
+
125
+ memories = store.list_memories(layer=layer)
126
+ if not memories:
127
+ console.print("[yellow]No memories found.[/yellow]")
128
+ return
129
+
130
+ mem_dicts = [m.to_dict() for m in memories]
131
+ memory_lookup = {m.id: m.to_dict() for m in memories}
132
+
133
+ # Build tree
134
+ from memctrl.tree import MemoryTreeBuilder
135
+ builder = MemoryTreeBuilder()
136
+
137
+ async def _do_query():
138
+ tree = await builder.build_tree(mem_dicts)
139
+ tree_dict = tree.to_dict()
140
+
141
+ # Retrieve
142
+ from memctrl.retriever import MemoryRetriever
143
+ retriever = MemoryRetriever()
144
+ result = await retriever.retrieve(query_text, tree_dict, memory_lookup=memory_lookup)
145
+ return result
146
+
147
+ result = asyncio.run(_do_query())
148
+
149
+ if result.facts:
150
+ console.print(Panel(f"[bold]Query:[/bold] {query_text}", title="memctrl"))
151
+ console.print(f"\n[bold green]Facts:[/bold green]")
152
+ for i, fact in enumerate(result.facts, 1):
153
+ console.print(f" {i}. {fact}")
154
+ console.print(f"\n[bold blue]Trace:[/bold blue] {' -> '.join(result.trace)}")
155
+ console.print(f"[bold]Confidence:[/bold] {result.confidence:.2f}")
156
+ else:
157
+ console.print(f"[yellow]No relevant memories found for:[/yellow] {query_text}")
158
+
159
+
160
+ @app.command("list")
161
+ def list_memories(
162
+ layer: Optional[str] = typer.Option(None, help="Filter by layer"),
163
+ limit: int = typer.Option(50, help="Max results"),
164
+ ):
165
+ """List all memories"""
166
+ store = _get_store()
167
+ memories = store.list_memories(layer=layer)[:limit]
168
+
169
+ if not memories:
170
+ console.print("[yellow]No memories found.[/yellow]")
171
+ return
172
+
173
+ table = Table(title="Memories", show_lines=True)
174
+ table.add_column("ID", style="dim", max_width=8)
175
+ table.add_column("Layer", style="cyan")
176
+ table.add_column("Content", max_width=60)
177
+ table.add_column("Source", style="green")
178
+ table.add_column("Conf", justify="right")
179
+
180
+ for mem in memories:
181
+ table.add_row(
182
+ mem.id[:8],
183
+ mem.layer,
184
+ mem.content[:80],
185
+ mem.source,
186
+ f"{mem.confidence:.1f}",
187
+ )
188
+ console.print(table)
189
+
190
+
191
+ @app.command()
192
+ def tree():
193
+ """Display memory tree (rich formatted)"""
194
+ store = _get_store()
195
+ memories = store.list_memories()
196
+
197
+ if not memories:
198
+ console.print("[yellow]No memories to display.[/yellow]")
199
+ return
200
+
201
+ from memctrl.tree import MemoryTreeBuilder
202
+ builder = MemoryTreeBuilder()
203
+
204
+ async def _do_tree():
205
+ mem_dicts = [m.to_dict() for m in memories]
206
+ root = await builder.build_tree(mem_dicts)
207
+ return root
208
+
209
+ root = asyncio.run(_do_tree())
210
+
211
+ def _build_rich(node, rich_node):
212
+ for child in node.children:
213
+ if child.is_leaf() or not child.children:
214
+ label = f"[dim][mem][/dim] {child.title[:50]}"
215
+ else:
216
+ label = f"[bold][dir] {child.title}[/bold]"
217
+ if child.confidence < 1.0:
218
+ label += f" [dim](conf: {child.confidence})[/dim]"
219
+ branch = rich_node.add(label)
220
+ _build_rich(child, branch)
221
+
222
+ rich_root = RichTree("[bold]Memory Tree[/bold]")
223
+ _build_rich(root, rich_root)
224
+ console.print(rich_root)
225
+
226
+
227
+ @app.command()
228
+ def forget(
229
+ memory_id: str = typer.Argument(..., help="Memory ID to forget"),
230
+ ):
231
+ """Remove a memory by ID"""
232
+ store = _get_store()
233
+ if store.delete_memory(memory_id):
234
+ console.print(f"[green]Forgot memory[/green] [dim]{memory_id}[/dim]")
235
+ else:
236
+ console.print(f"[red]Memory not found:[/red] {memory_id}")
237
+
238
+
239
+ @app.command()
240
+ def clear(
241
+ layer: Optional[str] = typer.Option(None, help="Clear specific layer"),
242
+ yes: bool = typer.Option(False, "--yes", help="Skip confirmation"),
243
+ ):
244
+ """Clear memories (all or by layer)"""
245
+ store = _get_store()
246
+ memories = store.list_memories(layer=layer)
247
+ count = len(memories)
248
+
249
+ if count == 0:
250
+ console.print("[yellow]No memories to clear.[/yellow]")
251
+ return
252
+
253
+ target = f"'{layer}' layer" if layer else "ALL memories"
254
+ if not yes:
255
+ confirm = typer.confirm(f"Clear {target}? ({count} memories)")
256
+ if not confirm:
257
+ console.print("Cancelled.")
258
+ return
259
+
260
+ if layer:
261
+ for mem in memories:
262
+ store.delete_memory(mem.id)
263
+ else:
264
+ for mem in memories:
265
+ store.delete_memory(mem.id)
266
+
267
+ console.print(f"[green]Cleared {count} memories.[/green]")
268
+
269
+
270
+ @app.command()
271
+ def trigger_cmd(
272
+ event: str = typer.Argument(..., help="Event name (e.g., on_session_end)"),
273
+ context: Optional[str] = typer.Option(None, help="JSON context string"),
274
+ ):
275
+ """Manually fire a trigger"""
276
+ store = _get_store()
277
+ engine = _get_engine()
278
+ rules = engine.load()
279
+
280
+ ctx = {}
281
+ if context:
282
+ try:
283
+ ctx = json.loads(context)
284
+ except json.JSONDecodeError:
285
+ console.print("[red]Invalid JSON context[/red]")
286
+ return
287
+
288
+ ids = engine.fire_trigger(event, ctx, store)
289
+ console.print(f"[green]Trigger '{event}' fired[/green] - {len(ids)} memories affected")
290
+
291
+
292
+ @app.command()
293
+ def audit(
294
+ limit: int = typer.Option(50, help="Number of log entries to show"),
295
+ ):
296
+ """Show audit log of triggers"""
297
+ store = _get_store()
298
+ logs = store.get_trigger_log(limit=limit)
299
+
300
+ if not logs:
301
+ console.print("[yellow]No audit entries.[/yellow]")
302
+ return
303
+
304
+ table = Table(title="Trigger Audit Log")
305
+ table.add_column("Timestamp", style="dim")
306
+ table.add_column("Event", style="cyan")
307
+ table.add_column("Action")
308
+ table.add_column("Memories", justify="right")
309
+
310
+ for log in logs:
311
+ table.add_row(
312
+ log.timestamp.strftime("%Y-%m-%d %H:%M"),
313
+ log.event,
314
+ log.action,
315
+ str(len(log.memories_affected)),
316
+ )
317
+ console.print(table)
318
+
319
+
320
+ @app.command()
321
+ def heatmap():
322
+ """Show memory distribution heatmap by layer and tags"""
323
+ store = _get_store()
324
+ memories = store.list_memories()
325
+
326
+ if not memories:
327
+ console.print("[yellow]No memories found.[/yellow]")
328
+ return
329
+
330
+ console.print(Panel("[bold]Memory Heatmap[/bold]", border_style="cyan"))
331
+
332
+ # Layer distribution
333
+ console.print("\n[bold]By Layer:[/bold]")
334
+ by_layer: dict = {}
335
+ for mem in memories:
336
+ by_layer[mem.layer] = by_layer.get(mem.layer, 0) + 1
337
+ total = len(memories)
338
+ for layer, count in sorted(by_layer.items(), key=lambda x: -x[1]):
339
+ pct = count / total * 100
340
+ bar_len = int(pct / 5)
341
+ bar = "█" * bar_len + "░" * (20 - bar_len)
342
+ color = "green" if layer == "project" else "yellow" if layer == "session" else "blue"
343
+ console.print(f" [{color}]{layer:10}[/{color}] {bar} {count:3} ({pct:.0f}%)")
344
+
345
+ # Tag distribution
346
+ tag_counts: dict = {}
347
+ for mem in memories:
348
+ for tag in mem.tags:
349
+ tag_counts[tag] = tag_counts.get(tag, 0) + 1
350
+
351
+ if tag_counts:
352
+ console.print("\n[bold]By Tag:[/bold]")
353
+ for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]:
354
+ bar_len = min(count, 20)
355
+ bar = "█" * bar_len + "░" * (20 - bar_len)
356
+ console.print(f" {tag:15} {bar} {count}")
357
+
358
+ console.print(f"\n[dim]Total: {total} memories[/dim]")
359
+
360
+
361
+ @app.command()
362
+ def timeline(
363
+ limit: int = typer.Option(20, help="Max events to show"),
364
+ ):
365
+ """Show chronological memory timeline"""
366
+ store = _get_store()
367
+ memories = store.list_memories()[:limit]
368
+ logs = store.get_trigger_log(limit=limit)
369
+
370
+ if not memories and not logs:
371
+ console.print("[yellow]No timeline events.[/yellow]")
372
+ return
373
+
374
+ console.print(Panel("[bold]Memory Timeline[/bold]", border_style="cyan"))
375
+
376
+ # Merge and sort events
377
+ events = []
378
+ for mem in memories:
379
+ events.append({
380
+ "ts": mem.created_at,
381
+ "type": "memory",
382
+ "layer": mem.layer,
383
+ "content": mem.content[:60],
384
+ "icon": "[mem]",
385
+ })
386
+ for log in logs:
387
+ events.append({
388
+ "ts": log.timestamp,
389
+ "type": "trigger",
390
+ "layer": "",
391
+ "content": f"{log.event}: {log.action}",
392
+ "icon": "[refl]",
393
+ })
394
+
395
+ events.sort(key=lambda x: x["ts"], reverse=True)
396
+
397
+ for ev in events[:limit]:
398
+ ts = ev["ts"].strftime("%Y-%m-%d %H:%M") if ev["ts"] else "?"
399
+ if ev["type"] == "memory":
400
+ color = "green" if ev["layer"] == "project" else "yellow" if ev["layer"] == "session" else "blue"
401
+ console.print(f" [dim]{ts}[/dim] [{color}]{ev['icon']}[/{color}] ({ev['layer']}) {ev['content']}")
402
+ else:
403
+ console.print(f" [dim]{ts}[/dim] [magenta]{ev['icon']}[/magenta] {ev['content']}")
404
+
405
+
406
+ @app.command()
407
+ def serve(
408
+ port: int = typer.Option(8080, help="Port to run MCP server on"),
409
+ host: str = typer.Option("127.0.0.1", help="Host to bind to"),
410
+ ):
411
+ """Start MCP server"""
412
+ console.print(f"[green]Starting MCP server on {host}:{port}[/green]")
413
+ console.print("[dim]Use Ctrl+C to stop[/dim]")
414
+
415
+ from memctrl.mcp_server import serve_mcp
416
+ asyncio.run(serve_mcp(host=host, port=port))
417
+
418
+
419
+ # ---------------------------------------------------------------------------
420
+ # Default .memoryrc content
421
+ # ---------------------------------------------------------------------------
422
+
423
+ def _default_memoryrc() -> str:
424
+ return '''# MemCtrl configuration
425
+
426
+ [layers]
427
+ project = "architecture decisions, tech stack, ADRs, why we chose X"
428
+ session = "current task, WIP, what was done this session"
429
+ user = "preferences, working style, patterns, personal rules"
430
+
431
+ [triggers]
432
+ on_commit = "consolidate session -> project"
433
+ on_session_end = "summarize session -> user"
434
+ 'on_file "docs/ADR-*.md"' = "extract -> project"
435
+ 'on_file "*.md"' = "extract -> project if contains decision"
436
+
437
+ [forget]
438
+ never = ["passwords", "keys", "PII", "secrets"]
439
+ after_days = { session = 7, user = 90 }
440
+
441
+ [extract]
442
+ confidence = { explicit = 1.0, inferred = 0.7, mentioned = 0.5 }
443
+ '''