forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemory_service.py
More file actions
4063 lines (3751 loc) · 174 KB
/
Copy pathmemory_service.py
File metadata and controls
4063 lines (3751 loc) · 174 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Memory routing seam — surfaces route reads/writes/search through MemoryService (WS-L)."""
import hashlib
import json
import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import wraps
from typing import Any, Callable, Collection, Dict, Iterator, List, Literal, NoReturn, Optional, Set, Tuple, cast
from uuid import UUID
from fastapi import HTTPException
from pydantic import ValidationError
import database.memories as memories_db
import database.vector_db as vector_db
from database._client import db as default_db_client
from database.memory_collections import MemoryCollections
from database.memory_apply_store import privacy_deletion_receipt_id
from database.memory_ledger import purge_source_replacement_receipts_for_memories
from database.legal_holds import destructive_operation_gate
from database.review_queue import purge_stale_review_conflicts_for_memories
from database.vector_db import delete_memory_vector
from models.memories import MemoryDB
from models.knowledge_ledger_search import (
LedgerSearchSurface as LedgerSearchSurface,
is_ledger_row_admissible as is_ledger_row_admissible,
ledger_row_is_rejected,
)
from models.memory_apply import WriterMode
from models.product_memory import (
MemoryAccessPolicy,
MemoryConsumer,
MemoryItem,
MemoryItemStatus,
MemoryKind,
MemorySubjectScope,
LedgerWriteReason,
MemoryTier,
ProcessingState,
RESTRICTED_SENSITIVITY_LABELS,
SourceState,
)
from utils.log_sanitizer import sanitize_validation_error
from utils.other.list_budget import ListReadBudget, ListReadBudgetExhausted, budgeted_get_all
from utils.memory.canonical_memory_adapter import (
CanonicalBatchMutationLimitError,
CanonicalMemoryNotFoundError,
CanonicalScanCursor,
canonical_memory_lineage_ids,
delete_default_canonical_memories,
delete_all_canonical_memories,
delete_canonical_memory,
delete_canonical_memories_batch,
memory_item_to_memorydb,
purge_canonical_memory_projections,
read_canonical_memory_item,
read_canonical_memories,
read_canonical_scan_page,
refine_canonical_memory,
replace_conversation_sourced_memories,
retract_conversation_sourced_memories,
search_canonical_memories,
search_result_to_memorydb,
update_canonical_memory_content,
update_canonical_memory_visibility,
update_canonical_memory_product_fields,
update_canonical_memory_review,
is_direct_user_write_authority,
write_canonical_external_memory,
)
from utils.memory.product_memory_read_service import (
iter_authoritative_product_memory_items,
iter_authoritative_product_memory_items_newest_first,
)
from utils.memory.knowledge_ledger import (
LEDGER_SCHEMA_VERSION,
LedgerProvenance,
LedgerWrite,
amend_user_fact as amend_fact,
evidence_id_for_ledger_provenance,
reopen_standalone_fact,
save_fact,
)
from utils.memory.ledger_history_policy import is_ledger_history_item
from utils.memory.rejected_memory_feedback import clear_rejected_memory_feedback_cache
from utils.memory.required_promotion import required_processing_payload
from config.memory_rollout import MemoryRolloutMode, rollout_mode_env_value
from utils.client_device import DeviceScopeRequest
from utils.memory.device_scope_filter import memory_matches_device
from utils.memory.memory_system import MemorySystem
from utils.memory.memory_system import ensure_canonical_apply_control_state
from utils.jit_rollout import JITDecisionStage, resolve_jit_rollout_sync
from utils.memory.memory_api_contract import MemoryApiExposure, memory_api_payload
from utils.memory.belief_model import public_belief_overlay_json
from utils.memory.universal_list_cursor import (
StreamKeyset,
UniversalListCursorError,
UniversalListCursorState,
cursor_secret,
decode_universal_list_cursor,
encode_universal_list_cursor,
)
from utils.metrics import (
MEMORY_HISTORICAL_MATERIALIZATION_TOTAL,
MEMORY_HISTORICAL_SUPPRESSION_TOTAL,
MEMORY_UNIVERSAL_READ_ORIGIN_TOTAL,
)
logger = logging.getLogger(__name__)
MemoryBackingStoreStream = Literal['canonical', 'historical', 'cursor']
class MemoryBackingStoreUnavailable(HTTPException):
"""Recoverable backing-store failure for mixed-list reads.
Subclasses ``HTTPException`` so existing callers keep the same 503 body.
``GET /v3/memories`` first-page fallback catches this type instead of
matching ``detail`` strings — a renamed or newly added unavailable
message must not escape to clients as a hard 503.
"""
def __init__(self, detail: str, *, stream: MemoryBackingStoreStream) -> None:
super().__init__(status_code=503, detail=detail)
self.stream = stream
MemoryPayload = Dict[str, Any]
McpSearchPayload = Dict[str, Any]
MAX_LEDGER_HISTORY_PROVIDER_WINDOW = 500
MAX_LEDGER_REVERT_CHAIN_LENGTH = 64
_LEDGER_QUERY_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9']{1,63}")
def _legal_hold_gated_deletion(method: Callable[..., Any]) -> Callable[..., Any]:
"""Hold one server-owned deletion gate across legacy and canonical layers."""
@wraps(method)
def wrapped(self: Any, uid: str, *args: Any, **kwargs: Any) -> Any:
with destructive_operation_gate(
uid,
kind="explicit_memory_deletion",
firestore_client=self.db_client,
):
return method(self, uid, *args, **kwargs)
return wrapped
def _returned_lineage_ids(result: object, fallback: List[str]) -> List[str]:
"""Normalize the internal canonical deletion receipt for legacy test seams."""
if isinstance(result, list):
ids = [memory_id for memory_id in result if isinstance(memory_id, str) and memory_id]
if ids:
return list(dict.fromkeys(ids))
return list(dict.fromkeys(fallback))
def _purge_required_canonical_projections(
uid: str,
memory_ids: List[str],
*,
db_client: Any,
reason: str,
preserve_source_replacement_receipts: bool = False,
) -> None:
"""Map provider failures to the released fail-closed deletion contract."""
try:
purge_canonical_memory_projections(
uid,
memory_ids,
db_client=db_client,
reason=reason,
include_review_queue=False,
preserve_source_replacement_receipts=preserve_source_replacement_receipts,
)
except Exception as exc:
raise HTTPException(
status_code=503,
detail="Canonical memory projection privacy cleanup unavailable",
) from exc
def _delete_historical_privacy_overrides(uid: str, memory_ids: List[str], *, db_client: Any) -> None:
"""Remove content-derived override paths after physical legacy cleanup."""
client = db_client if db_client is not None else default_db_client
collections = MemoryCollections(uid=uid)
for memory_id in dict.fromkeys(memory_id for memory_id in memory_ids if memory_id):
client.document(f"{collections.memory_historical_overrides}/{memory_id}").delete()
class DeviceScopeNotSupportedError(ValueError):
"""device_scope filtering is only supported on the canonical memory backend."""
@dataclass(frozen=True)
class ExternalMemoryWriteContext:
"""Released compatibility context for universal external memory mutations."""
memory_system: MemorySystem
legacy_write_allowed: bool = True
legacy_write_status_code: int = 200
legacy_write_detail: Any = None
@dataclass(frozen=True)
class UniversalMemoryListPage:
"""One mixed-view page plus an opaque continuation cursor.
``truncated`` is True only when the request's list-read budget ended the
scan early (#11831); a truncated page carries no continuation cursor
because its cursor state would not cover every consumed scan position.
"""
memories: List[MemoryDB]
next_cursor: Optional[str]
truncated: bool = False
def resolve_external_memory_write_context(
uid: str,
*,
db_client: Any,
memory_system: MemorySystem,
consumer: str,
operation: str,
) -> ExternalMemoryWriteContext:
del uid, db_client, memory_system, consumer, operation
return ExternalMemoryWriteContext(memory_system=MemorySystem.CANONICAL, legacy_write_allowed=False)
def raise_if_legacy_write_blocked(context: ExternalMemoryWriteContext) -> None:
del context
def _truncate_locked_preview_text(content: str) -> str:
if len(content) > 70:
return content[:70] + "..."
return content
def truncate_locked_memory_preview(memory: MemoryDB) -> MemoryDB:
"""Truncate locked-memory content to the legacy 70-char preview."""
if not getattr(memory, 'is_locked', False) or not memory.content:
return memory
truncated = _truncate_locked_preview_text(memory.content)
if truncated == memory.content:
return memory
return memory.model_copy(update={"content": truncated})
def _legacy_memorydb(value: MemoryDB | Dict[str, Any]) -> MemoryDB:
"""Normalize one legacy memory object so direct route serialization stays untiered."""
if isinstance(value, MemoryDB):
return value.model_copy(update={"memory_tier": None})
payload = memory_api_payload(value, MemoryApiExposure.LEGACY)
memory = MemoryDB.model_validate(payload)
return memory.model_copy(update={"memory_tier": None})
def fetch_memory_dict(uid: str, memory_id: str, *, db_client: Any) -> MemoryPayload:
"""Fetch through the universal repository, retaining the released dict shape."""
return MemoryService(db_client=db_client).fetch(uid, memory_id).model_dump(mode="python")
def _reject_legacy_device_scope(
device_scope_request: Optional[DeviceScopeRequest],
) -> None:
scope = device_scope_request.device_scope if device_scope_request else "all"
if scope and scope != "all":
raise DeviceScopeNotSupportedError("device_scope filtering is unavailable for this request")
@dataclass(frozen=True)
class MemorySearchMatch:
memory: MemoryDB
score: float
@dataclass(frozen=True)
class LedgerHistoryPage:
"""Bounded canonical ledger history with an honest provider-window signal."""
memories: Tuple[MemoryDB, ...]
truncated: bool
scanned_count: int
@dataclass(frozen=True)
class LedgerHistorySearchPage:
"""Historical query results plus whether the canonical provider window ended."""
matches: Tuple[MemorySearchMatch, ...]
truncated: bool
scanned_count: int
next_offset: Optional[int] = None
@dataclass(frozen=True)
class LedgerRevertIdentity:
"""Canonical fact identity that every row in a revert chain must share."""
kind: MemoryKind
slot: Optional[str]
subject_scope: Optional[MemorySubjectScope]
subject_entity_id: Optional[str]
def _validate_memory_list(memories: List[MemoryPayload]) -> List[MemoryDB]:
valid_memories: List[MemoryDB] = []
for memory in memories:
memory = memory_api_payload(memory, MemoryApiExposure.LEGACY)
if memory.get("is_locked", False):
content = memory.get("content", "")
memory = dict(memory)
memory["content"] = _truncate_locked_preview_text(content)
try:
valid_memories.append(_legacy_memorydb(memory))
except ValidationError as exc:
missing_fields = [err["loc"][0] for err in exc.errors() if err.get("loc")]
logger.warning(
"Skipping invalid memory doc %s: missing/invalid fields %s",
memory.get("id", "unknown"),
missing_fields,
)
return valid_memories
def _legacy_read_memories(uid: str, *, limit: int = 100, offset: int = 0) -> List[MemoryDB]:
# Bound list reads; do not expand first page to 5000 (prod GET 504s).
effective_limit = max(1, min(limit if limit else 100, 500))
memories = memories_db.get_memories(uid, effective_limit, offset)
return _validate_memory_list(memories)
def _memory_ids_and_scores(
matches: List[MemoryPayload],
) -> tuple[List[str], Dict[str, float]]:
memory_ids: List[str] = []
scores_by_id: Dict[str, float] = {}
for match in matches:
memory_id = match.get("memory_id")
if not isinstance(memory_id, str) or not memory_id:
continue
memory_ids.append(memory_id)
scores_by_id[memory_id] = float(match.get("score") or 0)
return memory_ids, scores_by_id
def _legacy_search_memories(uid: str, query: str, *, limit: int = 5) -> List[MemorySearchMatch]:
capped_limit = max(1, min(limit, 20))
matches = vector_db.find_similar_memories(uid, query, threshold=0.0, limit=capped_limit)
if not matches:
return []
memory_ids, scores_by_id = _memory_ids_and_scores(matches)
if not memory_ids:
return []
memories_data = memories_db.get_memories_by_ids(uid, memory_ids)
memories_data = [
memory_api_payload(memory, MemoryApiExposure.LEGACY)
for memory in memories_data
if not memory.get("is_locked", False)
]
results: List[MemorySearchMatch] = []
for memory_data in memories_data:
memory_id = memory_data.get("id")
if not isinstance(memory_id, str):
continue
try:
memory_obj = _legacy_memorydb(memory_data)
except ValidationError:
continue
results.append(MemorySearchMatch(memory=memory_obj, score=scores_by_id.get(memory_id, 0.0)))
return results
class LegacyMemoryBackend:
def read(
self,
uid: str,
*,
limit: int = 100,
offset: int = 0,
device_scope_request: Optional[DeviceScopeRequest] = None,
include_pending_processing: bool = False,
include_archive: bool = False,
now: Optional[datetime] = None,
) -> List[MemoryDB]:
_reject_legacy_device_scope(device_scope_request)
del include_pending_processing, include_archive, now
return _legacy_read_memories(uid, limit=limit, offset=offset)
def search(
self,
uid: str,
query: str,
*,
limit: int = 5,
device_scope_request: Optional[DeviceScopeRequest] = None,
) -> List[MemorySearchMatch]:
_reject_legacy_device_scope(device_scope_request)
return _legacy_search_memories(uid, query, limit=limit)
def write(self, uid: str, data: Dict[str, Any]) -> str:
del uid, data
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def review(self, uid: str, memory_id: str, value: bool) -> None:
del uid, memory_id, value
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def update_product_fields(
self,
uid: str,
memory_id: str,
*,
tags: Optional[List[str]] = None,
category: Optional[str] = None,
is_baseline: Optional[bool] = None,
is_read: Optional[bool] = None,
is_dismissed: Optional[bool] = None,
) -> MemoryDB:
del uid, memory_id, tags, category, is_baseline, is_read, is_dismissed
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def write_batch(self, uid: str, items: List[Dict[str, Any]]) -> List[str]:
del uid, items
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def update_content(self, uid: str, memory_id: str, content: str) -> MemoryDB:
del uid, memory_id, content
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def update_visibility(self, uid: str, memory_id: str, visibility: str) -> None:
del uid, memory_id, visibility
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def delete(self, uid: str, memory_id: str) -> None:
del uid, memory_id
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def delete_all(self, uid: str) -> None:
del uid
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
def delete_default(self, uid: str) -> None:
del uid
raise RuntimeError("historical memory adapter is read-only; use canonical apply")
class CanonicalMemoryBackend:
def __init__(self, *, db_client: Any = None):
self._db_client = db_client
def read(
self,
uid: str,
*,
limit: int = 100,
offset: int = 0,
device_scope_request: Optional[DeviceScopeRequest] = None,
include_pending_processing: bool = False,
include_archive: bool = False,
now: Optional[datetime] = None,
budget: Optional[ListReadBudget] = None,
) -> List[MemoryDB]:
return [
truncate_locked_memory_preview(memory)
for memory in read_canonical_memories(
uid,
limit=limit,
offset=offset,
db_client=self._db_client,
device_scope_request=device_scope_request,
include_pending_processing=include_pending_processing,
include_archive=include_archive,
now=now,
budget=budget,
)
]
def search(
self,
uid: str,
query: str,
*,
limit: int = 5,
device_scope_request: Optional[DeviceScopeRequest] = None,
item_filter: Optional[Callable[[MemoryItem], bool]] = None,
ledger_kinds: Optional[Collection[str]] = None,
) -> List[MemorySearchMatch]:
search_kwargs: Dict[str, Any] = {
"limit": limit,
"db_client": self._db_client,
"device_scope_request": device_scope_request,
"item_filter": item_filter,
}
if ledger_kinds is not None:
search_kwargs["ledger_kinds"] = ledger_kinds
items = search_canonical_memories(
uid,
query,
**search_kwargs,
)
results: List[MemorySearchMatch] = []
for rank, item in enumerate(items):
if not item.get("memory_id"):
continue
memory_obj = search_result_to_memorydb(uid, item)
if memory_obj.is_locked or memory_obj.user_review is False or memory_obj.invalid_at is not None:
continue
raw_score = item.get("score") or item.get("relevance_score")
try:
score = float(raw_score) if raw_score is not None else 1.0 - rank * 0.0001
except (TypeError, ValueError):
score = 1.0 - rank * 0.0001
results.append(MemorySearchMatch(memory=memory_obj, score=score))
return results
def write(self, uid: str, data: Dict[str, Any]) -> str:
return write_canonical_external_memory(uid, data, db_client=self._db_client)
def review(self, uid: str, memory_id: str, value: bool) -> None:
update_canonical_memory_review(uid, memory_id, value, db_client=self._db_client)
def update_product_fields(
self,
uid: str,
memory_id: str,
*,
tags: Optional[List[str]] = None,
category: Optional[str] = None,
is_baseline: Optional[bool] = None,
is_read: Optional[bool] = None,
is_dismissed: Optional[bool] = None,
) -> MemoryDB:
item = update_canonical_memory_product_fields(
uid,
memory_id,
tags=tags,
category=category,
is_baseline=is_baseline,
is_read=is_read,
is_dismissed=is_dismissed,
db_client=self._db_client,
)
return memory_item_to_memorydb(item)
def write_batch(self, uid: str, items: List[Dict[str, Any]]) -> List[str]:
return [self.write(uid, item) for item in items]
def update_content(self, uid: str, memory_id: str, content: str) -> MemoryDB:
item = update_canonical_memory_content(uid, memory_id, content, db_client=self._db_client)
return memory_item_to_memorydb(item)
def update_visibility(self, uid: str, memory_id: str, visibility: str) -> None:
update_canonical_memory_visibility(uid, memory_id, visibility, db_client=self._db_client)
def delete(self, uid: str, memory_id: str) -> List[str]:
return delete_canonical_memory(uid, memory_id, db_client=self._db_client)
def delete_batch(self, uid: str, memory_ids: List[str]) -> List[str]:
"""Atomically tombstone a bounded set of canonical identities."""
return delete_canonical_memories_batch(uid, memory_ids, db_client=self._db_client)
def delete_all(self, uid: str) -> None:
delete_all_canonical_memories(uid, db_client=self._db_client)
def delete_default(self, uid: str) -> None:
delete_default_canonical_memories(uid, db_client=self._db_client)
@dataclass(frozen=True)
class MemoryLocator:
"""Origin-qualified physical location for a released public memory id."""
uid: str
origin: str
physical_id: str
@dataclass(frozen=True)
class HistoricalMemoryRecord:
"""Read-only adaptation of one ``users/{uid}/memories`` document."""
memory: MemoryDB
locator: MemoryLocator
lifecycle: str = "grandfathered_long_term"
# False when the row is an index stub (id + sort timestamps only). Merge
# and suppression use stubs; the mixed list hydrates only the emitted page.
hydrated: bool = True
class HistoricalMemoryAdapter:
"""Bounded, protected, read-only reader for historical memory documents.
This class deliberately has no create/update/delete methods. Physical
deletion is exposed only through ``cleanup`` and is called after a
canonical mutation has committed.
"""
MAX_PAGE_SIZE = 500
MAX_COMPATIBILITY_WINDOW = 5000
def __init__(self, *, db_client: Any = None):
self._db_client = db_client
def _firestore_kwargs(self) -> Dict[str, Any]:
return {"firestore_client": self._db_client} if self._db_client is not None else {}
@staticmethod
def _timestamp(memory: MemoryDB) -> datetime:
value = memory.updated_at or memory.created_at
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
@staticmethod
def _historical_memory(
raw: MemoryPayload,
*,
include_locked_content: bool = False,
uid: Optional[str] = None,
) -> MemoryDB:
# Missing visibility is a compatibility case. Public is the released
# legacy default and is therefore retained for old documents.
payload = memory_api_payload(raw, MemoryApiExposure.LEGACY)
payload.setdefault("visibility", "public")
# Historical rows live under ``users/{uid}/memories/{id}`` and a legacy
# cohort never stored the redundant ``uid`` field, which ``MemoryDB``
# requires. The owning path is the authority for it, so fall back to
# it instead of dropping the row during Pydantic validation.
if uid is not None:
payload.setdefault("uid", uid)
# A few early historical documents predate ``updated_at``. Keep those
# rows readable and let every sort surface use the same creation-time
# fallback instead of dropping the row during Pydantic validation.
if payload.get("updated_at") is None and payload.get("created_at") is not None:
payload["updated_at"] = payload["created_at"]
if not include_locked_content and payload.get("is_locked") and isinstance(payload.get("content"), str):
payload["content"] = _truncate_locked_preview_text(payload["content"])
memory = MemoryDB.model_validate(payload)
# Historical rows are logically grandfathered Long-term records. This
# adapter classification is deliberately not a fabricated promotion
# receipt; it only preserves the released lifecycle response shape.
return memory.model_copy(update={"memory_tier": MemoryTier.long_term})
@classmethod
def _adapt(
cls,
uid: str,
raw: MemoryPayload,
*,
include_locked_content: bool = False,
) -> Optional[HistoricalMemoryRecord]:
memory_id = raw.get("id")
if not isinstance(memory_id, str) or not memory_id.strip():
return None
try:
memory = cls._historical_memory(raw, include_locked_content=include_locked_content, uid=uid)
except ValidationError as exc:
# Never log ValidationError.__str__ — it embeds input_value (memory content).
logger.warning(
"Skipping malformed historical memory uid=%s memory_id=%s: %s",
uid,
memory_id,
sanitize_validation_error(exc),
)
return None
except (TypeError, ValueError):
logger.warning(
"Skipping malformed historical memory uid=%s memory_id=%s type=adapt_error",
uid,
memory_id,
)
return None
if memory.visibility not in {"private", "public", "shared"}:
logger.warning(
"Skipping historical memory with unknown visibility uid=%s memory_id=%s",
uid,
memory_id,
)
return None
return HistoricalMemoryRecord(
memory=memory,
locator=MemoryLocator(uid=uid, origin="legacy", physical_id=memory.id),
)
@staticmethod
def matches_device(record: HistoricalMemoryRecord, request: Optional[DeviceScopeRequest]) -> bool:
if request is None or request.device_scope == "all":
return True
if not request.client_device_id:
return False
# A historical record has no capture-device provenance. It is
# device-neutral and remains visible under a scoped request. Records
# that do carry provenance use the same matcher as canonical rows.
memory = record.memory
known_devices = set(memory.capture_device_ids or [])
known_devices.update(
client_device_id
for evidence in memory.evidence
if (client_device_id := evidence.client_device_id) is not None
)
return not known_devices or request.client_device_id in known_devices
def _stub_from_index(self, uid: str, raw: Dict[str, Any]) -> Optional[HistoricalMemoryRecord]:
memory_id = raw.get("id")
if not isinstance(memory_id, str) or not memory_id.strip():
return None
def _as_datetime(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
return None
created_at = _as_datetime(raw.get("created_at"))
updated_at = _as_datetime(raw.get("updated_at")) or created_at
if created_at is None and updated_at is None:
return None
created_value = created_at or updated_at
updated_value = updated_at or created_value
assert created_value is not None and updated_value is not None
visibility = raw.get("visibility")
if visibility is None or visibility == "":
# Missing visibility is the released legacy default, same as _adapt.
visibility = "public"
elif visibility not in {"private", "public", "shared"}:
return None
capture_ids = raw.get("capture_device_ids") or []
if not isinstance(capture_ids, list):
capture_ids = []
capture_ids = [device_id for device_id in capture_ids if isinstance(device_id, str) and device_id]
try:
memory = MemoryDB.model_validate(
{
"id": memory_id,
"uid": uid,
"content": "",
"category": "interesting",
"created_at": created_value,
"updated_at": updated_value,
"visibility": visibility,
"capture_device_ids": capture_ids,
}
)
except (ValidationError, TypeError, ValueError):
return None
memory = memory.model_copy(update={"memory_tier": MemoryTier.long_term})
return HistoricalMemoryRecord(
memory=memory,
locator=MemoryLocator(uid=uid, origin="legacy", physical_id=memory.id),
hydrated=False,
)
def hydrate_records(
self,
uid: str,
records: List[HistoricalMemoryRecord],
*,
budget: Optional[ListReadBudget] = None,
) -> List[HistoricalMemoryRecord]:
memory_ids = [record.memory.id for record in records if not record.hydrated]
if not memory_ids:
return records
try:
raw_rows = memories_db.get_memories_by_ids(uid, memory_ids, budget=budget, **self._firestore_kwargs())
except ListReadBudgetExhausted:
raise
except Exception as exc:
raise MemoryBackingStoreUnavailable("Historical memory unavailable", stream="historical") from exc
adapted: Dict[str, HistoricalMemoryRecord] = {}
for raw in raw_rows:
record = self._adapt(uid, raw)
if record is None:
continue
adapted[record.memory.id] = record
hydrated: List[HistoricalMemoryRecord] = []
for record in records:
if record.hydrated:
hydrated.append(record)
continue
replacement = adapted.get(record.memory.id)
if replacement is not None:
hydrated.append(replacement)
return hydrated
def read(
self,
uid: str,
*,
limit: int = 100,
offset: int = 0,
device_scope_request: Optional[DeviceScopeRequest] = None,
hydrate: bool = True,
budget: Optional[ListReadBudget] = None,
) -> List[HistoricalMemoryRecord]:
bounded_limit = max(1, min(int(limit or 100), self.MAX_COMPATIBILITY_WINDOW))
bounded_offset = max(0, int(offset or 0))
needed = bounded_offset + bounded_limit
if needed > self.MAX_COMPATIBILITY_WINDOW:
raise HTTPException(status_code=413, detail="Historical memory pagination window exceeded")
# Index the whole prefix in one dual-window metadata query, then hydrate
# only the returned page. ``updated_or_created_desc`` has no single index,
# so the helper streams two candidate windows of ``limit + offset``
# documents. Hydrating that prefix (and decrypting it) is what took
# GET /v3/memories past the 30s edge timeout on 2026-08-18 once first
# pages fell back here. Mixed-list expansion needs prefix ids and
# timestamps, not content — pass hydrate=False for that caller.
# Grow the indexed prefix when adapt/device filters skip rows so a page
# of N valid memories is not silently shortened by malformed documents.
# With a ``budget`` both windows of every expansion round charge their
# fetched rows and each stream runs under the per-RPC timeout (#11831).
index_limit = needed
records: List[HistoricalMemoryRecord] = []
try:
while True:
index_rows = memories_db.list_memory_updated_or_created_index(
uid,
index_limit,
0,
budget=budget,
**self._firestore_kwargs(),
)
records = []
for raw in index_rows:
record = self._stub_from_index(uid, raw)
if record is None or not self.matches_device(record, device_scope_request):
continue
records.append(record)
if len(records) >= needed:
break
if len(records) >= needed or len(index_rows) < index_limit:
break
if index_limit >= self.MAX_COMPATIBILITY_WINDOW:
break
index_limit = min(
self.MAX_COMPATIBILITY_WINDOW,
max(index_limit + bounded_limit, index_limit * 2),
)
except ListReadBudgetExhausted:
# The request budget ended this read: return the rows already known
# so the caller can serve an explicitly truncated prefix instead of
# converting the typed exhaustion into a 503.
records.sort(
key=lambda record: (
-self._timestamp(record.memory).timestamp(),
record.memory.id,
)
)
return records[bounded_offset : bounded_offset + bounded_limit]
except Exception as exc:
raise MemoryBackingStoreUnavailable("Historical memory unavailable", stream="historical") from exc
records.sort(
key=lambda record: (
-self._timestamp(record.memory).timestamp(),
record.memory.id,
)
)
page = records[bounded_offset : bounded_offset + bounded_limit]
if not hydrate or not page:
return page
return self.hydrate_records(uid, page, budget=budget)
def read_scan_page(
self,
uid: str,
*,
limit: int = 100,
scan_offset: int = 0,
device_scope_request: Optional[DeviceScopeRequest] = None,
) -> NoReturn:
"""Retired offset scan — cursor paging must use dual keyset streams.
Kept only so accidental callers fail loudly instead of silently
reintroducing the 5000/source-window offset race.
"""
del uid, limit, scan_offset, device_scope_request
raise RuntimeError("historical offset scan is retired; use read_updated_scan_page / read_created_scan_page")
def _adapt_scan_payloads(
self,
uid: str,
payloads: List[Dict[str, Any]],
cursors: List[Tuple[datetime, str]],
*,
device_scope_request: Optional[DeviceScopeRequest],
drop_updated_at_present: bool = False,
include_locked_content: bool = False,
) -> List[Tuple[Optional[HistoricalMemoryRecord], Tuple[datetime, str]]]:
slots: List[Tuple[Optional[HistoricalMemoryRecord], Tuple[datetime, str]]] = []
for raw, scan_cursor in zip(payloads, cursors):
if drop_updated_at_present and raw.get('updated_at') is not None:
# Owned by the updated_at stream — advance created cursor only.
slots.append((None, scan_cursor))
continue
if raw.get('user_review') is False or raw.get('invalid_at') is not None:
slots.append((None, scan_cursor))
continue
decrypted = memories_db.prepare_memory_for_read(raw, uid) or raw
decrypted = dict(decrypted)
decrypted['id'] = raw.get('id')
record = self._adapt(uid, decrypted, include_locked_content=include_locked_content)
if record is None or not self.matches_device(record, device_scope_request):
slots.append((None, scan_cursor))
else:
slots.append((record, scan_cursor))
return slots
def read_updated_scan_page(
self,
uid: str,
*,
limit: int = 100,
start_after: Optional[Tuple[datetime, str]] = None,
device_scope_request: Optional[DeviceScopeRequest] = None,
include_locked_content: bool = False,
budget: Optional[ListReadBudget] = None,
) -> Tuple[List[Tuple[Optional[HistoricalMemoryRecord], Tuple[datetime, str]]], bool]:
"""Bounded updated_at-present historical keyset page."""
bounded_limit = max(1, min(int(limit or 100), self.MAX_PAGE_SIZE))
try:
payloads, cursors, exhausted = memories_db.scan_memories_updated_at_page(
uid,
limit=bounded_limit,
start_after=start_after,
budget=budget,
**self._firestore_kwargs(),
)
except (HTTPException, ListReadBudgetExhausted):
raise
except Exception as exc:
raise MemoryBackingStoreUnavailable("Historical memory unavailable", stream="historical") from exc
slots = self._adapt_scan_payloads(
uid,
payloads,
cursors,
device_scope_request=device_scope_request,
drop_updated_at_present=False,
include_locked_content=include_locked_content,
)
return slots, exhausted
def read_created_scan_page(
self,
uid: str,
*,
limit: int = 100,
start_after: Optional[Tuple[datetime, str]] = None,
device_scope_request: Optional[DeviceScopeRequest] = None,
include_locked_content: bool = False,
budget: Optional[ListReadBudget] = None,
) -> Tuple[List[Tuple[Optional[HistoricalMemoryRecord], Tuple[datetime, str]]], bool]:
"""Bounded created_at historical keyset page with updated_at-present filtered out."""
bounded_limit = max(1, min(int(limit or 100), self.MAX_PAGE_SIZE))
try:
payloads, cursors, exhausted = memories_db.scan_memories_created_at_page(
uid,
limit=bounded_limit,
start_after=start_after,
budget=budget,
**self._firestore_kwargs(),
)
except (HTTPException, ListReadBudgetExhausted):
raise
except Exception as exc:
raise MemoryBackingStoreUnavailable("Historical memory unavailable", stream="historical") from exc
slots = self._adapt_scan_payloads(
uid,
payloads,
cursors,
device_scope_request=device_scope_request,
drop_updated_at_present=True,
include_locked_content=include_locked_content,
)
return slots, exhausted
def get(self, uid: str, memory_id: str) -> Optional[HistoricalMemoryRecord]:
try:
raw = memories_db.get_memory(uid, memory_id, **self._firestore_kwargs())
except Exception as exc:
raise MemoryBackingStoreUnavailable("Historical memory unavailable", stream="historical") from exc
if not raw:
return None