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/__init__.py +3 -0
- memdebug/__main__.py +3 -0
- memdebug/adapters/__init__.py +0 -0
- memdebug/adapters/base.py +37 -0
- memdebug/adapters/common.py +69 -0
- memdebug/adapters/folder.py +96 -0
- memdebug/adapters/markdown_git.py +731 -0
- memdebug/adapters/mem0.py +324 -0
- memdebug/adapters/openwebui.py +209 -0
- memdebug/adapters/restore.py +705 -0
- memdebug/agents.py +128 -0
- memdebug/backends.py +29 -0
- memdebug/cli.py +857 -0
- memdebug/demo.py +190 -0
- memdebug/describe.py +34 -0
- memdebug/diff.py +91 -0
- memdebug/docker_source.py +164 -0
- memdebug/errors.py +29 -0
- memdebug/hints.py +145 -0
- memdebug/ledger.py +700 -0
- memdebug/models.py +157 -0
- memdebug/monitor.py +246 -0
- memdebug/paths.py +19 -0
- memdebug/reconcile.py +83 -0
- memdebug/report.py +279 -0
- memdebug/rollback_flow.py +69 -0
- memdebug/selftest.py +482 -0
- memdebug/stores.py +276 -0
- memdebug/sync.py +177 -0
- memdebug/textsafe.py +73 -0
- memdebug/viewer/__init__.py +1 -0
- memdebug/viewer/html.py +136 -0
- memdebug/viewer/pages.py +569 -0
- memdebug/viewer/redline.py +242 -0
- memdebug/viewer/server.py +432 -0
- memdebug/viewer/style.py +218 -0
- memdebug/witness.py +199 -0
- memdebug-0.2.0.dist-info/METADATA +206 -0
- memdebug-0.2.0.dist-info/RECORD +43 -0
- memdebug-0.2.0.dist-info/WHEEL +4 -0
- memdebug-0.2.0.dist-info/entry_points.txt +2 -0
- memdebug-0.2.0.dist-info/licenses/LICENSE +202 -0
- memdebug-0.2.0.dist-info/licenses/NOTICE +4 -0
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
|