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/models.py ADDED
@@ -0,0 +1,157 @@
1
+ """Canonical records. Every adapter translates its backend's history into these.
2
+
3
+ Field sizes are bounded here so one hostile row cannot bloat the ledger.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from enum import Enum
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from .textsafe import MAX_TEXT_CHARS
13
+
14
+ MAX_ID_CHARS = 256
15
+ MAX_FIELD_CHARS = MAX_TEXT_CHARS + 256 # room for the cut marker
16
+ MAX_SCOPE_ITEMS = 8
17
+
18
+
19
+ def _check_scope(value: dict[str, str]) -> dict[str, str]:
20
+ if len(value) > MAX_SCOPE_ITEMS:
21
+ raise ValueError("too many scope entries")
22
+ for key, item in value.items():
23
+ if not key or len(key) > MAX_ID_CHARS or len(item) > MAX_ID_CHARS:
24
+ raise ValueError("scope entry is empty or too long")
25
+ return value
26
+
27
+
28
+ class Op(str, Enum):
29
+ ADD = "ADD"
30
+ UPDATE = "UPDATE"
31
+ DELETE = "DELETE"
32
+ EXTERNAL = "EXTERNAL" # change found with no matching API history entry
33
+ SNAPSHOT = "SNAPSHOT" # bookkeeping: a snapshot was taken (its content hash is chained here)
34
+ SNAPSHOT_DELETED = "SNAP_DEL" # bookkeeping: a snapshot was deleted
35
+ ROLLBACK = "ROLLBACK" # bookkeeping: a memory store was restored to a snapshot (what was done is chained here)
36
+
37
+
38
+ META_OPS = frozenset({Op.SNAPSHOT, Op.SNAPSHOT_DELETED, Op.ROLLBACK}) # about the ledger itself, not about memories
39
+
40
+
41
+ class Trust(str, Enum):
42
+ TRUSTED = "trusted"
43
+ UNTRUSTED = "untrusted"
44
+ UNKNOWN = "unknown"
45
+
46
+
47
+ class SourceKind(str, Enum):
48
+ USER_MESSAGE = "user_message"
49
+ TOOL_RESULT = "tool_result"
50
+ CONSOLIDATION = "consolidation"
51
+ UNKNOWN = "unknown"
52
+
53
+
54
+ class Source(BaseModel):
55
+ """Where a memory write came from. Session fields are filled by a session-source adapter
56
+ (milestone 7). actor_id and role are whatever the backend recorded; they are not trusted
57
+ to decide the source kind."""
58
+
59
+ model_config = ConfigDict(extra="forbid")
60
+
61
+ session_id: str | None = Field(default=None, max_length=MAX_ID_CHARS)
62
+ turn: int | None = None
63
+ kind: SourceKind = SourceKind.UNKNOWN
64
+ actor_id: str | None = Field(default=None, max_length=MAX_ID_CHARS)
65
+ role: str | None = Field(default=None, max_length=MAX_ID_CHARS)
66
+ # What was observed about where this came from, in words (a backend's own label, how close a chat was). Descriptive only:
67
+ # it never decides the kind or the trust.
68
+ note: str | None = Field(default=None, max_length=400)
69
+
70
+
71
+ def derive_trust(source: Source | None) -> Trust:
72
+ """Simple rule: text a user wrote is trusted, text a tool returned is not."""
73
+ if source is None:
74
+ return Trust.UNKNOWN
75
+ if source.kind == SourceKind.USER_MESSAGE:
76
+ return Trust.TRUSTED
77
+ if source.kind == SourceKind.TOOL_RESULT:
78
+ return Trust.UNTRUSTED
79
+ return Trust.UNKNOWN
80
+
81
+
82
+ class MemoryEvent(BaseModel):
83
+ model_config = ConfigDict(extra="forbid")
84
+
85
+ backend: str = Field(min_length=1, max_length=64)
86
+ memory_id: str = Field(min_length=1, max_length=MAX_ID_CHARS)
87
+ op: Op
88
+ ts: datetime
89
+ ts_observed: bool = False # True when ts is when we saw it, not when the backend wrote it
90
+ backend_ref: str | None = Field(default=None, max_length=MAX_ID_CHARS) # backend's own row id
91
+ scope: dict[str, str] = Field(default_factory=dict)
92
+ before: str | None = Field(default=None, max_length=MAX_FIELD_CHARS)
93
+ after: str | None = Field(default=None, max_length=MAX_FIELD_CHARS)
94
+ source: Source | None = None
95
+ trust: Trust = Trust.UNKNOWN
96
+
97
+ @field_validator("ts")
98
+ @classmethod
99
+ def _aware(cls, value: datetime) -> datetime:
100
+ if value.tzinfo is None:
101
+ raise ValueError("timestamps must carry a timezone")
102
+ return value
103
+
104
+ @field_validator("scope")
105
+ @classmethod
106
+ def _scope(cls, value: dict[str, str]) -> dict[str, str]:
107
+ return _check_scope(value)
108
+
109
+
110
+ class LedgerEntry(BaseModel):
111
+ seq: int
112
+ id: str
113
+ event: MemoryEvent
114
+ prev_hash: str
115
+ hash: str
116
+
117
+
118
+ class Memory(BaseModel):
119
+ """One memory as the backend holds it right now."""
120
+
121
+ model_config = ConfigDict(extra="forbid")
122
+
123
+ id: str = Field(min_length=1, max_length=MAX_ID_CHARS)
124
+ text: str = Field(max_length=MAX_FIELD_CHARS)
125
+ scope: dict[str, str] = Field(default_factory=dict)
126
+ source: Source | None = None # what the backend says about where it came from, when it says anything
127
+
128
+ @field_validator("scope")
129
+ @classmethod
130
+ def _scope(cls, value: dict[str, str]) -> dict[str, str]:
131
+ return _check_scope(value)
132
+
133
+
134
+ class SnapshotInfo(BaseModel):
135
+ """What is known about a snapshot without loading its memories."""
136
+
137
+ model_config = ConfigDict(extra="forbid")
138
+
139
+ id: str = Field(pattern=r"^s[1-9][0-9]{0,8}$")
140
+ backend: str = Field(min_length=1, max_length=64)
141
+ scope: dict[str, str] = Field(default_factory=dict)
142
+ taken_at: datetime
143
+ ledger_seq: int = Field(ge=0) # the ledger entry this snapshot was taken after
144
+ ledger_head: str = Field(min_length=64, max_length=64)
145
+ complete: bool # False: the listing may have been cut short, so absence proves nothing
146
+ label: str | None = Field(default=None, max_length=100)
147
+ count: int = Field(ge=0)
148
+
149
+ @field_validator("scope")
150
+ @classmethod
151
+ def _scope(cls, value: dict[str, str]) -> dict[str, str]:
152
+ return _check_scope(value)
153
+
154
+
155
+ class Snapshot(BaseModel):
156
+ info: SnapshotInfo
157
+ memories: list[Memory]
memdebug/monitor.py ADDED
@@ -0,0 +1,246 @@
1
+ """Looking at every registered store: `check` (one pass), `status` (no changes made) and `watch` (repeat).
2
+
3
+ Each store is checked on its own: one that cannot be read is reported, and the others still run. Nothing here changes a
4
+ memory store; the only thing written is the ledger (and, if one is configured, the witness).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from .backends import friendly_id
15
+ from .errors import MemdebugError
16
+ from .hints import Hint, new_hints, scan
17
+ from .ledger import Ledger
18
+ from .models import Op
19
+ from .stores import Registry, StoreConfig, open_store
20
+ from .sync import sync
21
+ from .textsafe import safe_text
22
+ from .witness import SAME_DISK_WARNING, WitnessError, append_witness, same_disk
23
+
24
+ VERBS = {Op.ADD: "added", Op.UPDATE: "edited", Op.DELETE: "removed", Op.EXTERNAL: "changed outside the history"}
25
+ MIN_INTERVAL = 5.0
26
+
27
+
28
+ @dataclass
29
+ class StoreResult:
30
+ store: StoreConfig
31
+ changes: list[tuple[Op, str]] = field(default_factory=list)
32
+ outside_history: int = 0
33
+ error: str | None = None
34
+ warnings: list[str] = field(default_factory=list)
35
+ hints: list[tuple[str, Hint]] = field(default_factory=list) # wording worth a second look in what changed
36
+ previews: dict[str, str] = field(default_factory=dict) # the start of what each changed memory says, to label opaque ids
37
+
38
+ @property
39
+ def attention(self) -> bool:
40
+ return self.outside_history > 0
41
+
42
+ @property
43
+ def quiet(self) -> bool:
44
+ return not self.changes and self.error is None
45
+
46
+
47
+ @dataclass
48
+ class CheckSummary:
49
+ results: list[StoreResult]
50
+ ledger_ok: bool
51
+ ledger_problems: list[str] = field(default_factory=list)
52
+ witness_note: str | None = None
53
+ witness_warning: str | None = None
54
+
55
+ @property
56
+ def attention(self) -> bool:
57
+ return not self.ledger_ok or any(r.attention for r in self.results)
58
+
59
+ @property
60
+ def hinted(self) -> bool:
61
+ return any(r.hints for r in self.results)
62
+
63
+ def exit_code_for(self, strict: bool = False) -> int:
64
+ """1: something needs a look (with strict, also wording worth a second look); 2: a store could not be checked; else 0."""
65
+ return 1 if (self.attention or (strict and self.hinted)) else 2 if self.failed else 0
66
+
67
+ @property
68
+ def failed(self) -> bool:
69
+ return any(r.error for r in self.results)
70
+
71
+ @property
72
+ def exit_code(self) -> int:
73
+ return 1 if self.attention else 2 if self.failed else 0
74
+
75
+
76
+ def _list(ids: list[str], limit: int = 3, previews: dict[str, str] | None = None) -> str:
77
+ shown = ", ".join(friendly_id(i, (previews or {}).get(i)) for i in ids[:limit])
78
+ return shown + (f" and {len(ids) - limit} more" if len(ids) > limit else "")
79
+
80
+
81
+ def describe(result: StoreResult) -> str:
82
+ name = safe_text(result.store.name, 40)
83
+ if result.error:
84
+ return f" {name}: COULD NOT BE CHECKED: {result.error}"
85
+ if result.quiet:
86
+ return f" {name}: quiet, nothing new"
87
+ parts = []
88
+ for op in (Op.EXTERNAL, Op.ADD, Op.UPDATE, Op.DELETE):
89
+ ids = [i for o, i in result.changes if o == op]
90
+ if ids:
91
+ parts.append(f"{VERBS[op]}: {_list(ids, previews=result.previews)}")
92
+ lead = "ATTENTION" if result.attention else f"{len(result.changes)} change{'' if len(result.changes) == 1 else 's'} noticed"
93
+ return f" {name}: {lead} ({'; '.join(parts)})"
94
+
95
+
96
+ def hint_lines(result: StoreResult) -> list[str]:
97
+ shown = [f" worth a second look: {friendly_id(mid, result.previews.get(mid))}: {safe_text(h.message, 120)}" for mid, h in result.hints[:3]]
98
+ if len(result.hints) > 3:
99
+ shown.append(f" ... and {len(result.hints) - 3} more (see 'memdebug serve' or 'memdebug report')")
100
+ return shown
101
+
102
+
103
+ def check_store(store: StoreConfig, ledger: Ledger, *, settle: float = 1.0) -> StoreResult:
104
+ result = StoreResult(store)
105
+ before = ledger.counts()["events"]
106
+ try:
107
+ opened = open_store(store)
108
+ report = sync(opened.adapter, ledger, opened.scope, settle_seconds=settle)
109
+ except MemdebugError as exc:
110
+ result.error = safe_text(exc, 300)
111
+ return result
112
+ except Exception as exc: # one store failing for an unexpected reason must not stop the others
113
+ result.error = f"unexpected error ({type(exc).__name__})"
114
+ return result
115
+ result.warnings = [safe_text(w, 300) for w in opened.notes + report.warnings]
116
+ result.outside_history = report.external_events
117
+ for entry in ledger.entries()[before:]:
118
+ event = entry.event
119
+ if event.op in VERBS:
120
+ result.changes.append((event.op, event.memory_id))
121
+ result.previews[event.memory_id] = event.after if event.after is not None else (event.before or "")
122
+ if event.op in (Op.ADD, Op.UPDATE, Op.EXTERNAL) and len(result.hints) < 20:
123
+ result.hints += [(event.memory_id, h) for h in new_hints(event.before, event.after, budget=0.1, max_chars=20_000)
124
+ if h.severity == "warning"][:3]
125
+ return result
126
+
127
+
128
+ def check_all(registry: Registry, ledger: Ledger, *, settle: float = 1.0, ledger_path: Path | None = None) -> CheckSummary:
129
+ results = [check_store(store, ledger, settle=settle) for store in registry.stores]
130
+ verdict = ledger.verify()
131
+ summary = CheckSummary(results, verdict.ok, [safe_text(p, 300) for p in verdict.problems[:5]])
132
+ if registry.witness and verdict.ok and ledger.counts()["events"]:
133
+ try:
134
+ line, new = append_witness(ledger, Path(registry.witness))
135
+ summary.witness_note = f"witness updated (entry {line.seq})" if new else "witness already up to date"
136
+ if ledger_path is not None and same_disk(ledger_path, Path(registry.witness)):
137
+ summary.witness_warning = SAME_DISK_WARNING
138
+ except WitnessError as exc:
139
+ summary.witness_note = f"the witness could not be updated: {safe_text(exc, 200)}"
140
+ return summary
141
+
142
+
143
+ def summary_lines(summary: CheckSummary) -> list[str]:
144
+ lines = [line for r in summary.results for line in [describe(r), *hint_lines(r)]]
145
+ lines.append(" The ledger is intact." if summary.ledger_ok else " PROBLEM: the ledger failed its integrity check: "
146
+ + (summary.ledger_problems[0] if summary.ledger_problems else ""))
147
+ if summary.witness_note:
148
+ lines.append(f" {summary.witness_note}")
149
+ return lines
150
+
151
+
152
+ # -- status: look, change nothing -----------------------------------------------------------------------------------------
153
+
154
+ def ago(then: datetime, now: datetime) -> str:
155
+ seconds = max(0, int((now - then).total_seconds()))
156
+ for limit, unit, size in ((90, "second", 1), (5400, "minute", 60), (172800, "hour", 3600)):
157
+ if seconds < limit:
158
+ count = max(1, seconds // size)
159
+ return f"{count} {unit}{'' if count == 1 else 's'} ago"
160
+ days = seconds // 86400
161
+ return f"{days} days ago"
162
+
163
+
164
+ def status_lines(registry: Registry, ledger: Ledger, *, now: datetime | None = None) -> list[str]:
165
+ now = now or datetime.now(timezone.utc)
166
+ entries = ledger.entries()
167
+ snapshots = ledger.list_snapshots()
168
+ lines: list[str] = []
169
+ for store in registry.stores:
170
+ name = safe_text(store.name, 40)
171
+ try:
172
+ opened = open_store(store, refresh=False) # status only looks: it reads the copy that is already there
173
+ live = opened.adapter.list_memories(opened.scope)
174
+ backend = opened.adapter.name
175
+ except MemdebugError as exc:
176
+ lines.append(f" {name} ({store.kind}): cannot be read: {safe_text(exc, 200)}")
177
+ continue
178
+ mine = [e for e in entries if e.event.backend == backend and e.event.scope == opened.scope]
179
+ outside = sum(1 for e in mine if e.event.op == Op.EXTERNAL)
180
+ snaps = [s for s in snapshots if s.backend == backend and s.scope == opened.scope]
181
+ last = f"last snapshot {snaps[-1].id}, {ago(snaps[-1].taken_at, now)}" if snaps else "no snapshot yet (take one: memdebug snapshot ...)"
182
+ seen = f"last entry recorded {ago(mine[-1].event.ts, now)}" if mine else "nothing recorded yet"
183
+ flag = f"; {outside} change(s) outside the history on record" if outside else ""
184
+ partial = " (listing may be incomplete)" if not live.complete else ""
185
+ lines.append(f" {name} ({store.kind}): {len(live.memories)} memories{partial}; {seen}; {last}{flag}")
186
+ return lines
187
+
188
+
189
+ # -- what a store holds right now ---------------------------------------------------------------------------------------
190
+
191
+ def store_hints(store: StoreConfig, *, limit: int = 10, budget: float = 3.0, refresh: bool = True) -> list[tuple[str, Hint]]:
192
+ """Wording worth a second look in what a store holds now, so a planted phrase that is already there is not missed."""
193
+ opened = open_store(store, refresh=refresh)
194
+ found: list[tuple[str, Hint]] = []
195
+ deadline = time.monotonic() + budget
196
+ for memory in opened.adapter.list_memories(opened.scope).memories:
197
+ if time.monotonic() > deadline or len(found) >= limit:
198
+ break
199
+ found += [(friendly_id(memory.id, memory.text), h) for h in scan(memory.text, budget=0.05, max_chars=20_000) if h.severity == "warning"][:2]
200
+ return found[:limit]
201
+
202
+
203
+ # -- baseline ---------------------------------------------------------------------------------------------------------
204
+
205
+ def baseline(store: StoreConfig, ledger: Ledger, label: str = "baseline", *, refresh: bool = True) -> str:
206
+ """Record what the store holds now and save a snapshot of it. Returns the snapshot id."""
207
+ opened = open_store(store, refresh=refresh)
208
+ report = sync(opened.adapter, ledger, opened.scope, settle_seconds=0.0, adopt_existing=True)
209
+ if report.live is None:
210
+ raise MemdebugError("the store could not be listed")
211
+ info = ledger.save_snapshot(opened.adapter.name, opened.scope, report.live.memories, complete=report.live.complete,
212
+ taken_at=datetime.now(timezone.utc), label=label)
213
+ return info.id
214
+
215
+
216
+ # -- watch -----------------------------------------------------------------------------------------------------------------
217
+
218
+ def watch(registry: Registry, ledger: Ledger, *, every: float, say: Callable[[str], None], ring: Callable[[], None] = lambda: None,
219
+ cycles: int | None = None, sleep: Callable[[float], None] = time.sleep, settle: float = 1.0, ledger_path: Path | None = None,
220
+ clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc)) -> None:
221
+ """Check every store again and again. Only things worth knowing are printed: changes, and an error when it first appears."""
222
+ every = max(MIN_INTERVAL, every)
223
+ say(f"Watching {len(registry.stores)} store(s) every {every:g} seconds. Press Ctrl+C to stop.")
224
+ last_error: dict[str, str | None] = {}
225
+ done = 0
226
+ while cycles is None or done < cycles:
227
+ summary = check_all(registry, ledger, settle=settle, ledger_path=ledger_path)
228
+ stamp = clock().astimezone().strftime("%H:%M:%S")
229
+ shown = False
230
+ for result in summary.results:
231
+ if result.error and last_error.get(result.store.name) == result.error:
232
+ continue
233
+ last_error[result.store.name] = result.error
234
+ if not result.quiet:
235
+ say(f"[{stamp}]{describe(result)}")
236
+ for line in hint_lines(result):
237
+ say(line)
238
+ shown = True
239
+ if not summary.ledger_ok:
240
+ say(f"[{stamp}] PROBLEM: the ledger failed its integrity check")
241
+ shown = True
242
+ if shown and (summary.attention or summary.hinted):
243
+ ring()
244
+ done += 1
245
+ if cycles is None or done < cycles:
246
+ sleep(every)
memdebug/paths.py ADDED
@@ -0,0 +1,19 @@
1
+ """Where the ledger lives by default: a per-user folder, not the current directory (which could be
2
+ inside the repository being watched, or a shared folder)."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def default_ledger_path() -> Path:
10
+ if os.name == "nt":
11
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
12
+ else:
13
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
14
+ return Path(base) / "memdebug" / "ledger.db"
15
+
16
+
17
+ def default_config_path() -> Path:
18
+ """The list of stores memdebug has been told to watch, next to the default ledger."""
19
+ return default_ledger_path().with_name("stores.json")
memdebug/reconcile.py ADDED
@@ -0,0 +1,83 @@
1
+ """Compare what the ledger says the store holds with what the backend holds now.
2
+
3
+ Differences become EXTERNAL events: changes that bypassed the backend's own API history.
4
+ Two rules keep this from raising false alarms:
5
+ * deletions are only claimed for memories in the same scope as the live listing, and
6
+ * deletions are never claimed from a listing that may be cut short.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from typing import Iterable
13
+
14
+ from .models import META_OPS, Memory, MemoryEvent, Op
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class KnownMemory:
19
+ text: str
20
+ scope: dict[str, str] = field(default_factory=dict)
21
+
22
+
23
+ def replay_events(events: Iterable[MemoryEvent]) -> dict[str, KnownMemory]:
24
+ """Memory text per id, as the event stream says it is right now."""
25
+ state: dict[str, KnownMemory] = {}
26
+ for event in events:
27
+ if event.op in META_OPS:
28
+ continue # snapshot bookkeeping says nothing about what a memory contains
29
+ if event.op == Op.DELETE or event.after is None:
30
+ state.pop(event.memory_id, None)
31
+ continue
32
+ previous = state.get(event.memory_id)
33
+ scope = event.scope or (previous.scope if previous else {})
34
+ state[event.memory_id] = KnownMemory(event.after, scope)
35
+ return state
36
+
37
+
38
+ def scope_matches(memory_scope: dict[str, str], wanted: dict[str, str]) -> bool:
39
+ """True only when the memory's scope is known and contains every wanted entry."""
40
+ return bool(memory_scope) and all(memory_scope.get(k) == v for k, v in wanted.items())
41
+
42
+
43
+ def baseline_events(live: list[Memory], backend: str, now: datetime) -> list[MemoryEvent]:
44
+ """First run on an existing store: record what is already there as observed ADDs."""
45
+ return [
46
+ MemoryEvent(
47
+ backend=backend, memory_id=m.id, op=Op.ADD, ts=now, ts_observed=True,
48
+ scope=m.scope, after=m.text,
49
+ )
50
+ for m in sorted(live, key=lambda m: m.id)
51
+ ]
52
+
53
+
54
+ def find_external_changes(
55
+ known: dict[str, KnownMemory],
56
+ live: list[Memory],
57
+ backend: str,
58
+ now: datetime,
59
+ *,
60
+ scope: dict[str, str],
61
+ complete: bool = True,
62
+ ) -> list[MemoryEvent]:
63
+ events: list[MemoryEvent] = []
64
+ live_ids = {m.id for m in live}
65
+ for m in sorted(live, key=lambda m: m.id):
66
+ entry = known.get(m.id)
67
+ if entry is None or entry.text != m.text:
68
+ events.append(
69
+ MemoryEvent(
70
+ backend=backend, memory_id=m.id, op=Op.EXTERNAL, ts=now, ts_observed=True,
71
+ scope=m.scope, before=entry.text if entry else None, after=m.text,
72
+ )
73
+ )
74
+ if complete:
75
+ in_scope = {i: k for i, k in known.items() if scope_matches(k.scope, scope)}
76
+ for memory_id in sorted(set(in_scope) - live_ids):
77
+ events.append(
78
+ MemoryEvent(
79
+ backend=backend, memory_id=memory_id, op=Op.EXTERNAL, ts=now, ts_observed=True,
80
+ scope=in_scope[memory_id].scope, before=in_scope[memory_id].text, after=None,
81
+ )
82
+ )
83
+ return events