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.
@@ -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 []