dlightrag-memory 2.0.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.
- dlightrag_memory/__init__.py +37 -0
- dlightrag_memory/_storage/__init__.py +0 -0
- dlightrag_memory/_storage/pg.py +1311 -0
- dlightrag_memory/_storage/pg_bm25.py +186 -0
- dlightrag_memory/errors.py +25 -0
- dlightrag_memory/fusion.py +28 -0
- dlightrag_memory/mcp_server.py +241 -0
- dlightrag_memory/memory.py +260 -0
- dlightrag_memory/models.py +95 -0
- dlightrag_memory/normalize.py +24 -0
- dlightrag_memory/policy.py +82 -0
- dlightrag_memory/ports.py +79 -0
- dlightrag_memory/postgres.py +13 -0
- dlightrag_memory/py.typed +0 -0
- dlightrag_memory/recall.py +25 -0
- dlightrag_memory/store.py +676 -0
- dlightrag_memory-2.0.0.dist-info/METADATA +12 -0
- dlightrag_memory-2.0.0.dist-info/RECORD +22 -0
- dlightrag_memory-2.0.0.dist-info/WHEEL +4 -0
- dlightrag_memory-2.0.0.dist-info/entry_points.txt +2 -0
- dlightrag_memory-2.0.0.dist-info/licenses/LICENSE +199 -0
- dlightrag_memory-2.0.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
# Copyright 2025-2026 Hanlian Lu. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Storage-neutral Profile Memory persistence and atomic operation settlement."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
from uuid import NAMESPACE_URL, uuid5
|
|
14
|
+
|
|
15
|
+
from dlightrag_memory.errors import MemoryWriteRejectedError
|
|
16
|
+
from dlightrag_memory.models import (
|
|
17
|
+
MemoryOperation,
|
|
18
|
+
MemoryOperationReceipt,
|
|
19
|
+
MemoryRecord,
|
|
20
|
+
)
|
|
21
|
+
from dlightrag_memory.normalize import normalized_body
|
|
22
|
+
from dlightrag_memory.policy import MEMORY_SUPERSEDE_RETENTION_DAYS
|
|
23
|
+
from dlightrag_memory.ports import SearchCandidate
|
|
24
|
+
from dlightrag_memory.recall import recall_recency
|
|
25
|
+
|
|
26
|
+
# The adapter passes its opaque transaction context (or ``None``) so a host can
|
|
27
|
+
# validate an external capability in the same atomic settlement without the
|
|
28
|
+
# package importing or knowing that capability's schema.
|
|
29
|
+
OperationGuard = Callable[[object | None], Awaitable[None]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryStore(Protocol):
|
|
33
|
+
"""Deep storage seam: atomic mutations plus owner-scoped recall reads."""
|
|
34
|
+
|
|
35
|
+
async def apply_operation(
|
|
36
|
+
self,
|
|
37
|
+
operation: MemoryOperation,
|
|
38
|
+
*,
|
|
39
|
+
guard: OperationGuard | None = None,
|
|
40
|
+
) -> MemoryOperationReceipt: ...
|
|
41
|
+
|
|
42
|
+
async def clear_owner(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
owner_id: str,
|
|
46
|
+
guard: OperationGuard | None = None,
|
|
47
|
+
) -> int: ...
|
|
48
|
+
|
|
49
|
+
async def count_active(self, *, owner_id: str) -> int: ...
|
|
50
|
+
|
|
51
|
+
async def get(self, *, owner_id: str, memory_id: str) -> MemoryRecord | None: ...
|
|
52
|
+
|
|
53
|
+
async def search_candidates(
|
|
54
|
+
self, *, owner_id: str, query: str, limit: int
|
|
55
|
+
) -> tuple[SearchCandidate, ...]: ...
|
|
56
|
+
|
|
57
|
+
async def list_active_page(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
owner_id: str,
|
|
61
|
+
after: tuple[datetime, str] | None = None,
|
|
62
|
+
limit: int = 50,
|
|
63
|
+
) -> tuple[tuple[MemoryRecord, ...], tuple[datetime, str] | None]: ...
|
|
64
|
+
|
|
65
|
+
async def purge_superseded(self, *, older_than: datetime) -> int: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class _StoredOperation:
|
|
70
|
+
fingerprint: str
|
|
71
|
+
receipt: MemoryOperationReceipt
|
|
72
|
+
before_records: tuple[MemoryRecord, ...] = ()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InMemoryMemoryStore:
|
|
76
|
+
"""Process-local adapter with the durable adapter's exact operation semantics."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self._rows: dict[tuple[str, str], MemoryRecord] = {}
|
|
80
|
+
self._operations: dict[tuple[str, str], _StoredOperation] = {}
|
|
81
|
+
self._undone_by: dict[tuple[str, str], str] = {}
|
|
82
|
+
self._lock = asyncio.Lock()
|
|
83
|
+
|
|
84
|
+
async def apply_operation(
|
|
85
|
+
self,
|
|
86
|
+
operation: MemoryOperation,
|
|
87
|
+
*,
|
|
88
|
+
guard: OperationGuard | None = None,
|
|
89
|
+
) -> MemoryOperationReceipt:
|
|
90
|
+
change_id = operation_change_id(operation)
|
|
91
|
+
fingerprint = operation_fingerprint(operation)
|
|
92
|
+
key = (operation.owner_id, change_id)
|
|
93
|
+
async with self._lock:
|
|
94
|
+
if guard is not None:
|
|
95
|
+
await guard(None)
|
|
96
|
+
replay = self._operations.get(key)
|
|
97
|
+
if replay is not None:
|
|
98
|
+
if replay.fingerprint != fingerprint:
|
|
99
|
+
raise MemoryWriteRejectedError(
|
|
100
|
+
"Memory idempotency key was reused with different input."
|
|
101
|
+
)
|
|
102
|
+
return replay.receipt
|
|
103
|
+
|
|
104
|
+
candidate = dict(self._rows)
|
|
105
|
+
receipt, before_records, undone_target = self._apply_to_rows(
|
|
106
|
+
candidate, operation=operation, change_id=change_id
|
|
107
|
+
)
|
|
108
|
+
if receipt.changed and operation.mutation_scope is not None:
|
|
109
|
+
used = sum(
|
|
110
|
+
1
|
|
111
|
+
for (owner_id, _), stored in self._operations.items()
|
|
112
|
+
if owner_id == operation.owner_id
|
|
113
|
+
and stored.receipt.outcome == "changed"
|
|
114
|
+
and _receipt_scope(stored.receipt) == operation.mutation_scope
|
|
115
|
+
)
|
|
116
|
+
if used >= int(operation.mutation_limit or 0):
|
|
117
|
+
raise MemoryWriteRejectedError(
|
|
118
|
+
"This Answer Run reached its Memory mutation limit."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self._rows = candidate
|
|
122
|
+
self._operations[key] = _StoredOperation(
|
|
123
|
+
fingerprint=fingerprint,
|
|
124
|
+
receipt=receipt,
|
|
125
|
+
before_records=before_records,
|
|
126
|
+
)
|
|
127
|
+
if undone_target is not None and receipt.changed:
|
|
128
|
+
self._undone_by[(operation.owner_id, undone_target)] = change_id
|
|
129
|
+
return receipt
|
|
130
|
+
|
|
131
|
+
def _apply_to_rows(
|
|
132
|
+
self,
|
|
133
|
+
rows: dict[tuple[str, str], MemoryRecord],
|
|
134
|
+
*,
|
|
135
|
+
operation: MemoryOperation,
|
|
136
|
+
change_id: str,
|
|
137
|
+
) -> tuple[MemoryOperationReceipt, tuple[MemoryRecord, ...], str | None]:
|
|
138
|
+
now = datetime.now(UTC)
|
|
139
|
+
if operation.action == "remember":
|
|
140
|
+
return self._remember_rows(rows, operation=operation, change_id=change_id, now=now)
|
|
141
|
+
if operation.action == "forget":
|
|
142
|
+
return self._forget_rows(rows, operation=operation, change_id=change_id, now=now)
|
|
143
|
+
return self._undo_rows(rows, operation=operation, change_id=change_id, now=now)
|
|
144
|
+
|
|
145
|
+
def _remember_rows(
|
|
146
|
+
self,
|
|
147
|
+
rows: dict[tuple[str, str], MemoryRecord],
|
|
148
|
+
*,
|
|
149
|
+
operation: MemoryOperation,
|
|
150
|
+
change_id: str,
|
|
151
|
+
now: datetime,
|
|
152
|
+
) -> tuple[MemoryOperationReceipt, tuple[MemoryRecord, ...], None]:
|
|
153
|
+
body = operation.body.strip()
|
|
154
|
+
duplicate = next(
|
|
155
|
+
(
|
|
156
|
+
record
|
|
157
|
+
for record in rows.values()
|
|
158
|
+
if record.owner_id == operation.owner_id
|
|
159
|
+
and record.status == "active"
|
|
160
|
+
and normalized_body(record.body) == normalized_body(body)
|
|
161
|
+
),
|
|
162
|
+
None,
|
|
163
|
+
)
|
|
164
|
+
if duplicate is not None:
|
|
165
|
+
if operation.supersedes_id and duplicate.memory_id != operation.supersedes_id:
|
|
166
|
+
return (
|
|
167
|
+
operation_receipt(
|
|
168
|
+
operation,
|
|
169
|
+
change_id,
|
|
170
|
+
"conflict",
|
|
171
|
+
memory_ids=(duplicate.memory_id,),
|
|
172
|
+
kind=duplicate.kind,
|
|
173
|
+
body=duplicate.body,
|
|
174
|
+
now=now,
|
|
175
|
+
),
|
|
176
|
+
(),
|
|
177
|
+
None,
|
|
178
|
+
)
|
|
179
|
+
return (
|
|
180
|
+
operation_receipt(
|
|
181
|
+
operation,
|
|
182
|
+
change_id,
|
|
183
|
+
"unchanged",
|
|
184
|
+
memory_ids=(duplicate.memory_id,),
|
|
185
|
+
kind=duplicate.kind,
|
|
186
|
+
body=duplicate.body,
|
|
187
|
+
now=now,
|
|
188
|
+
),
|
|
189
|
+
(),
|
|
190
|
+
None,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
before: tuple[MemoryRecord, ...] = ()
|
|
194
|
+
if operation.supersedes_id:
|
|
195
|
+
old_key = (operation.owner_id, operation.supersedes_id)
|
|
196
|
+
old = rows.get(old_key)
|
|
197
|
+
if old is None or old.status != "active":
|
|
198
|
+
return (
|
|
199
|
+
operation_receipt(operation, change_id, "conflict", body=body, now=now),
|
|
200
|
+
(),
|
|
201
|
+
None,
|
|
202
|
+
)
|
|
203
|
+
before = (old,)
|
|
204
|
+
rows[old_key] = replace(old, status="superseded", updated_at=now)
|
|
205
|
+
|
|
206
|
+
memory_id = operation_record_id(operation.owner_id, change_id)
|
|
207
|
+
record = MemoryRecord(
|
|
208
|
+
owner_id=operation.owner_id,
|
|
209
|
+
memory_id=memory_id,
|
|
210
|
+
kind=operation.kind or "fact",
|
|
211
|
+
body=body,
|
|
212
|
+
provenance=operation.provenance,
|
|
213
|
+
status="active",
|
|
214
|
+
supersedes_id=operation.supersedes_id,
|
|
215
|
+
created_at=now,
|
|
216
|
+
updated_at=now,
|
|
217
|
+
)
|
|
218
|
+
rows[(operation.owner_id, memory_id)] = record
|
|
219
|
+
return (
|
|
220
|
+
operation_receipt(
|
|
221
|
+
operation,
|
|
222
|
+
change_id,
|
|
223
|
+
"changed",
|
|
224
|
+
memory_ids=(memory_id,),
|
|
225
|
+
kind=record.kind,
|
|
226
|
+
body=body,
|
|
227
|
+
now=now,
|
|
228
|
+
),
|
|
229
|
+
before,
|
|
230
|
+
None,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _forget_rows(
|
|
234
|
+
self,
|
|
235
|
+
rows: dict[tuple[str, str], MemoryRecord],
|
|
236
|
+
*,
|
|
237
|
+
operation: MemoryOperation,
|
|
238
|
+
change_id: str,
|
|
239
|
+
now: datetime,
|
|
240
|
+
) -> tuple[MemoryOperationReceipt, tuple[MemoryRecord, ...], None]:
|
|
241
|
+
if operation.memory_id:
|
|
242
|
+
row = rows.get((operation.owner_id, operation.memory_id))
|
|
243
|
+
matches = [row] if row is not None and row.status == "active" else []
|
|
244
|
+
else:
|
|
245
|
+
target = normalized_body(operation.body)
|
|
246
|
+
matches = [
|
|
247
|
+
record
|
|
248
|
+
for record in rows.values()
|
|
249
|
+
if record.owner_id == operation.owner_id
|
|
250
|
+
and record.status == "active"
|
|
251
|
+
and normalized_body(record.body) == target
|
|
252
|
+
]
|
|
253
|
+
if not matches:
|
|
254
|
+
return (operation_receipt(operation, change_id, "unchanged", now=now), (), None)
|
|
255
|
+
for record in matches:
|
|
256
|
+
rows[(record.owner_id, record.memory_id)] = replace(
|
|
257
|
+
record, status="forgotten", updated_at=now
|
|
258
|
+
)
|
|
259
|
+
first = matches[0]
|
|
260
|
+
return (
|
|
261
|
+
operation_receipt(
|
|
262
|
+
operation,
|
|
263
|
+
change_id,
|
|
264
|
+
"changed",
|
|
265
|
+
memory_ids=tuple(record.memory_id for record in matches),
|
|
266
|
+
kind=first.kind,
|
|
267
|
+
body=first.body,
|
|
268
|
+
now=now,
|
|
269
|
+
),
|
|
270
|
+
tuple(matches),
|
|
271
|
+
None,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _undo_rows(
|
|
275
|
+
self,
|
|
276
|
+
rows: dict[tuple[str, str], MemoryRecord],
|
|
277
|
+
*,
|
|
278
|
+
operation: MemoryOperation,
|
|
279
|
+
change_id: str,
|
|
280
|
+
now: datetime,
|
|
281
|
+
) -> tuple[MemoryOperationReceipt, tuple[MemoryRecord, ...], str | None]:
|
|
282
|
+
target_id = operation.target_change_id or ""
|
|
283
|
+
target = self._operations.get((operation.owner_id, target_id))
|
|
284
|
+
if (
|
|
285
|
+
target is None
|
|
286
|
+
or not target.receipt.changed
|
|
287
|
+
or target.receipt.action == "undo"
|
|
288
|
+
or (operation.owner_id, target_id) in self._undone_by
|
|
289
|
+
):
|
|
290
|
+
return (
|
|
291
|
+
operation_receipt(
|
|
292
|
+
operation,
|
|
293
|
+
change_id,
|
|
294
|
+
"conflict",
|
|
295
|
+
target_change_id=target_id,
|
|
296
|
+
now=now,
|
|
297
|
+
),
|
|
298
|
+
(),
|
|
299
|
+
None,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
target_receipt = target.receipt
|
|
303
|
+
if target_receipt.action == "remember":
|
|
304
|
+
current_id = target_receipt.memory_id or ""
|
|
305
|
+
current = rows.get((operation.owner_id, current_id))
|
|
306
|
+
if current is None or current.status != "active":
|
|
307
|
+
return (
|
|
308
|
+
operation_receipt(
|
|
309
|
+
operation,
|
|
310
|
+
change_id,
|
|
311
|
+
"conflict",
|
|
312
|
+
target_change_id=target_id,
|
|
313
|
+
now=now,
|
|
314
|
+
),
|
|
315
|
+
(),
|
|
316
|
+
None,
|
|
317
|
+
)
|
|
318
|
+
if target_receipt.supersedes_id and target.before_records:
|
|
319
|
+
old = target.before_records[0]
|
|
320
|
+
rows[(operation.owner_id, current_id)] = replace(
|
|
321
|
+
current, status="superseded", updated_at=now
|
|
322
|
+
)
|
|
323
|
+
restored_id = operation_record_id(operation.owner_id, change_id)
|
|
324
|
+
restored_record = replace(
|
|
325
|
+
old,
|
|
326
|
+
memory_id=restored_id,
|
|
327
|
+
provenance=operation.provenance,
|
|
328
|
+
status="active",
|
|
329
|
+
supersedes_id=current_id,
|
|
330
|
+
created_at=now,
|
|
331
|
+
updated_at=now,
|
|
332
|
+
)
|
|
333
|
+
rows[(operation.owner_id, restored_id)] = restored_record
|
|
334
|
+
return (
|
|
335
|
+
operation_receipt(
|
|
336
|
+
operation,
|
|
337
|
+
change_id,
|
|
338
|
+
"changed",
|
|
339
|
+
memory_ids=(restored_id,),
|
|
340
|
+
kind=restored_record.kind,
|
|
341
|
+
body=restored_record.body,
|
|
342
|
+
supersedes_id=current_id,
|
|
343
|
+
target_change_id=target_id,
|
|
344
|
+
now=now,
|
|
345
|
+
),
|
|
346
|
+
(current,),
|
|
347
|
+
target_id,
|
|
348
|
+
)
|
|
349
|
+
rows[(operation.owner_id, current_id)] = replace(
|
|
350
|
+
current, status="forgotten", updated_at=now
|
|
351
|
+
)
|
|
352
|
+
return (
|
|
353
|
+
operation_receipt(
|
|
354
|
+
operation,
|
|
355
|
+
change_id,
|
|
356
|
+
"changed",
|
|
357
|
+
memory_ids=(current_id,),
|
|
358
|
+
kind=current.kind,
|
|
359
|
+
body=current.body,
|
|
360
|
+
target_change_id=target_id,
|
|
361
|
+
now=now,
|
|
362
|
+
),
|
|
363
|
+
(current,),
|
|
364
|
+
target_id,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
# Preflight the whole target batch before restoring anything: the
|
|
368
|
+
# persisted journal must be well formed (nonempty, every before record
|
|
369
|
+
# belongs to this owner, memory ids are unique and, in order, exactly
|
|
370
|
+
# the target receipt's memory_ids), every target id must still exist
|
|
371
|
+
# for this owner as a forgotten row, and no currently active record
|
|
372
|
+
# outside the batch may share a normalized body. Sibling rows inside
|
|
373
|
+
# the batch may carry duplicate normalized bodies (the batch
|
|
374
|
+
# compensates an exact prior forget), so they are never conflicts.
|
|
375
|
+
if (
|
|
376
|
+
not target.before_records
|
|
377
|
+
or any(old.owner_id != operation.owner_id for old in target.before_records)
|
|
378
|
+
or len({old.memory_id for old in target.before_records}) != len(target.before_records)
|
|
379
|
+
or tuple(old.memory_id for old in target.before_records) != target_receipt.memory_ids
|
|
380
|
+
):
|
|
381
|
+
return (
|
|
382
|
+
operation_receipt(
|
|
383
|
+
operation,
|
|
384
|
+
change_id,
|
|
385
|
+
"conflict",
|
|
386
|
+
target_change_id=target_id,
|
|
387
|
+
now=now,
|
|
388
|
+
),
|
|
389
|
+
(),
|
|
390
|
+
None,
|
|
391
|
+
)
|
|
392
|
+
restored_ids: list[str] = []
|
|
393
|
+
restored_records: list[MemoryRecord] = []
|
|
394
|
+
target_bodies: set[str] = set()
|
|
395
|
+
for index, old in enumerate(target.before_records):
|
|
396
|
+
current = rows.get((operation.owner_id, old.memory_id))
|
|
397
|
+
if current is None or current.status != "forgotten":
|
|
398
|
+
return (
|
|
399
|
+
operation_receipt(
|
|
400
|
+
operation,
|
|
401
|
+
change_id,
|
|
402
|
+
"conflict",
|
|
403
|
+
target_change_id=target_id,
|
|
404
|
+
now=now,
|
|
405
|
+
),
|
|
406
|
+
(),
|
|
407
|
+
None,
|
|
408
|
+
)
|
|
409
|
+
restored_ids.append(operation_record_id(operation.owner_id, change_id, index=index))
|
|
410
|
+
restored_records.append(
|
|
411
|
+
replace(
|
|
412
|
+
old,
|
|
413
|
+
memory_id=restored_ids[-1],
|
|
414
|
+
provenance=operation.provenance,
|
|
415
|
+
status="active",
|
|
416
|
+
supersedes_id=old.memory_id,
|
|
417
|
+
created_at=now,
|
|
418
|
+
updated_at=now,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
target_bodies.add(normalized_body(old.body))
|
|
422
|
+
if any(
|
|
423
|
+
record.owner_id == operation.owner_id
|
|
424
|
+
and record.status == "active"
|
|
425
|
+
and normalized_body(record.body) in target_bodies
|
|
426
|
+
for record in rows.values()
|
|
427
|
+
):
|
|
428
|
+
return (
|
|
429
|
+
operation_receipt(
|
|
430
|
+
operation,
|
|
431
|
+
change_id,
|
|
432
|
+
"conflict",
|
|
433
|
+
target_change_id=target_id,
|
|
434
|
+
now=now,
|
|
435
|
+
),
|
|
436
|
+
(),
|
|
437
|
+
None,
|
|
438
|
+
)
|
|
439
|
+
for index, record in enumerate(restored_records):
|
|
440
|
+
key = (operation.owner_id, restored_ids[index])
|
|
441
|
+
existing = rows.get(key)
|
|
442
|
+
if existing is not None:
|
|
443
|
+
raise ValueError("memory id already exists with different content")
|
|
444
|
+
rows[key] = record
|
|
445
|
+
first = restored_records[0] if restored_records else None
|
|
446
|
+
return (
|
|
447
|
+
operation_receipt(
|
|
448
|
+
operation,
|
|
449
|
+
change_id,
|
|
450
|
+
"changed",
|
|
451
|
+
memory_ids=tuple(record.memory_id for record in restored_records),
|
|
452
|
+
kind=None if first is None else first.kind,
|
|
453
|
+
body="" if first is None else first.body,
|
|
454
|
+
target_change_id=target_id,
|
|
455
|
+
now=now,
|
|
456
|
+
),
|
|
457
|
+
target.before_records,
|
|
458
|
+
target_id,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# Raw row methods remain adapter test/bootstrap helpers. Product mutations use apply_operation.
|
|
462
|
+
async def insert(self, record: MemoryRecord) -> None:
|
|
463
|
+
key = (record.owner_id, record.memory_id)
|
|
464
|
+
current = self._rows.get(key)
|
|
465
|
+
if current is not None and current != record:
|
|
466
|
+
raise ValueError("memory id already exists with different content")
|
|
467
|
+
self._rows[key] = record
|
|
468
|
+
|
|
469
|
+
async def supersede(self, *, owner_id: str, old_id: str, new: MemoryRecord) -> None:
|
|
470
|
+
current = self._rows.get((owner_id, old_id))
|
|
471
|
+
if new.owner_id != owner_id:
|
|
472
|
+
raise ValueError("supersede cannot change owner")
|
|
473
|
+
if current is None or current.status != "active":
|
|
474
|
+
raise KeyError(old_id)
|
|
475
|
+
self._rows[(owner_id, old_id)] = replace(
|
|
476
|
+
current, status="superseded", updated_at=datetime.now(UTC)
|
|
477
|
+
)
|
|
478
|
+
self._rows[(new.owner_id, new.memory_id)] = new
|
|
479
|
+
|
|
480
|
+
async def forget(self, *, owner_id: str, memory_id: str) -> bool:
|
|
481
|
+
key = (owner_id, memory_id)
|
|
482
|
+
current = self._rows.get(key)
|
|
483
|
+
if current is None or current.status != "active":
|
|
484
|
+
return False
|
|
485
|
+
self._rows[key] = replace(current, status="forgotten", updated_at=datetime.now(UTC))
|
|
486
|
+
return True
|
|
487
|
+
|
|
488
|
+
async def clear_owner(
|
|
489
|
+
self,
|
|
490
|
+
*,
|
|
491
|
+
owner_id: str,
|
|
492
|
+
guard: OperationGuard | None = None,
|
|
493
|
+
) -> int:
|
|
494
|
+
async with self._lock:
|
|
495
|
+
if guard is not None:
|
|
496
|
+
await guard(None)
|
|
497
|
+
row_keys = [key for key in self._rows if key[0] == owner_id]
|
|
498
|
+
operation_keys = [key for key in self._operations if key[0] == owner_id]
|
|
499
|
+
for key in row_keys:
|
|
500
|
+
del self._rows[key]
|
|
501
|
+
for key in operation_keys:
|
|
502
|
+
del self._operations[key]
|
|
503
|
+
for key in [key for key in self._undone_by if key[0] == owner_id]:
|
|
504
|
+
del self._undone_by[key]
|
|
505
|
+
return len(row_keys)
|
|
506
|
+
|
|
507
|
+
async def count_active(self, *, owner_id: str) -> int:
|
|
508
|
+
return sum(
|
|
509
|
+
record.owner_id == owner_id and record.status == "active"
|
|
510
|
+
for record in self._rows.values()
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
async def get(self, *, owner_id: str, memory_id: str) -> MemoryRecord | None:
|
|
514
|
+
return self._rows.get((owner_id, memory_id))
|
|
515
|
+
|
|
516
|
+
def _active_records(self, *, owner_id: str) -> list[MemoryRecord]:
|
|
517
|
+
rows = [
|
|
518
|
+
record
|
|
519
|
+
for record in self._rows.values()
|
|
520
|
+
if record.owner_id == owner_id and record.status == "active"
|
|
521
|
+
]
|
|
522
|
+
rows.sort(key=recall_recency, reverse=True)
|
|
523
|
+
return rows
|
|
524
|
+
|
|
525
|
+
async def search_candidates(
|
|
526
|
+
self, *, owner_id: str, query: str, limit: int
|
|
527
|
+
) -> tuple[SearchCandidate, ...]:
|
|
528
|
+
cap = max(1, min(int(limit), 100))
|
|
529
|
+
active = self._active_records(owner_id=owner_id)
|
|
530
|
+
key = normalized_body(query)
|
|
531
|
+
exact = [record for record in active if normalized_body(record.body) == key]
|
|
532
|
+
query_terms = set(key.split())
|
|
533
|
+
scored: list[tuple[float, MemoryRecord]] = []
|
|
534
|
+
for record in active:
|
|
535
|
+
if normalized_body(record.body) == key:
|
|
536
|
+
continue
|
|
537
|
+
terms = set(normalized_body(record.body).split())
|
|
538
|
+
overlap = len(query_terms & terms) / max(1, len(query_terms))
|
|
539
|
+
substring = 0.25 if key and key in normalized_body(record.body) else 0.0
|
|
540
|
+
if (score := overlap + substring) > 0:
|
|
541
|
+
scored.append((score, record))
|
|
542
|
+
scored.sort(key=lambda item: (item[0], recall_recency(item[1])), reverse=True)
|
|
543
|
+
candidates = [
|
|
544
|
+
SearchCandidate(record=record, leg="exact", score=2.0) for record in exact[:cap]
|
|
545
|
+
]
|
|
546
|
+
candidates.extend(
|
|
547
|
+
SearchCandidate(record=record, leg="sparse", score=score)
|
|
548
|
+
for score, record in scored[: max(0, cap - len(candidates))]
|
|
549
|
+
)
|
|
550
|
+
return tuple(candidates)
|
|
551
|
+
|
|
552
|
+
async def list_active_page(
|
|
553
|
+
self,
|
|
554
|
+
*,
|
|
555
|
+
owner_id: str,
|
|
556
|
+
after: tuple[datetime, str] | None = None,
|
|
557
|
+
limit: int = 50,
|
|
558
|
+
) -> tuple[tuple[MemoryRecord, ...], tuple[datetime, str] | None]:
|
|
559
|
+
cap = max(1, min(int(limit), 100))
|
|
560
|
+
rows = self._active_records(owner_id=owner_id)
|
|
561
|
+
rows.sort(key=lambda record: (recall_recency(record), record.memory_id), reverse=True)
|
|
562
|
+
if after is not None:
|
|
563
|
+
rows = [
|
|
564
|
+
record
|
|
565
|
+
for record in rows
|
|
566
|
+
if (recall_recency(record), record.memory_id) < (after[0], after[1])
|
|
567
|
+
]
|
|
568
|
+
page = tuple(rows[:cap])
|
|
569
|
+
if len(rows) <= cap:
|
|
570
|
+
return page, None
|
|
571
|
+
last = rows[cap - 1]
|
|
572
|
+
return page, (recall_recency(last), last.memory_id)
|
|
573
|
+
|
|
574
|
+
async def purge_superseded(self, *, older_than: datetime) -> int:
|
|
575
|
+
async with self._lock:
|
|
576
|
+
row_keys = [
|
|
577
|
+
key
|
|
578
|
+
for key, record in self._rows.items()
|
|
579
|
+
if record.status != "active"
|
|
580
|
+
and record.updated_at is not None
|
|
581
|
+
and record.updated_at < older_than
|
|
582
|
+
]
|
|
583
|
+
operation_keys = [
|
|
584
|
+
key
|
|
585
|
+
for key, stored in self._operations.items()
|
|
586
|
+
if stored.receipt.created_at is not None and stored.receipt.created_at < older_than
|
|
587
|
+
]
|
|
588
|
+
for key in row_keys:
|
|
589
|
+
del self._rows[key]
|
|
590
|
+
for key in operation_keys:
|
|
591
|
+
del self._operations[key]
|
|
592
|
+
self._undone_by.pop(key, None)
|
|
593
|
+
return len(row_keys) + len(operation_keys)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def operation_change_id(operation: MemoryOperation) -> str:
|
|
597
|
+
return str(
|
|
598
|
+
uuid5(
|
|
599
|
+
NAMESPACE_URL,
|
|
600
|
+
f"dlightrag-memory-operation:{operation.owner_id}:{operation.idempotency_key}",
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def operation_record_id(owner_id: str, change_id: str, *, index: int = 0) -> str:
|
|
606
|
+
return str(uuid5(NAMESPACE_URL, f"dlightrag-memory-record:{owner_id}:{change_id}:{index}"))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def operation_fingerprint(operation: MemoryOperation) -> str:
|
|
610
|
+
payload = {
|
|
611
|
+
"action": operation.action,
|
|
612
|
+
"body": operation.body.strip(),
|
|
613
|
+
"kind": operation.kind,
|
|
614
|
+
"memory_id": operation.memory_id,
|
|
615
|
+
"mutation_limit": operation.mutation_limit,
|
|
616
|
+
"mutation_scope": operation.mutation_scope,
|
|
617
|
+
"origin_id": operation.provenance.origin_id,
|
|
618
|
+
"origin_kind": operation.provenance.origin_kind,
|
|
619
|
+
"owner_id": operation.owner_id,
|
|
620
|
+
"run_id": operation.provenance.run_id,
|
|
621
|
+
"session_id": operation.provenance.session_id,
|
|
622
|
+
"supersedes_id": operation.supersedes_id,
|
|
623
|
+
"target_change_id": operation.target_change_id,
|
|
624
|
+
}
|
|
625
|
+
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
626
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def operation_receipt(
|
|
630
|
+
operation: MemoryOperation,
|
|
631
|
+
change_id: str,
|
|
632
|
+
outcome: str,
|
|
633
|
+
*,
|
|
634
|
+
memory_ids: tuple[str, ...] = (),
|
|
635
|
+
kind: str | None = None,
|
|
636
|
+
body: str = "",
|
|
637
|
+
supersedes_id: str | None = None,
|
|
638
|
+
target_change_id: str | None = None,
|
|
639
|
+
now: datetime,
|
|
640
|
+
) -> MemoryOperationReceipt:
|
|
641
|
+
receipt = MemoryOperationReceipt(
|
|
642
|
+
change_id=change_id,
|
|
643
|
+
action=operation.action,
|
|
644
|
+
outcome=outcome, # type: ignore[arg-type]
|
|
645
|
+
memory_ids=memory_ids,
|
|
646
|
+
provenance=operation.provenance,
|
|
647
|
+
kind=kind, # type: ignore[arg-type]
|
|
648
|
+
body=body,
|
|
649
|
+
supersedes_id=supersedes_id if supersedes_id is not None else operation.supersedes_id,
|
|
650
|
+
target_change_id=(
|
|
651
|
+
target_change_id if target_change_id is not None else operation.target_change_id
|
|
652
|
+
),
|
|
653
|
+
mutation_scope=operation.mutation_scope,
|
|
654
|
+
created_at=now,
|
|
655
|
+
)
|
|
656
|
+
return receipt
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _receipt_scope(receipt: MemoryOperationReceipt) -> str | None:
|
|
660
|
+
return receipt.mutation_scope
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def default_purge_cutoff(days: int = MEMORY_SUPERSEDE_RETENTION_DAYS) -> datetime:
|
|
664
|
+
return datetime.now(UTC) - timedelta(days=days)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
__all__ = [
|
|
668
|
+
"InMemoryMemoryStore",
|
|
669
|
+
"MemoryStore",
|
|
670
|
+
"OperationGuard",
|
|
671
|
+
"default_purge_cutoff",
|
|
672
|
+
"operation_change_id",
|
|
673
|
+
"operation_fingerprint",
|
|
674
|
+
"operation_receipt",
|
|
675
|
+
"operation_record_id",
|
|
676
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dlightrag-memory
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Reusable cross-conversation profile memory for DlightRAG
|
|
5
|
+
Maintainer: HanlianLyu, hllyu
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
License-File: NOTICE
|
|
9
|
+
Requires-Python: <3.15,>=3.14.7
|
|
10
|
+
Requires-Dist: asyncpg>=0.31.0
|
|
11
|
+
Requires-Dist: mcp>=2.0.0
|
|
12
|
+
Requires-Dist: pydantic>=2.11.0
|