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/__init__.py
ADDED
memdebug/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""What a memory backend adapter must provide. Restore (plan and apply) comes in milestone 4.
|
|
2
|
+
|
|
3
|
+
Adapters only read. Everything they return came from an untrusted store.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from ..models import Memory, MemoryEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class LiveMemories:
|
|
15
|
+
memories: list[Memory]
|
|
16
|
+
complete: bool # False when the listing may be cut short, so absence proves nothing
|
|
17
|
+
warnings: list[str] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class HistoryRead:
|
|
22
|
+
events: list[MemoryEvent] # valid rows, oldest first
|
|
23
|
+
refs: set[str] # backend row ids of every row seen, valid or not
|
|
24
|
+
truncated: bool # True when more rows exist than were read
|
|
25
|
+
skipped: int = 0 # rows that could not be understood
|
|
26
|
+
warnings: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryAdapter(Protocol):
|
|
30
|
+
name: str
|
|
31
|
+
capabilities: set[str] # history, global_feed, provenance, restore
|
|
32
|
+
|
|
33
|
+
def list_memories(self, scope: dict[str, str]) -> LiveMemories: ...
|
|
34
|
+
|
|
35
|
+
def read_history(self, max_rows: int) -> HistoryRead: ...
|
|
36
|
+
|
|
37
|
+
def history(self, memory_id: str) -> list[MemoryEvent]: ...
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Small helpers shared by every adapter. Everything an adapter reads is untrusted."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..errors import AdapterError
|
|
10
|
+
from ..models import MAX_ID_CHARS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def decode(raw: bytes) -> str:
|
|
14
|
+
"""Invalid UTF-8 in stored data must never crash a read."""
|
|
15
|
+
return raw.decode("utf-8", "replace")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Warnings:
|
|
19
|
+
"""Collects warnings but never grows without bound."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, cap: int = 50):
|
|
22
|
+
self._items: list[str] = []
|
|
23
|
+
self._cap = cap
|
|
24
|
+
self._extra = 0
|
|
25
|
+
|
|
26
|
+
def add(self, message: str) -> None:
|
|
27
|
+
if len(self._items) < self._cap:
|
|
28
|
+
self._items.append(message)
|
|
29
|
+
else:
|
|
30
|
+
self._extra += 1
|
|
31
|
+
|
|
32
|
+
def as_list(self) -> list[str]:
|
|
33
|
+
items = list(self._items)
|
|
34
|
+
if self._extra:
|
|
35
|
+
items.append(f"... and {self._extra} more warnings")
|
|
36
|
+
return items
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clean_id(value: object) -> str | None:
|
|
40
|
+
if isinstance(value, str) and 0 < len(value) <= MAX_ID_CHARS:
|
|
41
|
+
return value
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_ts(value: object) -> datetime | None:
|
|
46
|
+
if not isinstance(value, str):
|
|
47
|
+
return None
|
|
48
|
+
text = value.strip()
|
|
49
|
+
if not text or len(text) > 64:
|
|
50
|
+
return None
|
|
51
|
+
if text[-1] in "Zz":
|
|
52
|
+
text = text[:-1] + "+00:00"
|
|
53
|
+
try:
|
|
54
|
+
parsed = datetime.fromisoformat(text)
|
|
55
|
+
if parsed.tzinfo is None:
|
|
56
|
+
parsed = parsed.replace(tzinfo=timezone.utc) # older rows carry no zone
|
|
57
|
+
return parsed.astimezone(timezone.utc)
|
|
58
|
+
except (ValueError, OverflowError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_regular_file(path: Path) -> Path:
|
|
63
|
+
try:
|
|
64
|
+
resolved = path.resolve(strict=True)
|
|
65
|
+
if not stat.S_ISREG(os.stat(resolved).st_mode):
|
|
66
|
+
raise AdapterError("path is not a regular file")
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise AdapterError(f"cannot access file: {exc.strerror}") from exc
|
|
69
|
+
return resolved
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A plain folder of markdown files that is not a git repository (for example an agent's own memory folder).
|
|
2
|
+
|
|
3
|
+
It reuses the markdown adapter's working-tree reader, so links and junctions are never followed, unsafe names and
|
|
4
|
+
oversized files are skipped, and the listing is marked incomplete whenever something could not be read. The folder keeps
|
|
5
|
+
no history, so memdebug records what it sees between two looks: a change is an observed ADD, UPDATE or DELETE, never an
|
|
6
|
+
"outside the history" alarm. Read-only: this adapter never writes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from ..errors import AdapterError
|
|
16
|
+
from ..models import Memory, MemoryEvent
|
|
17
|
+
from ..textsafe import safe_text
|
|
18
|
+
from .base import HistoryRead, LiveMemories
|
|
19
|
+
from .common import Warnings, clean_id
|
|
20
|
+
from .markdown_git import MarkdownGitAdapter, _valid_relpath
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FolderAdapter(MarkdownGitAdapter):
|
|
24
|
+
name = "folder"
|
|
25
|
+
capabilities: set[str] = set() # no history to read
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
root: str | Path,
|
|
30
|
+
*,
|
|
31
|
+
store: str | None = None,
|
|
32
|
+
subdir: str | None = None,
|
|
33
|
+
suffixes: tuple[str, ...] = (".md",),
|
|
34
|
+
only: tuple[str, ...] | None = None,
|
|
35
|
+
max_files: int = 20_000,
|
|
36
|
+
max_total_chars: int = 100_000_000,
|
|
37
|
+
clock: Callable[[], datetime] | None = None,
|
|
38
|
+
):
|
|
39
|
+
try:
|
|
40
|
+
self._root = Path(root).resolve(strict=True)
|
|
41
|
+
except OSError as exc:
|
|
42
|
+
raise AdapterError(f"cannot access the folder: {exc.strerror}") from exc
|
|
43
|
+
if not self._root.is_dir():
|
|
44
|
+
raise AdapterError("the path is not a folder")
|
|
45
|
+
if (self._root / ".git").exists():
|
|
46
|
+
raise AdapterError("this folder is a git repository: use the markdown (git) store type, which also reads its history")
|
|
47
|
+
if not (isinstance(max_files, int) and max_files > 0 and max_total_chars > 0):
|
|
48
|
+
raise AdapterError("max_files and max_total_chars must be positive")
|
|
49
|
+
if not suffixes or any(not s.startswith(".") or s != s.lower() for s in suffixes):
|
|
50
|
+
raise AdapterError("suffixes must be lowercase and start with a dot, such as '.md'")
|
|
51
|
+
self._suffixes = tuple(suffixes)
|
|
52
|
+
self._store = clean_id(store) or clean_id(self._root.name) or "memory"
|
|
53
|
+
self._max_files = max_files
|
|
54
|
+
self._max_total_chars = max_total_chars
|
|
55
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
56
|
+
self._git = None # type: ignore[assignment] # there is no git here; nothing in this class may use it
|
|
57
|
+
self._subdir = self._validate_subdir(subdir)
|
|
58
|
+
self._only: tuple[str, ...] | None = None
|
|
59
|
+
if only is not None:
|
|
60
|
+
names = tuple(only)
|
|
61
|
+
if not names or len(names) > 10 or any("/" in n or _valid_relpath(n, self._suffixes) is None for n in names):
|
|
62
|
+
raise AdapterError("the files to watch must be 1 to 10 plain markdown file names in the folder itself")
|
|
63
|
+
self._only = names
|
|
64
|
+
|
|
65
|
+
def read_history(self, max_rows: int) -> HistoryRead:
|
|
66
|
+
return HistoryRead(events=[], refs=set(), truncated=False)
|
|
67
|
+
|
|
68
|
+
def history(self, memory_id: str) -> list[MemoryEvent]:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def list_memories(self, scope: dict[str, str]) -> LiveMemories:
|
|
72
|
+
"""With `only`, exactly those files and nothing else: the rest of the folder (often a settings folder that also holds
|
|
73
|
+
credentials) is never listed or opened."""
|
|
74
|
+
if self._only is None:
|
|
75
|
+
return super().list_memories(scope)
|
|
76
|
+
if scope != {"store": self._store}:
|
|
77
|
+
raise AdapterError(f"scope must be {{'store': {safe_text(self._store, 40)!r}}} for this folder")
|
|
78
|
+
warnings = Warnings()
|
|
79
|
+
memories: list[Memory] = []
|
|
80
|
+
complete = True
|
|
81
|
+
for name in self._only:
|
|
82
|
+
full = os.path.join(self._root, name)
|
|
83
|
+
try:
|
|
84
|
+
os.lstat(full)
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
continue # not created yet (an agent writes it the first time it remembers something)
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
complete = False
|
|
89
|
+
warnings.add(f"{safe_text(name, 60)} could not be inspected: {exc.strerror}")
|
|
90
|
+
continue
|
|
91
|
+
text = self._read_working_file(full, name, warnings)
|
|
92
|
+
if text is None:
|
|
93
|
+
complete = False
|
|
94
|
+
continue
|
|
95
|
+
memories.append(Memory(id=name, text=text, scope={"store": self._store}))
|
|
96
|
+
return LiveMemories(memories=memories, complete=complete, warnings=warnings.as_list())
|