mnemo-mcp-agent 0.3.2__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.
mnemo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mnemo — cross-platform local bitemporal long-term memory for AI agents."""
2
+
3
+ __version__ = "0.1.0"
mnemo/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package."""
mnemo/cli/main.py ADDED
@@ -0,0 +1,512 @@
1
+ """Mnemo CLI — Borderless Matrix interface.
2
+
3
+ Commands:
4
+ init Initialise the memory database.
5
+ remember Store a new fact (AUDN pipeline).
6
+ search Hybrid 3-channel search.
7
+ invalidate Soft-delete a fact.
8
+ tier-decay Recompute all salience scores and migrate tiers.
9
+ debt Detect and display knowledge debt.
10
+ serve Start the MCP stdio server.
11
+ stats Show memory statistics.
12
+ visualize Generate interactive knowledge Sankey diagram.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from typing import Any
19
+
20
+ import typer
21
+ from rich import box
22
+ from rich.console import Console
23
+ from rich.table import Table
24
+
25
+ # Ensure UTF-8 output encoding across Windows legacy consoles
26
+ if sys.platform == "win32":
27
+ try:
28
+ if hasattr(sys.stdout, "reconfigure"):
29
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
30
+ if hasattr(sys.stderr, "reconfigure"):
31
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
32
+ except Exception:
33
+ pass
34
+
35
+ app = typer.Typer(
36
+ name="mnemo",
37
+ help="Mnemo -- bitemporal long-term memory for AI agents.",
38
+ rich_markup_mode="rich",
39
+ no_args_is_help=True,
40
+ )
41
+ console = Console(highlight=False)
42
+
43
+
44
+ def _safe_str(val: Any) -> str:
45
+ """Format string safely for display."""
46
+ return str(val)
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # init
51
+ # ---------------------------------------------------------------------------
52
+ @app.command()
53
+ def init(
54
+ db_path: str = typer.Option(
55
+ "",
56
+ "--db",
57
+ help="Path to SQLite database. Leave empty for default (~/.mnemo/memory.db).",
58
+ ),
59
+ ) -> None:
60
+ """Initialise (or re-initialise) the Mnemo memory database."""
61
+ from mnemo.storage.connection import Database, default_db_path
62
+
63
+ target = db_path if db_path else str(default_db_path())
64
+ Database(db_path=target)
65
+ console.print("→ [bold]mnemo init[/bold]")
66
+ console.print(f" [dim]database[/dim] {_safe_str(target)}")
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # remember
71
+ # ---------------------------------------------------------------------------
72
+ @app.command()
73
+ def remember(
74
+ text: str = typer.Argument(..., help="Fact text to store."),
75
+ category: str = typer.Option("general", "-c", "--category", help="Fact category."),
76
+ db_path: str = typer.Option("", "--db", help="Database path."),
77
+ ) -> None:
78
+ """Store a new fact through the AUDN pipeline."""
79
+ from mnemo.core.models import AUDNOperation
80
+ from mnemo.engine.audn import AUDNClassifier
81
+ from mnemo.serialization.toon import encode_fact
82
+ from mnemo.storage.connection import Database, default_db_path
83
+ from mnemo.storage.vector_store import VectorStore, create_embedder
84
+
85
+ target = db_path if db_path else str(default_db_path())
86
+ db = Database(db_path=target)
87
+ vs = VectorStore(create_embedder())
88
+ audn = AUDNClassifier(vs)
89
+
90
+ with db.session() as conn:
91
+ op, existing_id = audn.classify(text, category, conn)
92
+
93
+ if op == AUDNOperation.ADD:
94
+ fact = audn.execute_add(text, category, conn)
95
+ console.print("→ [bold]mnemo remember[/bold] [green]add[/green]")
96
+ console.print(
97
+ f" [dim]fact[/dim] {encode_fact(fact.model_dump(exclude={'embedding'}))}"
98
+ )
99
+ elif op == AUDNOperation.UPDATE and existing_id:
100
+ fact = audn.execute_update(existing_id, text, category, conn)
101
+ console.print("→ [bold]mnemo remember[/bold] [yellow]update[/yellow]")
102
+ console.print(f" [dim]old[/dim] {existing_id[:8]}..")
103
+ console.print(
104
+ f" [dim]new[/dim] {encode_fact(fact.model_dump(exclude={'embedding'}))}"
105
+ )
106
+ elif op == AUDNOperation.NOOP and existing_id:
107
+ audn.execute_noop(existing_id, conn)
108
+ console.print("→ [bold]mnemo remember[/bold] [dim]noop[/dim]")
109
+ console.print(f" [dim]reinforced[/dim] {existing_id[:8]}..")
110
+ else:
111
+ console.print(f"→ [bold]mnemo remember[/bold] [dim]{op.value}[/dim]")
112
+
113
+ db.close()
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # search
118
+ # ---------------------------------------------------------------------------
119
+ @app.command()
120
+ def search(
121
+ query: str = typer.Argument(..., help="Search query."),
122
+ limit: int = typer.Option(10, "-n", "--limit", help="Max results."),
123
+ min_tier: str = typer.Option(
124
+ "archived", "--min-tier", help="Minimum tier: core, working, peripheral, archived."
125
+ ),
126
+ db_path: str = typer.Option("", "--db", help="Database path."),
127
+ ) -> None:
128
+ """Hybrid 3-channel search (Vector + FTS5 + Graph + RRF)."""
129
+ from mnemo.core.models import MemoryTier
130
+ from mnemo.engine.retriever import HybridRetriever
131
+ from mnemo.storage.connection import Database, default_db_path
132
+ from mnemo.storage.fts_store import FTSStore
133
+ from mnemo.storage.graph_store import GraphStore
134
+ from mnemo.storage.vector_store import VectorStore, create_embedder
135
+
136
+ target = db_path if db_path else str(default_db_path())
137
+ db = Database(db_path=target)
138
+ vs = VectorStore(create_embedder())
139
+ retriever = HybridRetriever(vs, FTSStore(), GraphStore())
140
+ tier = MemoryTier(min_tier.lower())
141
+
142
+ with db.session() as conn:
143
+ results = retriever.search(query, conn, limit=limit, min_tier=tier)
144
+
145
+ if not results:
146
+ console.print("[dim]No matching memories found.[/dim]")
147
+ db.close()
148
+ return
149
+
150
+ table = Table(
151
+ title="→ [bold]Search Results[/bold]",
152
+ title_justify="left",
153
+ box=box.SIMPLE_HEAD,
154
+ show_header=True,
155
+ header_style="bold dim",
156
+ padding=(0, 1),
157
+ )
158
+ table.add_column("#", style="dim", width=4)
159
+ table.add_column("Score", justify="right", width=10)
160
+ table.add_column("Tier", width=10)
161
+ table.add_column("Category", width=12)
162
+ table.add_column("Text", ratio=1)
163
+ table.add_column("ID", style="dim", width=10)
164
+
165
+ for i, r in enumerate(results, 1):
166
+ tier_style = {
167
+ "core": "bold green",
168
+ "working": "bold yellow",
169
+ "peripheral": "dim cyan",
170
+ "archived": "dim",
171
+ }.get(r.fact.tier.value, "")
172
+ table.add_row(
173
+ str(i),
174
+ f"{r.score:.6f}",
175
+ f"[{tier_style}]{r.fact.tier.value}[/{tier_style}]",
176
+ r.fact.category,
177
+ r.fact.text[:80],
178
+ r.fact.id[:8] + "..",
179
+ )
180
+
181
+ console.print()
182
+ console.print(table)
183
+ console.print()
184
+ db.close()
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # invalidate
189
+ # ---------------------------------------------------------------------------
190
+ @app.command()
191
+ def invalidate(
192
+ fact_id: str = typer.Argument(..., help="UUID of the fact to invalidate."),
193
+ db_path: str = typer.Option("", "--db", help="Database path."),
194
+ ) -> None:
195
+ """Soft-delete a fact (close its valid_end)."""
196
+ from mnemo.engine.audn import AUDNClassifier
197
+ from mnemo.storage.connection import Database, default_db_path
198
+ from mnemo.storage.vector_store import VectorStore, create_embedder
199
+
200
+ target = db_path if db_path else str(default_db_path())
201
+ db = Database(db_path=target)
202
+ vs = VectorStore(create_embedder())
203
+ audn = AUDNClassifier(vs)
204
+
205
+ with db.session() as conn:
206
+ audn.execute_delete(fact_id, conn)
207
+
208
+ console.print("→ [bold]mnemo invalidate[/bold]")
209
+ console.print(f" [dim]invalidated[/dim] [red]{fact_id[:8]}..[/red]")
210
+ db.close()
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # tier-decay
215
+ # ---------------------------------------------------------------------------
216
+ @app.command("tier-decay")
217
+ def tier_decay(
218
+ db_path: str = typer.Option("", "--db", help="Database path."),
219
+ ) -> None:
220
+ """Recompute all salience scores and migrate tiers."""
221
+ from mnemo.engine.tier_manager import TierManager
222
+ from mnemo.storage.connection import Database, default_db_path
223
+
224
+ target = db_path if db_path else str(default_db_path())
225
+ db = Database(db_path=target)
226
+ mgr = TierManager()
227
+
228
+ with db.session() as conn:
229
+ result = mgr.decay_all(conn)
230
+
231
+ console.print("→ [bold]mnemo tier-decay[/bold]")
232
+ console.print(f" [dim]processed[/dim] [green]{result['processed']}[/green]")
233
+ console.print(f" [dim]migrated[/dim] [yellow]{result['migrated']}[/yellow]")
234
+ db.close()
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # debt
239
+ # ---------------------------------------------------------------------------
240
+ @app.command()
241
+ def debt(
242
+ db_path: str = typer.Option("", "--db", help="Database path."),
243
+ ) -> None:
244
+ """Detect and display knowledge / architectural debt."""
245
+ from mnemo.engine.tier_manager import TierManager
246
+ from mnemo.storage.connection import Database, default_db_path
247
+
248
+ target = db_path if db_path else str(default_db_path())
249
+ db = Database(db_path=target)
250
+ mgr = TierManager()
251
+
252
+ with db.session() as conn:
253
+ mgr.decay_all(conn)
254
+ items = mgr.detect_debt(conn)
255
+ if items:
256
+ mgr.persist_debt(items, conn)
257
+
258
+ if not items:
259
+ console.print("→ [bold]mnemo debt[/bold]")
260
+ console.print(" [green]No debt detected.[/green]")
261
+ db.close()
262
+ return
263
+
264
+ table = Table(
265
+ title="→ [bold]Debt Ledger[/bold]",
266
+ title_justify="left",
267
+ box=box.SIMPLE_HEAD,
268
+ show_header=True,
269
+ header_style="bold dim",
270
+ padding=(0, 1),
271
+ )
272
+ table.add_column("Ceiling", width=10)
273
+ table.add_column("Trigger", width=20)
274
+ table.add_column("Context", ratio=1)
275
+
276
+ for item in items:
277
+ ceiling_style = {
278
+ "critical": "bold red",
279
+ "high": "red",
280
+ "medium": "yellow",
281
+ "low": "dim",
282
+ }.get(item.ceiling, "")
283
+ table.add_row(
284
+ f"[{ceiling_style}]{item.ceiling}[/{ceiling_style}]",
285
+ item.trigger,
286
+ item.code_context,
287
+ )
288
+
289
+ console.print()
290
+ console.print(table)
291
+ console.print()
292
+ db.close()
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # serve
297
+ # ---------------------------------------------------------------------------
298
+ @app.command()
299
+ def serve() -> None:
300
+ """Start the Mnemo MCP server (stdio transport)."""
301
+ err_console = Console(stderr=True, highlight=False)
302
+ err_console.print("→ [bold]mnemo serve[/bold]")
303
+ err_console.print(" [dim]transport[/dim] stdio")
304
+ err_console.print(" [dim]status[/dim] running (press Ctrl+C to stop)")
305
+ from mnemo.mcp.server import run_server
306
+
307
+ run_server()
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # stats
312
+ # ---------------------------------------------------------------------------
313
+ @app.command()
314
+ def stats(
315
+ db_path: str = typer.Option("", "--db", help="Database path."),
316
+ ) -> None:
317
+ """Show memory store statistics."""
318
+ import time
319
+
320
+ from mnemo.storage.connection import Database, default_db_path
321
+
322
+ target = db_path if db_path else str(default_db_path())
323
+ db = Database(db_path=target)
324
+ now = time.time()
325
+
326
+ with db.session() as conn:
327
+ total = conn.execute("SELECT COUNT(*) AS c FROM facts WHERE ingest_end IS NULL").fetchone()
328
+ active = conn.execute(
329
+ "SELECT COUNT(*) AS c FROM facts WHERE ingest_end IS NULL "
330
+ "AND (valid_end IS NULL OR valid_end > ?)",
331
+ (now,),
332
+ ).fetchone()
333
+ tiers = conn.execute(
334
+ "SELECT tier, COUNT(*) AS c FROM facts "
335
+ "WHERE ingest_end IS NULL AND (valid_end IS NULL OR valid_end > ?) "
336
+ "GROUP BY tier ORDER BY tier",
337
+ (now,),
338
+ ).fetchall()
339
+ entities_count = conn.execute(
340
+ "SELECT COUNT(*) AS c FROM entities WHERE ingest_end IS NULL "
341
+ "AND (valid_end IS NULL OR valid_end > ?)",
342
+ (now,),
343
+ ).fetchone()
344
+ relations_count = conn.execute(
345
+ "SELECT COUNT(*) AS c FROM relations WHERE ingest_end IS NULL "
346
+ "AND (valid_end IS NULL OR valid_end > ?)",
347
+ (now,),
348
+ ).fetchone()
349
+ debt_count = conn.execute("SELECT COUNT(*) AS c FROM debt_ledger").fetchone()
350
+
351
+ table = Table(
352
+ title="→ [bold]Mnemo Memory Stats[/bold]",
353
+ title_justify="left",
354
+ box=box.SIMPLE_HEAD,
355
+ show_header=True,
356
+ header_style="bold dim",
357
+ padding=(0, 1),
358
+ )
359
+ table.add_column("Metric", style="bold", ratio=1)
360
+ table.add_column("Value", justify="right", width=14)
361
+
362
+ table.add_row("Total facts (all versions)", str(total["c"]))
363
+ table.add_row("Active facts", str(active["c"]))
364
+ for t in tiers:
365
+ table.add_row(f" · Tier: {t['tier']}", str(t["c"]))
366
+ table.add_row("Active entities", str(entities_count["c"]))
367
+ table.add_row("Active relations", str(relations_count["c"]))
368
+ table.add_row("Debt ledger items", str(debt_count["c"]))
369
+ table.add_row("Database", _safe_str(target))
370
+
371
+ console.print()
372
+ console.print(table)
373
+ console.print()
374
+ db.close()
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # visualize
379
+ # ---------------------------------------------------------------------------
380
+ @app.command()
381
+ def visualize(
382
+ output: str = typer.Option(
383
+ "./mnemo_graph.html",
384
+ "-o",
385
+ "--output",
386
+ help="Path to output HTML file.",
387
+ ),
388
+ open_browser: bool = typer.Option(
389
+ True,
390
+ "--open/--no-open",
391
+ help="Automatically open generated HTML visualization in default browser.",
392
+ ),
393
+ format_type: str = typer.Option(
394
+ "html",
395
+ "--format",
396
+ "-f",
397
+ help="Visualization format: 'html' (interactive Sankey) or 'mermaid' (terminal output).",
398
+ ),
399
+ db_path: str = typer.Option("", "--db", help="Database path."),
400
+ ) -> None:
401
+ """Generate an interactive Dark Minimal Sankey flow diagram of the memory graph."""
402
+ import webbrowser
403
+ from pathlib import Path
404
+
405
+ from mnemo.cli.visualize import (
406
+ export_knowledge_sankey_data,
407
+ generate_html_report,
408
+ generate_mermaid_sankey,
409
+ )
410
+ from mnemo.storage.connection import Database, default_db_path
411
+
412
+ target_db = db_path if db_path else str(default_db_path())
413
+ db = Database(db_path=target_db)
414
+
415
+ with db.session() as conn:
416
+ data = export_knowledge_sankey_data(conn)
417
+
418
+ db.close()
419
+
420
+ if format_type.lower() == "mermaid":
421
+ mermaid_code = generate_mermaid_sankey(data)
422
+ console.print(mermaid_code)
423
+ return
424
+
425
+ html_content = generate_html_report(data)
426
+ out_path = Path(output).resolve()
427
+ out_path.parent.mkdir(parents=True, exist_ok=True)
428
+ out_path.write_text(html_content, encoding="utf-8")
429
+
430
+ console.print("→ [bold]mnemo visualize[/bold]")
431
+ console.print(f" [dim]nodes[/dim] {len(data['nodes'])}")
432
+ console.print(f" [dim]active flows[/dim] {len(data['values'])}")
433
+ console.print(f" [dim]core memory[/dim] {data['metrics']['core_pct']}%")
434
+ console.print()
435
+ console.print(f'the result is available by path "{_safe_str(out_path)}"')
436
+
437
+ if open_browser:
438
+ webbrowser.open(out_path.as_uri())
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # doctor
443
+ # ---------------------------------------------------------------------------
444
+ @app.command()
445
+ def doctor(
446
+ db_path: str = typer.Option("", "--db", help="Database path."),
447
+ ) -> None:
448
+ """Diagnose database health, FTS5 index integrity, and graph statistics."""
449
+ from mnemo.storage.connection import Database, default_db_path
450
+
451
+ target = db_path if db_path else str(default_db_path())
452
+ db = Database(db_path=target)
453
+ health = db.check_integrity()
454
+
455
+ status_color = "green" if health["integrity_ok"] else "red"
456
+ fts_color = "green" if health["fts_in_sync"] else "yellow"
457
+
458
+ console.print("→ [bold]mnemo doctor[/bold]")
459
+ console.print(f" [dim]database[/dim] {_safe_str(target)}")
460
+ console.print(
461
+ f" [dim]schema version[/dim] v{health['schema_version']} (target v{health['target_version']})"
462
+ )
463
+ console.print(
464
+ f" [dim]sqlite integrity[/dim][{status_color}] {'ok' if health['integrity_ok'] else 'corrupted'}[/{status_color}]"
465
+ )
466
+ console.print(f" [dim]journal mode[/dim] {health['journal_mode']}")
467
+ console.print(
468
+ f" [dim]fts5 sync[/dim] [{fts_color}] {'synchronized' if health['fts_in_sync'] else 'repaired'}[/{fts_color}]"
469
+ )
470
+
471
+ counts = health.get("table_counts", {})
472
+ console.print(f" [dim]active facts[/dim] {counts.get('facts', 0)}")
473
+ console.print(f" [dim]entities[/dim] {counts.get('entities', 0)}")
474
+ console.print(f" [dim]relations[/dim] {counts.get('relations', 0)}")
475
+ console.print(f" [dim]cached files[/dim] {counts.get('file_scan_cache', 0)}")
476
+ db.close()
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # scan
481
+ # ---------------------------------------------------------------------------
482
+ @app.command()
483
+ def scan(
484
+ path: str = typer.Argument(".", help="Project directory path to scan."),
485
+ db_path: str = typer.Option("", "--db", help="Database path."),
486
+ force: bool = typer.Option(False, "--force", "-f", help="Force re-scan all files."),
487
+ ) -> None:
488
+ """Perform an incremental AST scan to extract modules, classes, and dependencies."""
489
+ from mnemo.engine.scanner import ProjectScanner
490
+ from mnemo.storage.connection import Database, default_db_path
491
+
492
+ target = db_path if db_path else str(default_db_path())
493
+ db = Database(db_path=target)
494
+ scanner = ProjectScanner(root_path=path)
495
+
496
+ with db.session() as conn:
497
+ res = scanner.scan(conn, force=force)
498
+
499
+ console.print("→ [bold]mnemo scan[/bold]")
500
+ console.print(f" [dim]scanned files[/dim] {res['scanned_files']}")
501
+ console.print(f" [dim]skipped files[/dim] {res['skipped_files']}")
502
+ console.print(f" [dim]entities added[/dim] {res['entities_added']}")
503
+ console.print(f" [dim]relations added[/dim] {res['relations_added']}")
504
+ db.close()
505
+
506
+
507
+ # ---------------------------------------------------------------------------
508
+ # Entry point
509
+ # ---------------------------------------------------------------------------
510
+
511
+ if __name__ == "__main__":
512
+ app()