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 ADDED
@@ -0,0 +1,3 @@
1
+ """memdebug: inspect, compare and roll back agent memory."""
2
+
3
+ __version__ = "0.2.0"
memdebug/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
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())