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/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
|