memdebug 0.2.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.
memdebug/cli.py ADDED
@@ -0,0 +1,857 @@
1
+ import functools
2
+ import os
3
+ import re
4
+ import tempfile
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from .adapters.markdown_git import MarkdownGitAdapter
13
+ from .adapters.mem0 import Mem0Adapter, build_mem0_memory, validate_scope
14
+ from .adapters.openwebui import OpenWebUIAdapter
15
+ from .adapters.restore import Plan, Restorer
16
+ from .agents import scan_agents
17
+ from .agents import summary_lines as agent_lines
18
+ from .describe import describe_event
19
+ from .diff import Diff, diff_snapshots, line_diff
20
+ from .docker_source import valid_container
21
+ from .errors import MemdebugError
22
+ from .ledger import Ledger
23
+ from .monitor import baseline, check_all, status_lines, store_hints, summary_lines, watch
24
+ from .paths import default_ledger_path
25
+ from .report import RENDERERS, build_report, write_report
26
+ from .rollback_flow import run_rollback
27
+ from .stores import (
28
+ KIND_NAMES,
29
+ KINDS,
30
+ StoreConfig,
31
+ detect_kind,
32
+ discover_docker,
33
+ load_registry,
34
+ open_store,
35
+ save_registry,
36
+ valid_name,
37
+ )
38
+ from .sync import sync
39
+ from .textsafe import console_safe, safe_text
40
+ from .witness import SAME_DISK_WARNING, append_witness, same_disk, verify_witness
41
+
42
+ app = typer.Typer(help="Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral.", no_args_is_help=True)
43
+
44
+
45
+ def _show_version(show: bool) -> None:
46
+ if show:
47
+ from . import __version__
48
+
49
+ typer.echo(f"memdebug {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def _main(version: bool = typer.Option(False, "--version", callback=_show_version, is_eager=True, help="Show the version and exit.")):
55
+ pass
56
+
57
+ DB_OPTION = typer.Option(
58
+ None, "--db", help="Path to the ledger file (default: a per-user data folder, see 'memdebug where')."
59
+ )
60
+
61
+
62
+ def _open_ledger(db: Optional[Path]) -> Ledger:
63
+ if db is None:
64
+ db = default_ledger_path()
65
+ try:
66
+ db.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
67
+ except OSError as exc:
68
+ raise MemdebugError(f"cannot create the default ledger folder: {exc.strerror}") from exc
69
+ return Ledger(db)
70
+
71
+
72
+ def echo(text: str, **kwargs) -> None:
73
+ typer.echo(console_safe(text), **kwargs)
74
+
75
+
76
+ def guarded(func):
77
+ """Turn expected errors into a clean message and exit code, and hide tracebacks that
78
+ could expose paths or values unless MEMDEBUG_DEBUG is set."""
79
+
80
+ @functools.wraps(func)
81
+ def wrapper(*args, **kwargs):
82
+ try:
83
+ return func(*args, **kwargs)
84
+ except MemdebugError as exc:
85
+ echo(f"error: {safe_text(exc, 500)}", err=True)
86
+ raise typer.Exit(2) from None
87
+ except (typer.Exit, typer.Abort, KeyboardInterrupt):
88
+ raise
89
+ except Exception as exc:
90
+ if os.environ.get("MEMDEBUG_DEBUG"):
91
+ raise
92
+ echo(
93
+ f"unexpected error ({type(exc).__name__}); set MEMDEBUG_DEBUG=1 to see details",
94
+ err=True,
95
+ )
96
+ raise typer.Exit(70) from None
97
+
98
+ return wrapper
99
+
100
+
101
+ @app.command()
102
+ @guarded
103
+ def timeline(db: Path = DB_OPTION, limit: int = typer.Option(50, min=1, max=10_000, help="Newest entries to show.")):
104
+ """Show memory events, newest first. Memory text is shown escaped, never raw."""
105
+ entries = _open_ledger(db).entries()[::-1][:limit]
106
+ if not entries:
107
+ echo("The ledger is empty.")
108
+ return
109
+ for entry in entries:
110
+ event = entry.event
111
+ text = describe_event(event)
112
+ flag = " [untrusted source]" if event.trust.value == "untrusted" else ""
113
+ echo(
114
+ f"{safe_text(entry.id, 12):>6} {event.ts:%Y-%m-%d %H:%M} {event.op.value:<8} "
115
+ f"{safe_text(event.memory_id, 36)} {safe_text(text, 60)}{flag}"
116
+ )
117
+
118
+
119
+ @app.command()
120
+ @guarded
121
+ def verify(
122
+ db: Path = DB_OPTION,
123
+ expected_head: Optional[str] = typer.Option(None, help="Head hash recorded in a snapshot."),
124
+ witness: Optional[Path] = typer.Option(None, "--witness", help="Also check the ledger against this witness file."),
125
+ ):
126
+ """Check that the ledger has not been edited (and, with --witness, not rewritten or cut short)."""
127
+ ledger = _open_ledger(db)
128
+ result = ledger.verify(expected_head)
129
+ failed = False
130
+ if result.ok:
131
+ echo("Ledger intact.")
132
+ else:
133
+ failed = True
134
+ for problem in result.problems:
135
+ echo(f"PROBLEM: {problem}")
136
+ if witness is not None:
137
+ check = verify_witness(ledger, witness, ledger_path=db or default_ledger_path())
138
+ for note in check.warnings:
139
+ echo(f"warning: {note}", err=True)
140
+ if check.ok:
141
+ echo(f"Witness agrees: the ledger still holds all {check.lines} witnessed head(s); "
142
+ f"{check.unwitnessed} newer entr{'y' if check.unwitnessed == 1 else 'ies'} not yet witnessed.")
143
+ else:
144
+ failed = True
145
+ for problem in check.problems:
146
+ echo(f"PROBLEM: {safe_text(problem, 300)}")
147
+ if failed:
148
+ raise typer.Exit(1)
149
+
150
+
151
+ @app.command("report")
152
+ @guarded
153
+ def report_command(
154
+ fmt: str = typer.Option("markdown", "--format", "-f", help="markdown (for people), json (for programs) or sarif (for CI and code scanning)."),
155
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Write to this file instead of the screen."),
156
+ force: bool = typer.Option(False, "--force", help="Replace the --out file if it exists."),
157
+ witness: Optional[Path] = typer.Option(None, "--witness", help="Also check the ledger against this witness file."),
158
+ fail_on: str = typer.Option("none", "--fail-on", help="'findings': exit 1 if anything needs a look; 'hints': also if any wording looks worth a second look (for CI). Default: only a failed integrity check does."),
159
+ db: Path = DB_OPTION,
160
+ ):
161
+ """A report on the ledger: integrity, what changed outside the store's own history, rollbacks and snapshots."""
162
+ if fmt not in RENDERERS or fail_on not in ("none", "findings", "hints"):
163
+ raise MemdebugError("--format must be markdown, json or sarif, and --fail-on must be none, findings or hints")
164
+ ledger = _open_ledger(db)
165
+ check = verify_witness(ledger, witness, ledger_path=db or default_ledger_path()) if witness is not None else None
166
+ report = build_report(ledger, ledger_name=(db or default_ledger_path()).name, witness=check)
167
+ text = RENDERERS[fmt](report)
168
+ if out is None:
169
+ echo(text, nl=False)
170
+ else:
171
+ write_report(out, text, force=force)
172
+ echo(f"Report written: {out}")
173
+ if not report.integrity_ok or (check is not None and not check.ok) or (fail_on in ("findings", "hints") and report.attention) or (fail_on == "hints" and report.hinted):
174
+ raise typer.Exit(1)
175
+
176
+
177
+ @app.command("witness")
178
+ @guarded
179
+ def witness_command(
180
+ file: Path = typer.Option(..., "--file", help="The witness file (created if missing). Keep it away from the ledger."),
181
+ db: Path = DB_OPTION,
182
+ ):
183
+ """Record the ledger's newest entry in a witness file kept somewhere else, so rewriting or cutting the ledger is noticed."""
184
+ ledger = _open_ledger(db)
185
+ if not ledger.verify().ok:
186
+ raise MemdebugError("the ledger has problems; run 'memdebug verify' first. Nothing was witnessed")
187
+ line, new = append_witness(ledger, file)
188
+ echo(f"{'Witnessed' if new else 'Already witnessed'}: entry {line.seq} of {line.count}, head {line.head[:16]}...")
189
+ if same_disk(db or default_ledger_path(), file):
190
+ echo(f"warning: {SAME_DISK_WARNING}", err=True)
191
+ echo("Check it any time with: memdebug verify --witness <that file>")
192
+
193
+
194
+ sync_app = typer.Typer(
195
+ help="Copy a backend's history into the ledger and look for edits made outside its own history.",
196
+ no_args_is_help=True,
197
+ )
198
+ app.add_typer(sync_app, name="sync")
199
+
200
+ SETTLE_OPTION = typer.Option(1.0, min=0.0, max=60.0, help="Seconds between the two confirming passes.")
201
+ ADOPT_OPTION = typer.Option(False, help="First sync only: record memories without history as ADDs.")
202
+
203
+
204
+ def _run_sync(adapter, ledger, scope, adopt_existing, settle, notes):
205
+ report = sync(adapter, ledger, scope, adopt_existing=adopt_existing, settle_seconds=settle)
206
+ for note in notes + report.warnings:
207
+ echo(f"warning: {safe_text(note, 300)}", err=True)
208
+ echo(
209
+ f"Added {report.history_events} history event(s) and {report.external_events} "
210
+ f"change(s) made outside the backend's own history. Ledger head: {ledger.head()}"
211
+ )
212
+
213
+
214
+ MEM0_HISTORY_OPTION = typer.Option(..., "--mem0-history-db", help="Mem0's history.db (opened read-only).")
215
+ MEM0_CONFIG_OPTION = typer.Option(None, "--mem0-config", help="JSON config for mem0 (never printed).")
216
+ MD_PATH_OPTION = typer.Option(..., "--path", help="Top folder of the git repository holding the markdown memories.")
217
+ MD_STORE_OPTION = typer.Option(None, "--store", help="A name for this memory store (default: folder name).")
218
+ MD_SUBDIR_OPTION = typer.Option(None, "--subdir", help="Only read this folder inside the repository.")
219
+
220
+
221
+ def _setup_mem0(history_db, config, user_id, agent_id, run_id):
222
+ scope = validate_scope(
223
+ {k: v for k, v in (("user_id", user_id), ("agent_id", agent_id), ("run_id", run_id)) if v is not None}
224
+ )
225
+ memory, notes = build_mem0_memory(config)
226
+ return Mem0Adapter(memory, history_db), scope, notes
227
+
228
+
229
+ def _setup_markdown(path, store, subdir):
230
+ adapter = MarkdownGitAdapter(path, store=store, subdir=subdir)
231
+ return adapter, {"store": adapter.store}, []
232
+
233
+
234
+ @sync_app.command("mem0")
235
+ @guarded
236
+ def sync_mem0(
237
+ mem0_history_db: Path = MEM0_HISTORY_OPTION,
238
+ db: Path = DB_OPTION,
239
+ mem0_config: Optional[Path] = MEM0_CONFIG_OPTION,
240
+ user_id: Optional[str] = typer.Option(None, "--user-id"),
241
+ agent_id: Optional[str] = typer.Option(None, "--agent-id"),
242
+ run_id: Optional[str] = typer.Option(None, "--run-id"),
243
+ adopt_existing: bool = ADOPT_OPTION,
244
+ settle: float = SETTLE_OPTION,
245
+ ):
246
+ """Self-hosted Mem0: history from its history.db, live memories through its own API."""
247
+ adapter, scope, notes = _setup_mem0(mem0_history_db, mem0_config, user_id, agent_id, run_id)
248
+ _run_sync(adapter, _open_ledger(db), scope, adopt_existing, settle, notes)
249
+
250
+
251
+ @sync_app.command("markdown")
252
+ @guarded
253
+ def sync_markdown(
254
+ path: Path = MD_PATH_OPTION,
255
+ db: Path = DB_OPTION,
256
+ store: Optional[str] = MD_STORE_OPTION,
257
+ subdir: Optional[str] = MD_SUBDIR_OPTION,
258
+ adopt_existing: bool = ADOPT_OPTION,
259
+ settle: float = SETTLE_OPTION,
260
+ ):
261
+ """Markdown files in a git repository: history from git log, live memories from the working tree.
262
+ Uncommitted edits show up as changes made outside the history."""
263
+ adapter, scope, notes = _setup_markdown(path, store, subdir)
264
+ _run_sync(adapter, _open_ledger(db), scope, adopt_existing, settle, notes)
265
+
266
+
267
+ # -- snapshots and compare -------------------------------------------------------------------------------
268
+
269
+ snapshot_app = typer.Typer(
270
+ help="Save what a memory store holds right now, so it can be compared later.", no_args_is_help=True
271
+ )
272
+ app.add_typer(snapshot_app, name="snapshot")
273
+
274
+ LABEL_OPTION = typer.Option(None, "--label", help="A short note for this snapshot (up to 100 characters).")
275
+ DIFF_FROM_OPTION = typer.Option(None, "--diff-from", help="Also show what changed since this snapshot (for example s1).")
276
+
277
+
278
+ def _scope_text(scope: dict) -> str:
279
+ return ", ".join(f"{safe_text(k, 20)}={safe_text(v, 30)}" for k, v in sorted(scope.items())) or "-"
280
+
281
+
282
+ def _print_diff(result: Diff, full: bool = False, limit: int = 200) -> None:
283
+ echo(
284
+ f"{result.old.id} -> {result.new.id} ({safe_text(result.old.backend, 30)}, {_scope_text(result.old.scope)}): "
285
+ f"{result.count('changed')} changed, {result.count('added')} added, {result.count('removed')} removed"
286
+ )
287
+ for note in result.warnings:
288
+ echo(f"warning: {safe_text(note, 300)}", err=True)
289
+ symbols = {"changed": "~", "added": "+", "removed": "-"}
290
+ for change in result.changes[:limit]:
291
+ name = safe_text(change.memory_id, 36)
292
+ if change.kind == "changed":
293
+ detail = f"{safe_text(change.before, 40)} -> {safe_text(change.after, 40)}"
294
+ else:
295
+ detail = safe_text(change.after if change.kind == "added" else change.before, 60)
296
+ echo(f"{symbols[change.kind]} {name} {detail}")
297
+ if full and change.kind == "changed":
298
+ for line in line_diff(change.before, change.after):
299
+ echo(f" {safe_text(line, 200)}")
300
+ if len(result.changes) > limit:
301
+ echo(f"... and {len(result.changes) - limit} more (use --limit to show more)")
302
+
303
+
304
+ def _run_snapshot(adapter, ledger, scope, notes, settle, label, diff_from, adopt_existing=False):
305
+ previous = ledger.load_snapshot(diff_from) if diff_from else None # fail before doing any work
306
+ report = sync(adapter, ledger, scope, adopt_existing=adopt_existing, settle_seconds=settle)
307
+ live = report.live
308
+ if live is None: # cannot happen with a normal sync; never snapshot something unchecked
309
+ raise MemdebugError("the sync did not produce a listing to snapshot")
310
+ for note in notes + report.warnings:
311
+ echo(f"warning: {safe_text(note, 300)}", err=True)
312
+ info = ledger.save_snapshot(
313
+ adapter.name, scope, live.memories, complete=live.complete,
314
+ taken_at=datetime.now(timezone.utc), label=label,
315
+ )
316
+ echo(
317
+ f"Saved snapshot {info.id}: {info.count} memories"
318
+ + ("" if info.complete else " (the listing may be incomplete; comparisons will not claim additions or removals from it)")
319
+ + f". Ledger head: {ledger.head()}"
320
+ )
321
+ if previous is not None:
322
+ _print_diff(diff_snapshots(previous, ledger.load_snapshot(info.id)))
323
+
324
+
325
+ @snapshot_app.command("mem0")
326
+ @guarded
327
+ def snapshot_mem0(
328
+ mem0_history_db: Path = MEM0_HISTORY_OPTION,
329
+ db: Path = DB_OPTION,
330
+ mem0_config: Optional[Path] = MEM0_CONFIG_OPTION,
331
+ user_id: Optional[str] = typer.Option(None, "--user-id"),
332
+ agent_id: Optional[str] = typer.Option(None, "--agent-id"),
333
+ run_id: Optional[str] = typer.Option(None, "--run-id"),
334
+ label: Optional[str] = LABEL_OPTION,
335
+ diff_from: Optional[str] = DIFF_FROM_OPTION,
336
+ adopt_existing: bool = ADOPT_OPTION,
337
+ settle: float = SETTLE_OPTION,
338
+ ):
339
+ """Sync, then save a snapshot of a self-hosted Mem0 store."""
340
+ adapter, scope, notes = _setup_mem0(mem0_history_db, mem0_config, user_id, agent_id, run_id)
341
+ _run_snapshot(adapter, _open_ledger(db), scope, notes, settle, label, diff_from, adopt_existing)
342
+
343
+
344
+ @snapshot_app.command("markdown")
345
+ @guarded
346
+ def snapshot_markdown(
347
+ path: Path = MD_PATH_OPTION,
348
+ db: Path = DB_OPTION,
349
+ store: Optional[str] = MD_STORE_OPTION,
350
+ subdir: Optional[str] = MD_SUBDIR_OPTION,
351
+ label: Optional[str] = LABEL_OPTION,
352
+ diff_from: Optional[str] = DIFF_FROM_OPTION,
353
+ adopt_existing: bool = ADOPT_OPTION,
354
+ settle: float = SETTLE_OPTION,
355
+ ):
356
+ """Sync, then save a snapshot of markdown memory files in a git repository."""
357
+ adapter, scope, notes = _setup_markdown(path, store, subdir)
358
+ _run_snapshot(adapter, _open_ledger(db), scope, notes, settle, label, diff_from, adopt_existing)
359
+
360
+
361
+ @snapshot_app.command("list")
362
+ @guarded
363
+ def snapshot_list(db: Path = DB_OPTION):
364
+ """List saved snapshots."""
365
+ infos = _open_ledger(db).list_snapshots()
366
+ if not infos:
367
+ echo("No snapshots yet.")
368
+ return
369
+ for info in infos:
370
+ label = f" {safe_text(info.label, 60)}" if info.label else ""
371
+ echo(
372
+ f"{info.id:>5} {info.taken_at:%Y-%m-%d %H:%M} {safe_text(info.backend, 14):<12} "
373
+ f"{_scope_text(info.scope)} {info.count} memories"
374
+ f"{'' if info.complete else ' (incomplete)'} after e{info.ledger_seq}{label}"
375
+ )
376
+
377
+
378
+ @snapshot_app.command("delete")
379
+ @guarded
380
+ def snapshot_delete(
381
+ snapshot_id: str = typer.Argument(..., help="For example s1."),
382
+ db: Path = DB_OPTION,
383
+ yes: bool = typer.Option(False, "--yes", help="Do not ask for confirmation."),
384
+ ):
385
+ """Delete a snapshot. The deletion is recorded in the ledger; the texts only that snapshot used are removed."""
386
+ if not yes:
387
+ typer.confirm(f"Delete snapshot {safe_text(snapshot_id, 12)}? This cannot be undone.", abort=True)
388
+ info = _open_ledger(db).delete_snapshot(snapshot_id)
389
+ echo(f"Deleted snapshot {info.id}.")
390
+
391
+
392
+ @app.command("diff")
393
+ @guarded
394
+ def diff_command(
395
+ old: str = typer.Argument(..., help="The earlier snapshot, for example s1."),
396
+ new: str = typer.Argument(..., help="The later snapshot, for example s2."),
397
+ db: Path = DB_OPTION,
398
+ full: bool = typer.Option(False, "--full", help="Show a line-by-line diff of each changed memory."),
399
+ limit: int = typer.Option(200, min=1, max=100_000, help="Most changes to list."),
400
+ ):
401
+ """Show what changed between two snapshots."""
402
+ ledger = _open_ledger(db)
403
+ _print_diff(diff_snapshots(ledger.load_snapshot(old), ledger.load_snapshot(new)), full=full, limit=limit)
404
+
405
+
406
+ @app.command()
407
+ @guarded
408
+ def where():
409
+ """Show where the default ledger is kept."""
410
+ echo(str(default_ledger_path()))
411
+
412
+
413
+ @app.command()
414
+ @guarded
415
+ def selftest():
416
+ """Check on THIS machine that the safety protections work (git isolation, read-only access,
417
+ killing a hung git, symlink and junction handling, path rules). Run it once on every new
418
+ platform, especially Windows."""
419
+ from .selftest import run_all
420
+
421
+ results = run_all()
422
+ failed = 0
423
+ for name, status, detail in results:
424
+ echo(f"[{status:<4}] {name}" + (f": {detail}" if detail else ""))
425
+ failed += status == "FAIL"
426
+ if failed:
427
+ echo(f"{failed} check(s) FAILED. Do not rely on this tool on this machine until that is understood.", err=True)
428
+ raise typer.Exit(1)
429
+ echo("All checks passed (SKIP means the check could not be run here, so it proves nothing).")
430
+
431
+
432
+ # -- rollback -----------------------------------------------------------------------------------------------
433
+
434
+ rollback_app = typer.Typer(
435
+ help="Put a memory store back to what a snapshot held. Shows what would change first; nothing is written without --apply.",
436
+ no_args_is_help=True,
437
+ )
438
+ app.add_typer(rollback_app, name="rollback")
439
+
440
+
441
+ def _print_plan(plan: Plan, snapshot_label: str, full: bool) -> None:
442
+ echo(f"Rollback of \"{safe_text(plan.store, 40)}\" to {plan.snapshot_id}{snapshot_label}")
443
+ if plan.branch:
444
+ echo(f"Branch {safe_text(plan.branch.removeprefix('refs/heads/'), 60)} is at {(plan.head or '')[:12]}.")
445
+ echo("")
446
+ verbs = {"restore": "~ restore ", "recreate": "+ recreate", "remove": "- remove "}
447
+ for item in plan.items:
448
+ echo(f" {verbs[item.action]} {safe_text(item.path, 70)}")
449
+ echo(f" {safe_text(item.note, 200)}")
450
+ if full and item.action != "remove":
451
+ for line in line_diff(item.live_text or "", item.target_text or "")[:60]:
452
+ echo(f" {safe_text(line, 160)}")
453
+ if not plan.items:
454
+ echo(" Nothing to restore: every file in scope already matches the snapshot.")
455
+ if plan.kept_new:
456
+ shown = ", ".join(safe_text(n, 40) for n in plan.kept_new[:5]) + (" ..." if len(plan.kept_new) > 5 else "")
457
+ echo(f"\nAdded since the snapshot and left alone: {shown} (use --remove-added to remove them)")
458
+ for name, reason in plan.skipped:
459
+ echo(f"\nSkipped {safe_text(name, 60)}: {safe_text(reason, 200)}")
460
+ for note in plan.warnings:
461
+ echo(f"\nwarning: {safe_text(note, 300)}", err=True)
462
+ if plan.items:
463
+ saved = sum(1 for i in plan.items if i.backup)
464
+ echo(f"\n{len(plan.items)} file(s) would change"
465
+ + (", in one new commit" if plan.commits else ", with no new commit")
466
+ + (f"; {saved} would be saved to a backup first" if saved else "") + ".")
467
+ for reason in plan.blockers:
468
+ echo(f"\nCannot be applied now: {safe_text(reason, 300)}")
469
+
470
+
471
+ @rollback_app.command("markdown")
472
+ @guarded
473
+ def rollback_markdown(
474
+ to: str = typer.Option(..., "--to", help="The snapshot to go back to (for example s1)."),
475
+ path: Path = MD_PATH_OPTION,
476
+ db: Path = DB_OPTION,
477
+ store: Optional[str] = MD_STORE_OPTION,
478
+ subdir: Optional[str] = MD_SUBDIR_OPTION,
479
+ only: Optional[list[str]] = typer.Option(None, "--only", help="Restore only this file (repeat for several)."),
480
+ remove_added: bool = typer.Option(False, "--remove-added", help="Also remove files added since the snapshot."),
481
+ full: bool = typer.Option(False, "--full", help="Show the line changes for each file."),
482
+ apply: bool = typer.Option(False, "--apply", help="Do it. Without this, nothing is changed."),
483
+ yes: bool = typer.Option(False, "--yes", help="Do not ask for confirmation (needed when not at a keyboard)."),
484
+ settle: float = SETTLE_OPTION,
485
+ ):
486
+ """Markdown files in a git repository: restore them to a snapshot with a new commit. History is never rewritten,
487
+ anything not already in git is backed up first, and the rollback can itself be undone."""
488
+ ledger = _open_ledger(db)
489
+ snapshot = ledger.load_snapshot(to) # fail before doing any work
490
+ adapter, scope, _ = _setup_markdown(path, store, subdir)
491
+ restorer = Restorer(adapter)
492
+ chosen = list(only or [])
493
+ plan = restorer.plan(snapshot, only=chosen, remove_added=remove_added)
494
+ label = f" ({safe_text(snapshot.info.label, 60)})" if snapshot.info.label else ""
495
+ _print_plan(plan, label, full)
496
+ if plan.blockers:
497
+ raise typer.Exit(1)
498
+ if not apply:
499
+ echo("\nDry run: nothing was changed. Add --apply to do it.")
500
+ return
501
+ if not plan.items:
502
+ return
503
+ if not yes:
504
+ if not (os.isatty(0) and os.isatty(1)):
505
+ raise MemdebugError("not at a keyboard: pass --yes to confirm, or run it from a terminal")
506
+ typed = typer.prompt(f"\nType {snapshot.info.id} to confirm", default="", show_default=False)
507
+ if typed.strip() != snapshot.info.id:
508
+ echo("Cancelled. Nothing was changed.")
509
+ raise typer.Exit(1)
510
+
511
+ result = run_rollback(adapter, ledger, scope, restorer, snapshot, plan, only=chosen, remove_added=remove_added, settle=settle,
512
+ warn=lambda text: echo(f"warning: {text}", err=True))
513
+ outcome = result.outcome
514
+ if result.entry is None:
515
+ echo(f"The files were restored, but recording it in the ledger failed: {result.record_error}", err=True)
516
+ echo(f"Run 'memdebug sync markdown --path ...' to record the changes. Undo point: snapshot {result.before.id}.", err=True)
517
+ raise typer.Exit(3)
518
+ echo(f"\nRestored {len(outcome.items)} file(s) to {snapshot.info.id}. Recorded as {result.entry.event.memory_id}.")
519
+ if outcome.commit:
520
+ echo(f"New commit {outcome.commit[:12]} on {safe_text((outcome.branch or '').removeprefix('refs/heads/'), 60)} "
521
+ f"(it was at {(outcome.previous_head or '')[:12]}).")
522
+ if outcome.backup_ref:
523
+ echo(f"Content that git did not have was saved first: {outcome.backup_ref}")
524
+ echo(" Get a file back with: git checkout <that name> -- <file>")
525
+ echo(f"To undo this rollback: memdebug rollback markdown --path <the same folder> --to {result.before.id} --apply")
526
+ echo("Now restart your agent's session: a running session keeps the memory it already loaded.")
527
+ echo(f"Ledger head: {ledger.head()}")
528
+
529
+
530
+ @app.command("demo")
531
+ @guarded
532
+ def demo_command(
533
+ folder: Optional[Path] = typer.Option(None, "--dir", help="Work in this new or empty folder (it is kept). Default: a temporary folder."),
534
+ keep: bool = typer.Option(False, "--keep", help="Keep the temporary folder afterwards."),
535
+ serve_viewer: bool = typer.Option(False, "--serve", help="Afterwards, open the demo ledger in the browser viewer."),
536
+ ):
537
+ """Try memdebug in about a minute: a made-up agent memory, a made-up attack, and what you would see.
538
+ It works in a throwaway folder and never reads or changes anything of yours."""
539
+ from .demo import prepare_folder, remove_folder, run_demo
540
+
541
+ path, temporary = prepare_folder(folder)
542
+ try:
543
+ result = run_demo(path, echo)
544
+ if serve_viewer:
545
+ import logging
546
+
547
+ from .viewer.server import serve
548
+
549
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
550
+
551
+ def announce(url: str) -> None:
552
+ echo("\nThis is the demo ledger in the read-only viewer (this computer only). Open this link (it contains a secret):")
553
+ echo(f" {url}")
554
+ echo("Look at: Overview, Timeline (the amber entry and the ROLLBACK), Snapshots, Integrity. Ctrl+C to stop.")
555
+
556
+ serve(result.ledger_path, 0, announce, True)
557
+ finally:
558
+ if temporary and not keep:
559
+ remove_folder(path)
560
+ if temporary and keep or not temporary:
561
+ echo(f"\nThe demo folder was kept: {path}")
562
+ else:
563
+ echo("\nThe throwaway folder was removed.")
564
+ echo("Next: point memdebug at your own memory, for example: memdebug snapshot markdown --path <folder> --label baseline")
565
+
566
+
567
+ # -- registered stores: add, stores, remove, check, status, watch, setup ---------------------------------------------------
568
+
569
+ def _config_path(db: Optional[Path]) -> Path:
570
+ """The list of watched stores lives next to the ledger it belongs to."""
571
+ return (db or default_ledger_path()).with_name("stores.json")
572
+
573
+
574
+ def _guess_name(path: Path, taken: set[str]) -> str:
575
+ base = re.sub(r"[^a-z0-9]+", "-", (path.stem if path.is_file() else path.name).lower()).strip("-")[:30] or "memory"
576
+ name, n = base, 2
577
+ while name in taken:
578
+ name, n = f"{base}-{n}", n + 1
579
+ return name
580
+
581
+
582
+ def _copies_dir(db: Optional[Path]) -> Path:
583
+ """Where memdebug keeps the copies it takes of databases that live inside containers."""
584
+ return _config_path(db).parent / "copies"
585
+
586
+
587
+ def _register(registry, path: Path, *, name: Optional[str], kind: Optional[str], user_id: Optional[str], agent_id: Optional[str],
588
+ run_id: Optional[str], subdir: Optional[str], docker: Optional[str] = None, files: Optional[str] = None) -> StoreConfig:
589
+ """Check that a store can be opened, then add it to the registry (not yet saved)."""
590
+ resolved = path.expanduser().resolve()
591
+ chosen_kind = kind or detect_kind(resolved)
592
+ if chosen_kind not in KINDS:
593
+ raise MemdebugError("the type must be one of: " + ", ".join(KINDS))
594
+ same = next((s for s in registry.stores if (s.path == str(resolved) or (docker and s.docker == docker)) and s.subdir == subdir and (user_id is None or s.user_id == user_id) and s.files == files), None)
595
+ if same is not None:
596
+ raise MemdebugError(f"that is already watched as {safe_text(same.name, 40)}")
597
+ chosen_name = name or _guess_name(resolved, {s.name for s in registry.stores})
598
+ if not valid_name(chosen_name):
599
+ raise MemdebugError("a store name must be 1 to 40 lowercase letters, digits, dots, dashes or underscores")
600
+ cfg = StoreConfig(name=chosen_name, kind=chosen_kind, path=str(resolved), subdir=subdir, user_id=user_id, agent_id=agent_id, run_id=run_id, docker=docker, files=files)
601
+ opened = open_store(cfg) # fails clearly if this is not something memdebug can read
602
+ if cfg.kind == "openwebui" and cfg.user_id is None and isinstance(opened.adapter, OpenWebUIAdapter):
603
+ # Whose memories to read is worked out once, now, and remembered: re-deriving it on every look would break a store that
604
+ # later has no memories, or a second user.
605
+ cfg = replace(cfg, user_id=opened.adapter.user)
606
+ registry.add(cfg)
607
+ return cfg
608
+
609
+
610
+ def _say_hints(cfg: StoreConfig, *, refresh: bool = True) -> None:
611
+ """Wording already in the store that is worth a second look, said right away: a planted phrase may predate memdebug."""
612
+ try:
613
+ found = store_hints(cfg, refresh=refresh)
614
+ except MemdebugError:
615
+ return
616
+ for label, hint in found[:3]:
617
+ echo(f" worth a second look: {safe_text(label, 90)}: {safe_text(hint.message, 120)}")
618
+ if len(found) > 3:
619
+ echo(f" ... and {len(found) - 3} more. These are guesses from wording; 'memdebug serve' shows each one.")
620
+
621
+
622
+ @app.command("add")
623
+ @guarded
624
+ def add_command(
625
+ path: Optional[Path] = typer.Argument(None, help="A folder of notes, a git repository, or a database (a copy of Open WebUI's webui.db, Mem0's history.db). Not needed with --docker."),
626
+ docker: Optional[str] = typer.Option(None, "--docker", help="Open WebUI running in Docker: the container's name (see 'docker ps'). memdebug copies its database itself."),
627
+ name: Optional[str] = typer.Option(None, "--name", help="A short name for it (default: taken from the path)."),
628
+ kind: Optional[str] = typer.Option(None, "--type", help="markdown, folder, openwebui or mem0 (default: worked out from the path)."),
629
+ user_id: Optional[str] = typer.Option(None, "--user-id", help="Whose memories (needed for Mem0; for Open WebUI only if several users)."),
630
+ agent_id: Optional[str] = typer.Option(None, "--agent-id", help="Mem0 only."),
631
+ run_id: Optional[str] = typer.Option(None, "--run-id", help="Mem0 only."),
632
+ subdir: Optional[str] = typer.Option(None, "--subdir", help="Only read this folder inside it."),
633
+ files: Optional[str] = typer.Option(None, "--files", help="A folder store only: watch just these markdown files in it (comma-separated), nothing else in the folder."),
634
+ baseline_now: bool = typer.Option(True, "--baseline/--no-baseline", help="Save a first snapshot right away (recommended)."),
635
+ db: Path = DB_OPTION,
636
+ ):
637
+ """Tell memdebug to watch a memory store, once. After that, 'memdebug check' looks at it and 'memdebug watch' keeps looking."""
638
+ ledger = _open_ledger(db)
639
+ config = _config_path(db)
640
+ registry = load_registry(config)
641
+ if docker is not None:
642
+ if path is not None:
643
+ raise MemdebugError("give either a path or --docker, not both")
644
+ if not valid_container(docker):
645
+ raise MemdebugError("that is not a usable container name; see 'docker ps'")
646
+ kind = "openwebui"
647
+ name = name or _guess_name(Path(docker), {s.name for s in registry.stores})
648
+ path = _copies_dir(db) / name / "webui.db"
649
+ elif path is None:
650
+ raise MemdebugError("say what to watch: a path, or --docker NAME for Open WebUI in Docker")
651
+ cfg = _register(registry, path, name=name, kind=kind, user_id=user_id, agent_id=agent_id, run_id=run_id, subdir=subdir, docker=docker, files=files)
652
+ save_registry(registry, config)
653
+ echo(f"Added {safe_text(cfg.name, 40)}: {KIND_NAMES[cfg.kind]}.")
654
+ if baseline_now:
655
+ echo(f"Saved a first snapshot, {baseline(cfg, ledger, refresh=False)}, of what it holds now. Later changes are compared with it.")
656
+ _say_hints(cfg, refresh=False)
657
+ echo("Next: 'memdebug check' looks for changes, 'memdebug watch' keeps looking, 'memdebug serve' shows them in the browser.")
658
+
659
+
660
+ @app.command("stores")
661
+ @guarded
662
+ def stores_command(db: Path = DB_OPTION):
663
+ """List the stores memdebug watches."""
664
+ registry = load_registry(_config_path(db))
665
+ if not registry.stores:
666
+ echo("No stores yet. Run 'memdebug setup' for a guided start, or 'memdebug add <path>'.")
667
+ return
668
+ for store in registry.stores:
669
+ echo(f"{safe_text(store.name, 40)}: {KIND_NAMES[store.kind]}: {safe_text(store.path, 200)}")
670
+ if registry.witness:
671
+ echo(f"witness file: {safe_text(registry.witness, 200)}")
672
+
673
+
674
+ @app.command("remove")
675
+ @guarded
676
+ def remove_command(name: str = typer.Argument(..., help="The store's name."), db: Path = DB_OPTION):
677
+ """Stop watching a store. What was already recorded about it stays in the ledger."""
678
+ config = _config_path(db)
679
+ registry = load_registry(config)
680
+ registry.remove(name)
681
+ save_registry(registry, config)
682
+ echo(f"No longer watching {safe_text(name, 40)}. Its history stays in the ledger.")
683
+
684
+
685
+ @app.command("check")
686
+ @guarded
687
+ def check_command(
688
+ strict: bool = typer.Option(False, "--strict", help="Also exit with code 1 when changed wording looks worth a second look (for CI)."),
689
+ settle: float = SETTLE_OPTION,
690
+ db: Path = DB_OPTION,
691
+ ):
692
+ """Look at every watched store once: what changed since last time, and is anything wrong? Exit code 1 means something needs a look."""
693
+ registry = load_registry(_config_path(db))
694
+ if not registry.stores:
695
+ echo("Nothing to check yet. Run 'memdebug setup' for a guided start, or 'memdebug add <path>'.")
696
+ raise typer.Exit(2)
697
+ ledger = _open_ledger(db)
698
+ echo(f"Checking {len(registry.stores)} store(s)...")
699
+ summary = check_all(registry, ledger, settle=settle, ledger_path=db or default_ledger_path())
700
+ for line in summary_lines(summary):
701
+ echo(line)
702
+ for note in [w for r in summary.results for w in r.warnings][:5] + ([summary.witness_warning] if summary.witness_warning else []):
703
+ echo(f" warning: {safe_text(note, 300)}", err=True)
704
+ if summary.attention or summary.hinted:
705
+ echo(" Look closer: 'memdebug serve' shows exactly what changed. 'memdebug rollback ...' can put markdown notes back.")
706
+ raise typer.Exit(summary.exit_code_for(strict))
707
+
708
+
709
+ @app.command("status")
710
+ @guarded
711
+ def status_command(db: Path = DB_OPTION):
712
+ """Where things stand, without looking for new changes or writing anything."""
713
+ registry = load_registry(_config_path(db))
714
+ if not registry.stores:
715
+ echo("No stores yet. Run 'memdebug setup' for a guided start, or 'memdebug add <path>'.")
716
+ return
717
+ ledger = _open_ledger(db)
718
+ echo(f"{len(registry.stores)} store(s) watched; ledger {'intact' if ledger.verify().ok else 'HAS PROBLEMS'}.")
719
+ for line in status_lines(registry, ledger):
720
+ echo(line)
721
+
722
+
723
+ @app.command("watch")
724
+ @guarded
725
+ def watch_command(
726
+ every: float = typer.Option(60.0, "--every", min=5.0, max=86400.0, help="Seconds between looks."),
727
+ once: bool = typer.Option(False, "--once", help="Look once and stop (like 'check', but prints only what is new)."),
728
+ bell: bool = typer.Option(True, "--bell/--no-bell", help="Ring the terminal bell when something needs a look."),
729
+ settle: float = SETTLE_OPTION,
730
+ db: Path = DB_OPTION,
731
+ ):
732
+ """Keep looking at every watched store and say so when something changes. Stop with Ctrl+C."""
733
+ registry = load_registry(_config_path(db))
734
+ if not registry.stores:
735
+ echo("Nothing to watch yet. Run 'memdebug setup' for a guided start, or 'memdebug add <path>'.")
736
+ raise typer.Exit(2)
737
+ ledger = _open_ledger(db)
738
+ try:
739
+ watch(registry, ledger, every=every, say=echo, ring=(lambda: typer.echo("\a", nl=False)) if bell else (lambda: None),
740
+ cycles=1 if once else None, settle=settle, ledger_path=db or default_ledger_path())
741
+ except KeyboardInterrupt:
742
+ echo("\nStopped.")
743
+
744
+
745
+ @app.command("agents")
746
+ @guarded
747
+ def agents_command():
748
+ """Which AI agents memdebug can see on this computer, and what each keeps. Only looks at folder names; changes nothing."""
749
+ for line in agent_lines(scan_agents(), [c.docker for c in discover_docker(Path(tempfile.gettempdir())) if c.docker]):
750
+ echo(line)
751
+ echo("\n'memdebug setup' offers to watch what it found.")
752
+
753
+
754
+ @app.command("setup")
755
+ @guarded
756
+ def setup_command(
757
+ yes: bool = typer.Option(False, "--yes", help="Accept everything that is found and ask nothing."),
758
+ db: Path = DB_OPTION,
759
+ ):
760
+ """A guided start: find the memory your agents keep on this computer, save a first snapshot of each, and optionally set up a witness."""
761
+ ledger = _open_ledger(db)
762
+ config = _config_path(db)
763
+ registry = load_registry(config)
764
+ interactive = not yes
765
+ echo("memdebug setup")
766
+ echo("It looks for memory on this computer, tells you what it found, and saves a first snapshot of each store you keep.")
767
+ echo("Nothing is changed in your memory; memdebug only reads it.\n")
768
+ added: list[StoreConfig] = []
769
+ watching = {(s.path, s.files) for s in registry.stores}
770
+ watched_containers = {s.docker for s in registry.stores if s.docker}
771
+ agents = scan_agents()
772
+ containers = [c for c in discover_docker(_copies_dir(db)) if c.docker not in watched_containers]
773
+ echo("Agents on this computer:")
774
+ for line in agent_lines(agents, [c.docker for c in containers if c.docker]):
775
+ echo(line)
776
+ found = [c for item in agents for c in item.candidates if (str(c.path.resolve()), ",".join(c.files) if c.files else None) not in watching] + containers
777
+ if found:
778
+ echo("\nWhat memdebug can watch:")
779
+ for c in found:
780
+ echo(f" {safe_text(c.name, 40)}: {c.why} ({safe_text(c.path, 120)})")
781
+ if yes or typer.confirm(" Watch it?", default=True):
782
+ try:
783
+ added.append(_register(registry, c.path, name=c.name, kind=c.kind, user_id=None, agent_id=None, run_id=None, subdir=None, docker=c.docker,
784
+ files=",".join(c.files) if c.files else None))
785
+ except MemdebugError as exc:
786
+ echo(f" Could not set that up: {safe_text(exc, 200)}")
787
+ if not found:
788
+ echo("Nothing was found automatically (that is normal if your agent keeps its memory somewhere else).")
789
+ while interactive:
790
+ raw = typer.prompt("\nAnother folder of notes, git repository or database to watch? Enter its path, or just press Enter to continue",
791
+ default="", show_default=False).strip().strip('"')
792
+ if not raw:
793
+ break
794
+ try:
795
+ path = Path(raw)
796
+ guessed = detect_kind(path.expanduser().resolve())
797
+ user = typer.prompt(" Mem0 needs a user id", default="") if guessed == "mem0" else None
798
+ added.append(_register(registry, path, name=None, kind=guessed, user_id=user or None, agent_id=None, run_id=None, subdir=None))
799
+ echo(f" Will watch it as {KIND_NAMES[guessed]}.")
800
+ except MemdebugError as exc:
801
+ echo(f" Could not use that: {safe_text(exc, 200)}")
802
+ for cfg in added:
803
+ echo(f"\nSaving a first snapshot of {safe_text(cfg.name, 40)}...")
804
+ try:
805
+ echo(f" Done: {baseline(cfg, ledger, refresh=False)}")
806
+ _say_hints(cfg, refresh=False)
807
+ except MemdebugError as exc:
808
+ echo(f" Could not read it: {safe_text(exc, 200)}")
809
+ if interactive and registry.stores and not registry.witness:
810
+ echo("\nA witness is a second copy of the ledger's fingerprint, kept somewhere else, so a rewritten or cut-short ledger is noticed.")
811
+ folder = typer.prompt("Folder for it, ideally on another drive or a synced folder (press Enter to skip)", default="", show_default=False).strip().strip('"')
812
+ if folder:
813
+ target = Path(folder).expanduser() / "memdebug-witness.txt"
814
+ try:
815
+ append_witness(ledger, target)
816
+ registry.witness = str(target.resolve())
817
+ echo(f" Witness set up: {safe_text(target, 160)}")
818
+ if same_disk(db or default_ledger_path(), target):
819
+ echo(f" warning: {SAME_DISK_WARNING}")
820
+ except MemdebugError as exc:
821
+ echo(f" Could not set up the witness: {safe_text(exc, 200)}")
822
+ save_registry(registry, config)
823
+ if not registry.stores:
824
+ echo("\nNo stores yet. Add one later with: memdebug add <path>")
825
+ return
826
+ echo(f"\nWatching {len(registry.stores)} store(s). From now on:")
827
+ echo(" memdebug check look for changes once")
828
+ echo(" memdebug watch keep looking, and say when something changes")
829
+ echo(" memdebug serve see it all in your browser")
830
+
831
+
832
+ @app.command("serve")
833
+ @guarded
834
+ def serve_command(
835
+ db: Path = DB_OPTION,
836
+ port: int = typer.Option(8765, min=0, max=65535, help="Port on this computer (0 picks a free one)."),
837
+ open_browser: bool = typer.Option(False, "--open", help="Open the link in your browser (it contains the secret)."),
838
+ ):
839
+ """Start a read-only viewer on this computer only (127.0.0.1). Open the link it prints."""
840
+ import logging
841
+
842
+ from .viewer.server import serve
843
+
844
+ path = db if db is not None else default_ledger_path()
845
+ if not path.is_file():
846
+ raise MemdebugError("there is no ledger at that path yet; run 'memdebug sync ...' first")
847
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
848
+
849
+ def announce(url: str) -> None:
850
+ echo("Read-only viewer running on this computer only. Open this link (it contains a secret):")
851
+ echo(f" {url}")
852
+ echo("Press Ctrl+C to stop.")
853
+
854
+ try:
855
+ serve(path, port, announce, open_browser)
856
+ except OSError as exc:
857
+ raise MemdebugError(f"cannot start the viewer on port {port}: {exc.strerror}. Try --port 0.") from exc