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