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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Read-only adapter for self-hosted Mem0 (verified against mem0ai 2.2.1).
|
|
2
|
+
|
|
3
|
+
Where the data comes from:
|
|
4
|
+
* live memories -> Mem0's own get_all() (public API)
|
|
5
|
+
* the global feed -> Mem0's history database, opened strictly read-only, because the
|
|
6
|
+
public API cannot list the history of memories that were deleted.
|
|
7
|
+
* one memory's log -> Mem0's own history() (public API)
|
|
8
|
+
|
|
9
|
+
This adapter never writes to Mem0 or to its database. Everything it reads is untrusted.
|
|
10
|
+
|
|
11
|
+
Mem0 facts this relies on (2.2.1):
|
|
12
|
+
* history rows: id, memory_id, old_memory, new_memory, event, created_at, updated_at,
|
|
13
|
+
is_deleted, actor_id, role. Events are 'ADD', 'UPDATE', 'DELETE'.
|
|
14
|
+
* for UPDATE and DELETE rows, created_at is when the *memory* was created and updated_at is
|
|
15
|
+
when the *event* happened; for ADD both are the creation time.
|
|
16
|
+
* rows have no scope column; scope has to come from the live listing.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import sys
|
|
25
|
+
from contextlib import closing
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
from pydantic import ValidationError
|
|
31
|
+
|
|
32
|
+
from ..errors import AdapterError, MemdebugError, UnsupportedSchemaError
|
|
33
|
+
from ..models import MAX_ID_CHARS, Memory, MemoryEvent, Op, Source
|
|
34
|
+
from ..textsafe import bound_text, safe_text
|
|
35
|
+
from .base import HistoryRead, LiveMemories
|
|
36
|
+
from .common import Warnings as _Warnings
|
|
37
|
+
from .common import clean_id as _clean_id
|
|
38
|
+
from .common import decode as _decode
|
|
39
|
+
from .common import parse_ts as _parse_ts
|
|
40
|
+
from .common import require_regular_file as _require_regular_file
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
REQUIRED_COLUMNS = ("id", "memory_id", "old_memory", "new_memory", "event", "created_at", "updated_at")
|
|
45
|
+
OPTIONAL_COLUMNS = ("actor_id", "role") # fixed names; column names are never taken from data
|
|
46
|
+
SCOPE_KEYS = ("user_id", "agent_id", "run_id")
|
|
47
|
+
_OPS = {"ADD": Op.ADD, "UPDATE": Op.UPDATE, "DELETE": Op.DELETE}
|
|
48
|
+
_MAX_CONFIG_BYTES = 1_000_000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text_or_none(value: object) -> tuple[bool, str | None]:
|
|
52
|
+
if value is None:
|
|
53
|
+
return True, None
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return True, bound_text(value)
|
|
56
|
+
return False, None # BLOB, number or anything else SQLite's loose typing allowed in
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_scope(scope: object) -> dict[str, str]:
|
|
60
|
+
if not isinstance(scope, dict) or not scope:
|
|
61
|
+
raise AdapterError("a scope is required: give user_id, agent_id or run_id")
|
|
62
|
+
clean: dict[str, str] = {}
|
|
63
|
+
for key, value in scope.items():
|
|
64
|
+
if key not in SCOPE_KEYS:
|
|
65
|
+
raise AdapterError(f"unsupported scope key: {safe_text(key, 30)}")
|
|
66
|
+
if _clean_id(value) is None:
|
|
67
|
+
raise AdapterError(f"scope value for {key} must be a non-empty string of at most {MAX_ID_CHARS} characters")
|
|
68
|
+
clean[key] = value
|
|
69
|
+
return clean
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Mem0Adapter:
|
|
73
|
+
name = "mem0"
|
|
74
|
+
capabilities = {"history", "global_feed"}
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
memory: Any,
|
|
79
|
+
history_db_path: str | Path,
|
|
80
|
+
*,
|
|
81
|
+
max_rows: int = 100_000,
|
|
82
|
+
live_limit: int = 10_000,
|
|
83
|
+
max_total_chars: int = 100_000_000,
|
|
84
|
+
clock: Callable[[], datetime] | None = None,
|
|
85
|
+
):
|
|
86
|
+
for attribute in ("get_all", "history"):
|
|
87
|
+
if not callable(getattr(memory, attribute, None)):
|
|
88
|
+
raise AdapterError(f"memory object has no callable {attribute}(); expected a mem0 Memory")
|
|
89
|
+
if not (isinstance(max_rows, int) and max_rows > 0 and isinstance(live_limit, int) and live_limit > 0):
|
|
90
|
+
raise AdapterError("max_rows and live_limit must be positive integers")
|
|
91
|
+
self._memory = memory
|
|
92
|
+
self._path = Path(history_db_path)
|
|
93
|
+
self._max_rows = max_rows
|
|
94
|
+
self._live_limit = live_limit
|
|
95
|
+
self._max_total_chars = max_total_chars
|
|
96
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
97
|
+
with closing(self._connect_ro()) as conn: # fail early on a wrong file
|
|
98
|
+
self._check_schema(conn)
|
|
99
|
+
|
|
100
|
+
# -- read-only database access ----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _connect_ro(self) -> sqlite3.Connection:
|
|
103
|
+
path = _require_regular_file(self._path)
|
|
104
|
+
try:
|
|
105
|
+
# mode=ro: never creates the file, never writes. query_only is a second lock.
|
|
106
|
+
conn = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True, timeout=5)
|
|
107
|
+
conn.text_factory = _decode
|
|
108
|
+
conn.execute("PRAGMA query_only = ON")
|
|
109
|
+
return conn
|
|
110
|
+
except sqlite3.Error as exc:
|
|
111
|
+
raise AdapterError(f"cannot open history file read-only: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _check_schema(conn: sqlite3.Connection) -> set[str]:
|
|
115
|
+
try:
|
|
116
|
+
present = {row[1] for row in conn.execute("PRAGMA table_info(history)")}
|
|
117
|
+
except sqlite3.Error as exc:
|
|
118
|
+
raise AdapterError(f"cannot read history file: {exc}") from exc
|
|
119
|
+
if not present:
|
|
120
|
+
raise UnsupportedSchemaError("no 'history' table found; is this Mem0's history database?")
|
|
121
|
+
missing = [c for c in REQUIRED_COLUMNS if c not in present]
|
|
122
|
+
if missing:
|
|
123
|
+
raise UnsupportedSchemaError(f"history table lacks expected columns: {', '.join(missing)}")
|
|
124
|
+
return present
|
|
125
|
+
|
|
126
|
+
# -- row -> event -------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _event_from_row(
|
|
129
|
+
self, row: dict[str, Any], label: str, ref_fallback: str, now: datetime, warnings: _Warnings
|
|
130
|
+
) -> tuple[MemoryEvent | None, str]:
|
|
131
|
+
ref = _clean_id(row.get("id")) or ref_fallback
|
|
132
|
+
memory_id = _clean_id(row.get("memory_id"))
|
|
133
|
+
if memory_id is None:
|
|
134
|
+
warnings.add(f"{label}: missing or invalid memory_id; row skipped")
|
|
135
|
+
return None, ref
|
|
136
|
+
raw_event = row.get("event")
|
|
137
|
+
op = _OPS.get(raw_event.strip().upper()) if isinstance(raw_event, str) else None
|
|
138
|
+
if op is None:
|
|
139
|
+
warnings.add(f"{label}: unknown event {safe_text(raw_event, 30)!r}; row skipped")
|
|
140
|
+
return None, ref
|
|
141
|
+
ok_old, old = _text_or_none(row.get("old_memory"))
|
|
142
|
+
ok_new, new = _text_or_none(row.get("new_memory"))
|
|
143
|
+
if not (ok_old and ok_new):
|
|
144
|
+
warnings.add(f"{label}: memory text is not text; row skipped")
|
|
145
|
+
return None, ref
|
|
146
|
+
if op is Op.UPDATE and new is None:
|
|
147
|
+
warnings.add(f"{label}: UPDATE without new text")
|
|
148
|
+
|
|
149
|
+
created = _parse_ts(row.get("created_at"))
|
|
150
|
+
updated = _parse_ts(row.get("updated_at"))
|
|
151
|
+
ts = (created or updated) if op is Op.ADD else (updated or created)
|
|
152
|
+
observed = False
|
|
153
|
+
if ts is None:
|
|
154
|
+
ts, observed = now, True
|
|
155
|
+
warnings.add(f"{label}: no usable timestamp; using the time it was read")
|
|
156
|
+
elif ts > now + timedelta(days=1):
|
|
157
|
+
warnings.add(f"{label}: timestamp is in the future")
|
|
158
|
+
|
|
159
|
+
actor = _clean_id(row.get("actor_id"))
|
|
160
|
+
role = _clean_id(row.get("role"))
|
|
161
|
+
source = Source(actor_id=actor, role=role) if (actor or role) else None
|
|
162
|
+
try:
|
|
163
|
+
event = MemoryEvent(
|
|
164
|
+
backend=self.name, memory_id=memory_id, op=op, ts=ts, ts_observed=observed,
|
|
165
|
+
backend_ref=ref, before=old, after=new, source=source,
|
|
166
|
+
)
|
|
167
|
+
except ValidationError:
|
|
168
|
+
warnings.add(f"{label}: row failed validation; skipped")
|
|
169
|
+
return None, ref
|
|
170
|
+
return event, ref
|
|
171
|
+
|
|
172
|
+
# -- the adapter interface -------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def read_history(self, max_rows: int) -> HistoryRead:
|
|
175
|
+
if not (isinstance(max_rows, int) and max_rows > 0):
|
|
176
|
+
raise AdapterError("max_rows must be a positive integer")
|
|
177
|
+
warnings = _Warnings()
|
|
178
|
+
now = self._clock()
|
|
179
|
+
pairs: list[tuple[MemoryEvent, int]] = []
|
|
180
|
+
refs: set[str] = set()
|
|
181
|
+
skipped = 0
|
|
182
|
+
truncated = False
|
|
183
|
+
total_chars = 0
|
|
184
|
+
with closing(self._connect_ro()) as conn:
|
|
185
|
+
present = self._check_schema(conn)
|
|
186
|
+
names = list(REQUIRED_COLUMNS) + [c for c in OPTIONAL_COLUMNS if c in present]
|
|
187
|
+
# Identifiers come from the fixed tuples above; values are always bound parameters.
|
|
188
|
+
query = f"SELECT rowid, {', '.join(names)} FROM history ORDER BY rowid LIMIT ?"
|
|
189
|
+
try:
|
|
190
|
+
for index, db_row in enumerate(conn.execute(query, (max_rows + 1,))):
|
|
191
|
+
if index >= max_rows:
|
|
192
|
+
truncated = True
|
|
193
|
+
break
|
|
194
|
+
rowid = db_row[0]
|
|
195
|
+
row = dict(zip(names, db_row[1:], strict=False))
|
|
196
|
+
event, ref = self._event_from_row(row, f"history rowid {rowid}", f"rowid:{rowid}", now, warnings)
|
|
197
|
+
refs.add(ref)
|
|
198
|
+
if event is None:
|
|
199
|
+
skipped += 1
|
|
200
|
+
continue
|
|
201
|
+
total_chars += len(event.before or "") + len(event.after or "")
|
|
202
|
+
if total_chars > self._max_total_chars:
|
|
203
|
+
truncated = True
|
|
204
|
+
warnings.add("history text is larger than the memory budget; the rest was not read")
|
|
205
|
+
break
|
|
206
|
+
pairs.append((event, rowid))
|
|
207
|
+
except sqlite3.Error as exc:
|
|
208
|
+
raise AdapterError(f"reading history failed: {exc}") from exc
|
|
209
|
+
pairs.sort(key=lambda pair: (pair[0].ts, pair[1]))
|
|
210
|
+
if truncated:
|
|
211
|
+
warnings.add(f"history has more than {max_rows} rows; only the oldest {max_rows} were read")
|
|
212
|
+
return HistoryRead(
|
|
213
|
+
events=[event for event, _ in pairs], refs=refs, truncated=truncated,
|
|
214
|
+
skipped=skipped, warnings=warnings.as_list(),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def list_memories(self, scope: dict[str, str]) -> LiveMemories:
|
|
218
|
+
clean = validate_scope(scope)
|
|
219
|
+
try:
|
|
220
|
+
result = self._memory.get_all(filters=dict(clean), top_k=self._live_limit, show_expired=True)
|
|
221
|
+
except Exception as exc: # any failure inside the backend becomes one controlled error
|
|
222
|
+
raise AdapterError(f"mem0 get_all failed: {type(exc).__name__}: {safe_text(exc, 200)}") from exc
|
|
223
|
+
if not isinstance(result, dict) or not isinstance(result.get("results"), list):
|
|
224
|
+
raise AdapterError("mem0 get_all returned an unexpected shape; is this mem0ai 2.x?")
|
|
225
|
+
items = result["results"]
|
|
226
|
+
warnings = _Warnings()
|
|
227
|
+
complete = len(items) < self._live_limit
|
|
228
|
+
if not complete:
|
|
229
|
+
warnings.add(
|
|
230
|
+
f"listing reached the limit of {self._live_limit}; absence will not be reported as deletion"
|
|
231
|
+
)
|
|
232
|
+
memories: list[Memory] = []
|
|
233
|
+
seen: set[str] = set()
|
|
234
|
+
for index, item in enumerate(items):
|
|
235
|
+
memory_id = _clean_id(item.get("id")) if isinstance(item, dict) else None
|
|
236
|
+
text = item.get("memory") if isinstance(item, dict) else None
|
|
237
|
+
if memory_id is None or not isinstance(text, str):
|
|
238
|
+
warnings.add(f"live item {index}: missing id or text; skipped")
|
|
239
|
+
complete = False # a hidden memory could be mistaken for a deleted one
|
|
240
|
+
continue
|
|
241
|
+
if memory_id in seen:
|
|
242
|
+
warnings.add(f"live item {index}: duplicate id; skipped")
|
|
243
|
+
continue
|
|
244
|
+
seen.add(memory_id)
|
|
245
|
+
item_scope = {k: item[k] for k in SCOPE_KEYS if _clean_id(item.get(k))}
|
|
246
|
+
try:
|
|
247
|
+
memories.append(Memory(id=memory_id, text=bound_text(text), scope={**clean, **item_scope}))
|
|
248
|
+
except ValidationError:
|
|
249
|
+
warnings.add(f"live item {index}: failed validation; skipped")
|
|
250
|
+
complete = False
|
|
251
|
+
return LiveMemories(memories=memories, complete=complete, warnings=warnings.as_list())
|
|
252
|
+
|
|
253
|
+
def history(self, memory_id: str) -> list[MemoryEvent]:
|
|
254
|
+
if _clean_id(memory_id) is None:
|
|
255
|
+
raise AdapterError(f"memory_id must be a non-empty string of at most {MAX_ID_CHARS} characters")
|
|
256
|
+
try:
|
|
257
|
+
rows = self._memory.history(memory_id)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
raise AdapterError(f"mem0 history failed: {type(exc).__name__}: {safe_text(exc, 200)}") from exc
|
|
260
|
+
if not isinstance(rows, list):
|
|
261
|
+
raise AdapterError("mem0 history returned an unexpected shape")
|
|
262
|
+
warnings = _Warnings()
|
|
263
|
+
now = self._clock()
|
|
264
|
+
pairs: list[tuple[MemoryEvent, int]] = []
|
|
265
|
+
for index, row in enumerate(rows[: self._max_rows]):
|
|
266
|
+
if not isinstance(row, dict):
|
|
267
|
+
warnings.add(f"history item {index}: not a record; skipped")
|
|
268
|
+
continue
|
|
269
|
+
event, _ = self._event_from_row(row, f"history item {index}", f"api:{index}", now, warnings)
|
|
270
|
+
if event is not None:
|
|
271
|
+
pairs.append((event, index))
|
|
272
|
+
for message in warnings.as_list():
|
|
273
|
+
logger.warning("%s", message)
|
|
274
|
+
pairs.sort(key=lambda pair: (pair[0].ts, pair[1]))
|
|
275
|
+
return [event for event, _ in pairs]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# -- starting mem0 -------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def ensure_mem0_telemetry_off() -> list[str]:
|
|
282
|
+
"""Mem0 sends anonymous usage events by default. Turn that off before it is imported."""
|
|
283
|
+
os.environ.setdefault("MEM0_TELEMETRY", "false")
|
|
284
|
+
notes: list[str] = []
|
|
285
|
+
if os.environ["MEM0_TELEMETRY"].strip().lower() in ("true", "1", "yes"):
|
|
286
|
+
notes.append("MEM0_TELEMETRY is on in your environment; mem0 will send anonymous usage reports")
|
|
287
|
+
else:
|
|
288
|
+
module = sys.modules.get("mem0.memory.telemetry")
|
|
289
|
+
if module is not None and getattr(module, "MEM0_TELEMETRY", False):
|
|
290
|
+
notes.append(
|
|
291
|
+
"mem0 was already imported with telemetry on; start a fresh process with "
|
|
292
|
+
"MEM0_TELEMETRY=false to stop its anonymous usage reports"
|
|
293
|
+
)
|
|
294
|
+
return notes
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _load_json_config(path: Path) -> dict[str, Any]:
|
|
298
|
+
resolved = _require_regular_file(path)
|
|
299
|
+
if resolved.stat().st_size > _MAX_CONFIG_BYTES:
|
|
300
|
+
raise AdapterError("mem0 config file is too large")
|
|
301
|
+
try:
|
|
302
|
+
config = json.loads(resolved.read_text(encoding="utf-8"))
|
|
303
|
+
except (OSError, ValueError) as exc:
|
|
304
|
+
raise AdapterError("mem0 config file is not valid JSON") from exc
|
|
305
|
+
if not isinstance(config, dict):
|
|
306
|
+
raise AdapterError("mem0 config must be a JSON object")
|
|
307
|
+
return config
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def build_mem0_memory(config_path: str | Path | None = None) -> tuple[Any, list[str]]:
|
|
311
|
+
"""Create a mem0 Memory. Errors never echo the config, which may hold API keys."""
|
|
312
|
+
notes = ensure_mem0_telemetry_off()
|
|
313
|
+
try:
|
|
314
|
+
from mem0 import Memory
|
|
315
|
+
except ImportError as exc:
|
|
316
|
+
raise AdapterError("mem0ai is not installed; run: pip install 'mem0ai>=2'") from exc
|
|
317
|
+
try:
|
|
318
|
+
if config_path is None:
|
|
319
|
+
return Memory(), notes
|
|
320
|
+
return Memory.from_config(_load_json_config(Path(config_path))), notes
|
|
321
|
+
except MemdebugError:
|
|
322
|
+
raise
|
|
323
|
+
except Exception as exc:
|
|
324
|
+
raise AdapterError(f"could not start mem0: {type(exc).__name__}") from exc
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Open WebUI's memory feature, read from a COPY of its database (webui.db).
|
|
2
|
+
|
|
3
|
+
Open WebUI keeps what it remembers about a user in a `memory` table and records no change history, so memdebug records
|
|
4
|
+
what it sees between two looks. This adapter only reads: the file is opened read-only (SQLite's read-only mode plus
|
|
5
|
+
`query_only`), only the `memory` and `user` tables are touched, and every row is validated. It never reads chats,
|
|
6
|
+
passwords, API keys or settings. Work on a copy of the database, not the live file inside the container.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import bisect
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
from contextlib import closing
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from ..errors import AdapterError
|
|
20
|
+
from ..models import Memory, MemoryEvent, Source, SourceKind
|
|
21
|
+
from ..textsafe import bound_text, safe_text
|
|
22
|
+
from .base import HistoryRead, LiveMemories
|
|
23
|
+
from .common import Warnings, clean_id, decode, require_regular_file
|
|
24
|
+
|
|
25
|
+
MAX_ID_CHARS = 256
|
|
26
|
+
_REQUIRED = {"id", "user_id", "content"}
|
|
27
|
+
_PLAIN = re.compile(r"^[A-Za-z0-9_.:/ -]{1,40}\Z")
|
|
28
|
+
WINDOW_SECONDS = 120 # a chat message this close to a memory's last change counts as "around the same time"
|
|
29
|
+
MAX_CHAT_ROWS = 200_000 # chat records compared with; more than this and the comparison is skipped
|
|
30
|
+
_OPTIONAL = ("type", "meta", "created_at", "updated_at")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _plain(value: object) -> str | None:
|
|
34
|
+
"""A short label made of plain characters, or just its size if it is anything else. Never free text."""
|
|
35
|
+
if value is None or value == "":
|
|
36
|
+
return None
|
|
37
|
+
text = str(value)
|
|
38
|
+
return text if _PLAIN.match(text) else f"(text, {len(text)} chars)"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _epoch(value: object) -> float | None:
|
|
42
|
+
"""Seconds since 1970, from seconds or milliseconds; anything else is not trusted as a time."""
|
|
43
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
44
|
+
return None
|
|
45
|
+
for low, high, divisor in ((1e9, 1e11, 1), (1e12, 1e14, 1000)):
|
|
46
|
+
if low <= value < high:
|
|
47
|
+
return value / divisor
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OpenWebUIAdapter:
|
|
52
|
+
name = "openwebui"
|
|
53
|
+
capabilities: set[str] = set() # no history to read
|
|
54
|
+
|
|
55
|
+
def __init__(self, db_path: str | Path, *, user_id: str | None = None, max_rows: int = 50_000,
|
|
56
|
+
clock: Callable[[], datetime] | None = None):
|
|
57
|
+
self._path = Path(db_path)
|
|
58
|
+
self._max_rows = max_rows
|
|
59
|
+
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
60
|
+
if not (isinstance(max_rows, int) and max_rows > 0):
|
|
61
|
+
raise AdapterError("max_rows must be positive")
|
|
62
|
+
wanted = None if user_id is None else clean_id(user_id)
|
|
63
|
+
if user_id is not None and wanted is None:
|
|
64
|
+
raise AdapterError("the user id must be a non-empty string of reasonable length")
|
|
65
|
+
with closing(self._connect()) as db:
|
|
66
|
+
columns = self._columns(db, "memory")
|
|
67
|
+
if not _REQUIRED <= columns:
|
|
68
|
+
raise AdapterError("this does not look like Open WebUI's database: the memory table lacks the expected columns")
|
|
69
|
+
self._user = wanted or self._only_user(db)
|
|
70
|
+
|
|
71
|
+
# -- reading ---------------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _connect(self) -> sqlite3.Connection:
|
|
74
|
+
path = require_regular_file(self._path)
|
|
75
|
+
try:
|
|
76
|
+
conn = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True, timeout=5)
|
|
77
|
+
conn.text_factory = decode
|
|
78
|
+
conn.execute("PRAGMA query_only = ON")
|
|
79
|
+
return conn
|
|
80
|
+
except sqlite3.Error as exc:
|
|
81
|
+
raise AdapterError(f"cannot open the Open WebUI database read-only: {safe_text(exc, 100)}") from exc
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _columns(db: sqlite3.Connection, table: str) -> set[str]:
|
|
85
|
+
try:
|
|
86
|
+
return {str(row[1]) for row in db.execute(f'PRAGMA table_info("{table}")')}
|
|
87
|
+
except sqlite3.Error as exc:
|
|
88
|
+
raise AdapterError(f"cannot read the database structure: {safe_text(exc, 100)}") from exc
|
|
89
|
+
|
|
90
|
+
def _only_user(self, db: sqlite3.Connection) -> str:
|
|
91
|
+
"""The user whose memories to read, when none was named: the single user of this installation."""
|
|
92
|
+
try:
|
|
93
|
+
owners = {clean_id(row[0]) for row in db.execute('SELECT DISTINCT "user_id" FROM "memory" LIMIT 3')}
|
|
94
|
+
owners.discard(None)
|
|
95
|
+
if len(owners) == 1:
|
|
96
|
+
return str(next(iter(owners)))
|
|
97
|
+
if len(owners) > 1:
|
|
98
|
+
raise AdapterError("memories of several users are stored here; say which one with --user-id")
|
|
99
|
+
if "user" in {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")} and "id" in self._columns(db, "user"):
|
|
100
|
+
users = {clean_id(row[0]) for row in db.execute('SELECT "id" FROM "user" LIMIT 3')}
|
|
101
|
+
users.discard(None)
|
|
102
|
+
if len(users) == 1:
|
|
103
|
+
return str(next(iter(users)))
|
|
104
|
+
except sqlite3.Error as exc:
|
|
105
|
+
raise AdapterError(f"cannot read the Open WebUI database: {safe_text(exc, 100)}") from exc
|
|
106
|
+
raise AdapterError("cannot tell whose memories to read; say which user with --user-id")
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def user(self) -> str:
|
|
110
|
+
return self._user
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def scope(self) -> dict[str, str]:
|
|
114
|
+
return {"user_id": self._user}
|
|
115
|
+
|
|
116
|
+
def list_memories(self, scope: dict[str, str]) -> LiveMemories:
|
|
117
|
+
if scope != self.scope:
|
|
118
|
+
raise AdapterError(f"scope must be {{'user_id': {safe_text(self._user, 40)!r}}} for this database")
|
|
119
|
+
warnings = Warnings()
|
|
120
|
+
memories: list[Memory] = []
|
|
121
|
+
complete = True
|
|
122
|
+
with closing(self._connect()) as db:
|
|
123
|
+
have = self._columns(db, "memory")
|
|
124
|
+
extra = [name for name in _OPTIONAL if name in have]
|
|
125
|
+
order = '"created_at", "id"' if "created_at" in have else '"id"'
|
|
126
|
+
try:
|
|
127
|
+
rows = db.execute(f'SELECT "id", "content"{"".join(f", {chr(34)}{n}{chr(34)}" for n in extra)} FROM "memory" '
|
|
128
|
+
f'WHERE "user_id" = ? ORDER BY {order} LIMIT ?', (self._user, self._max_rows + 1)).fetchall()
|
|
129
|
+
except sqlite3.Error as exc:
|
|
130
|
+
raise AdapterError(f"cannot read the memories: {safe_text(exc, 100)}") from exc
|
|
131
|
+
chat = self._chat_times(db)
|
|
132
|
+
if len(rows) > self._max_rows:
|
|
133
|
+
rows = rows[:self._max_rows]
|
|
134
|
+
complete = False
|
|
135
|
+
warnings.add(f"more than {self._max_rows} memories; the rest were not read")
|
|
136
|
+
for raw_id, content, *details in rows:
|
|
137
|
+
memory_id = clean_id(raw_id)
|
|
138
|
+
if memory_id is None or not isinstance(content, str):
|
|
139
|
+
complete = False
|
|
140
|
+
warnings.add("a memory row without a usable id or text was skipped")
|
|
141
|
+
continue
|
|
142
|
+
facts = dict(zip(extra, details, strict=True))
|
|
143
|
+
memories.append(Memory(id=memory_id, text=bound_text(content), scope=dict(scope), source=self._source(facts, chat)))
|
|
144
|
+
return LiveMemories(memories=memories, complete=complete, warnings=warnings.as_list())
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _chat_times(db: sqlite3.Connection) -> list[tuple[float, str]] | None:
|
|
148
|
+
"""When each chat message happened and its role, nothing else, or None if that cannot be compared. The message text is never read."""
|
|
149
|
+
try:
|
|
150
|
+
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
151
|
+
if "chat_message" not in tables or not {"role", "created_at"} <= OpenWebUIAdapter._columns(db, "chat_message"):
|
|
152
|
+
return None
|
|
153
|
+
rows = db.execute('SELECT "created_at", "role" FROM "chat_message" WHERE "created_at" IS NOT NULL '
|
|
154
|
+
'ORDER BY "created_at" LIMIT ?', (MAX_CHAT_ROWS + 1,)).fetchall()
|
|
155
|
+
except sqlite3.Error:
|
|
156
|
+
return None
|
|
157
|
+
if len(rows) > MAX_CHAT_ROWS:
|
|
158
|
+
return None
|
|
159
|
+
times = [(t, _plain(role) or "unknown") for raw, role in rows if (t := _epoch(raw)) is not None]
|
|
160
|
+
return sorted(times)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _source(facts: dict, chat: list[tuple[float, str]] | None) -> Source | None:
|
|
164
|
+
"""What Open WebUI itself records about a memory, and how close a chat was to it. A description, never a verdict."""
|
|
165
|
+
meta = facts.get("meta")
|
|
166
|
+
data = None
|
|
167
|
+
if isinstance(meta, str) and len(meta) <= 20_000:
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(meta)
|
|
170
|
+
except ValueError:
|
|
171
|
+
data = None
|
|
172
|
+
label = _plain(data.get("created_by")) if isinstance(data, dict) else None
|
|
173
|
+
kind = _plain(facts.get("type"))
|
|
174
|
+
touched = _epoch(facts.get("updated_at")) or _epoch(facts.get("created_at"))
|
|
175
|
+
if label is None and kind is None and touched is None:
|
|
176
|
+
return None
|
|
177
|
+
parts: list[str] = []
|
|
178
|
+
near: list[tuple[float, str]] = []
|
|
179
|
+
if touched is not None:
|
|
180
|
+
parts.append("last touched " + datetime.fromtimestamp(touched, timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
|
|
181
|
+
if chat is None:
|
|
182
|
+
parts.append("chat records could not be compared")
|
|
183
|
+
elif not chat:
|
|
184
|
+
parts.append("there are no chat messages to compare with")
|
|
185
|
+
else:
|
|
186
|
+
low = bisect.bisect_left(chat, (touched - WINDOW_SECONDS, ""))
|
|
187
|
+
high = bisect.bisect_right(chat, (touched + WINDOW_SECONDS, "\uffff"))
|
|
188
|
+
near = chat[low:high]
|
|
189
|
+
position = bisect.bisect_left(chat, (touched, ""))
|
|
190
|
+
gap = min(abs(chat[i][0] - touched) for i in (position - 1, position) if 0 <= i < len(chat))
|
|
191
|
+
if near:
|
|
192
|
+
parts.append(f"{len(near)} chat message{'' if len(near) == 1 else 's'} within {WINDOW_SECONDS} s (nearest {gap:.0f} s away)")
|
|
193
|
+
else:
|
|
194
|
+
parts.append(f"no chat message within {WINDOW_SECONDS} s (nearest is {gap:.0f} s away)")
|
|
195
|
+
if label == "manual" and not near and touched is not None and chat:
|
|
196
|
+
parts.append("consistent with being added by hand in the settings")
|
|
197
|
+
elif label == "manual" and near:
|
|
198
|
+
parts.append("labelled manual, but a chat was active at the time")
|
|
199
|
+
elif label != "manual" and near:
|
|
200
|
+
parts.append(f"{'not labelled manual' if label is None else 'labelled ' + label}, and a chat was active at the time: "
|
|
201
|
+
"check what the assistant had just read")
|
|
202
|
+
return Source(kind=SourceKind.UNKNOWN, actor_id=f"created_by={label}" if label else None,
|
|
203
|
+
role=f"type={kind}" if kind else None, note="; ".join(parts)[:390] or None)
|
|
204
|
+
|
|
205
|
+
def read_history(self, max_rows: int) -> HistoryRead:
|
|
206
|
+
return HistoryRead(events=[], refs=set(), truncated=False)
|
|
207
|
+
|
|
208
|
+
def history(self, memory_id: str) -> list[MemoryEvent]:
|
|
209
|
+
return []
|