pycontextdb 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (153) hide show
  1. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/CHANGELOG.md +14 -0
  2. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/PKG-INFO +1 -1
  3. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/__init__.py +1 -1
  4. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/client.py +51 -19
  5. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/retrieval.py +28 -16
  6. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/trust.py +31 -7
  7. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/base.py +26 -6
  8. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/postgres_store.py +28 -8
  9. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/sqlite_store.py +2 -0
  10. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/memory_evolution.md +17 -6
  11. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/pyproject.toml +1 -1
  12. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_memory_evolution.py +184 -1
  13. pycontextdb-0.4.2/tests/unit/test_pg_sql.py +228 -0
  14. pycontextdb-0.4.2/tests/unit/test_retrieval.py +84 -0
  15. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_store.py +39 -2
  16. pycontextdb-0.4.0/tests/unit/test_pg_sql.py +0 -94
  17. pycontextdb-0.4.0/tests/unit/test_retrieval.py +0 -30
  18. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/CODEOWNERS +0 -0
  19. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/copilot-instructions.md +0 -0
  20. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/pull_request_template.md +0 -0
  21. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/workflows/ci.yml +0 -0
  22. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/workflows/publish.yml +0 -0
  23. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.gitignore +0 -0
  24. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.pre-commit-config.yaml +0 -0
  25. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/COMMERCIAL.md +0 -0
  26. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/CONTRIBUTING.md +0 -0
  27. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/LICENSE +0 -0
  28. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/NOTICE +0 -0
  29. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/OPEN_CORE.md +0 -0
  30. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/README.md +0 -0
  31. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/.cursorrules +0 -0
  32. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/CLAUDE.md +0 -0
  33. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/README.md +0 -0
  34. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/llms-full.txt +0 -0
  35. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/llms.txt +0 -0
  36. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/README.md +0 -0
  37. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/architecture.png +0 -0
  38. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/benchmarks.png +0 -0
  39. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/hero.png +0 -0
  40. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/performance.png +0 -0
  41. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/problem.png +0 -0
  42. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/quickstart.png +0 -0
  43. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/__init__.py +0 -0
  44. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/run_benchmarks.py +0 -0
  45. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/trust_bakeoff.py +0 -0
  46. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/__init__.py +0 -0
  47. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/memory_bus.py +0 -0
  48. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/rl_manager.py +0 -0
  49. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/cli.py +0 -0
  50. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/__init__.py +0 -0
  51. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/base.py +0 -0
  52. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/postgres.py +0 -0
  53. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/__init__.py +0 -0
  54. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/clock.py +0 -0
  55. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/config.py +0 -0
  56. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/exceptions.py +0 -0
  57. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/models.py +0 -0
  58. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/policy.py +0 -0
  59. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/slots.py +0 -0
  60. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/__init__.py +0 -0
  61. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/evolution.py +0 -0
  62. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/formation.py +0 -0
  63. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/salience.py +0 -0
  64. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/__init__.py +0 -0
  65. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/base.py +0 -0
  66. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/causal.py +0 -0
  67. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/entity.py +0 -0
  68. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/semantic.py +0 -0
  69. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/temporal.py +0 -0
  70. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/__init__.py +0 -0
  71. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/act.py +0 -0
  72. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/autogen.py +0 -0
  73. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/crewai.py +0 -0
  74. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/langchain.py +0 -0
  75. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/livekit.py +0 -0
  76. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/openai_tools.py +0 -0
  77. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/pipecat.py +0 -0
  78. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/prompting.py +0 -0
  79. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/mcp.py +0 -0
  80. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/__init__.py +0 -0
  81. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/experiential.py +0 -0
  82. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/factual.py +0 -0
  83. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/working.py +0 -0
  84. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/pool.py +0 -0
  85. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/__init__.py +0 -0
  86. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/audit.py +0 -0
  87. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/injection.py +0 -0
  88. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/pii_detector.py +0 -0
  89. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/retention.py +0 -0
  90. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/py.typed +0 -0
  91. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/serve.py +0 -0
  92. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/__init__.py +0 -0
  93. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/factory.py +0 -0
  94. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/pg_sql.py +0 -0
  95. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/vector_index.py +0 -0
  96. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/__init__.py +0 -0
  97. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/embeddings.py +0 -0
  98. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/llm.py +0 -0
  99. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/migrations.py +0 -0
  100. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/api.md +0 -0
  101. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/architecture.md +0 -0
  102. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/connectors.md +0 -0
  103. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/index.md +0 -0
  104. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/migrations.md +0 -0
  105. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/multi_tenant.md +0 -0
  106. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/postgres.md +0 -0
  107. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/prompts.md +0 -0
  108. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/quickstart.md +0 -0
  109. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/serve.md +0 -0
  110. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/trust_policy.md +0 -0
  111. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/customer_support.py +0 -0
  112. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/multi_agent_team.py +0 -0
  113. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/phone_agent.py +0 -0
  114. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/research_assistant.py +0 -0
  115. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/CLA-entity.md +0 -0
  116. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/CLA-individual.md +0 -0
  117. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/signed/README.md +0 -0
  118. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/mkdocs.yml +0 -0
  119. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/scripts/check_open_core_boundary.py +0 -0
  120. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/scripts/check_release.py +0 -0
  121. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/__init__.py +0 -0
  122. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/conftest.py +0 -0
  123. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/evals/__init__.py +0 -0
  124. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/evals/test_trust_model.py +0 -0
  125. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/__init__.py +0 -0
  126. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/test_client.py +0 -0
  127. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/test_langchain_integration.py +0 -0
  128. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/pg_util.py +0 -0
  129. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/test_version.py +0 -0
  130. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/__init__.py +0 -0
  131. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_audit.py +0 -0
  132. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_config.py +0 -0
  133. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_connectors.py +0 -0
  134. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_decision_scope.py +0 -0
  135. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_embeddings.py +0 -0
  136. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_evolution.py +0 -0
  137. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_formation.py +0 -0
  138. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_graphs.py +0 -0
  139. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_host_api.py +0 -0
  140. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_init.py +0 -0
  141. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_memory_bus.py +0 -0
  142. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_models.py +0 -0
  143. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_open_core_boundary.py +0 -0
  144. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_pii_detector.py +0 -0
  145. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_concurrency.py +0 -0
  146. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_connector.py +0 -0
  147. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_memory_evolution.py +0 -0
  148. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_retention.py +0 -0
  149. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_scoped_id_access.py +0 -0
  150. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_serve.py +0 -0
  151. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_trust_policy.py +0 -0
  152. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_vector_index.py +0 -0
  153. {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.2 — 2026-09-20
6
+
7
+ - Recall now bounds sibling composition before deep-copying Postgres cache
8
+ entries and borrows read-only candidate references while ranking. Final
9
+ results remain detached, and entity priority, temporal validity, user scope,
10
+ and composition limits are unchanged.
11
+
12
+ ## 0.4.1 — 2026-08-24
13
+
14
+ - NOOP audit entries now carry the existing memory ID and canonical slot
15
+ identifiers when a duplicate, explicit target, or unambiguous slot resolves
16
+ to a current memory. Ambiguous slot-only NOOPs fail closed, while all NOOPs
17
+ remain content-free and leave the memory version unchanged.
18
+
5
19
  ## 0.4.0 — 2026-08-23
6
20
 
7
21
  - Explicit deterministic `add` / `update` / `delete` / `noop` memory
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: The unified context layer for AI agents — memory you can act on without treating wishes as facts
5
5
  Project-URL: Homepage, https://github.com/atomsai/contextdb
6
6
  Project-URL: Documentation, https://contextdb.dev
@@ -41,7 +41,7 @@ from contextdb.core.policy import TrustPolicy
41
41
  from contextdb.pool import ContextDBPool
42
42
  from contextdb.utils.embeddings import EmbeddingProvider
43
43
 
44
- __version__ = "0.4.0"
44
+ __version__ = "0.4.2"
45
45
 
46
46
  __all__ = [
47
47
  "Clock",
@@ -639,13 +639,26 @@ class ContextDB:
639
639
  attribute=attribute,
640
640
  )
641
641
  if self._audit is not None:
642
+ details: dict[str, object] = {
643
+ "operation": EvolutionOperation.NOOP.value,
644
+ "reason": safe_reason,
645
+ }
646
+ verified_slot = (
647
+ canonicalize_slot(
648
+ verified.entity_key,
649
+ verified.attribute_key,
650
+ )
651
+ if verified is not None
652
+ else None
653
+ )
654
+ if verified_slot is not None:
655
+ details["entity"] = verified_slot.entity
656
+ details["attribute"] = verified_slot.attribute
642
657
  await self._audit.log(
643
658
  operation="NOOP",
659
+ memory_id=verified.id if verified is not None else None,
644
660
  user_id=uid,
645
- details={
646
- "operation": EvolutionOperation.NOOP.value,
647
- "reason": safe_reason,
648
- },
661
+ details=details,
649
662
  )
650
663
  token = await store.consistency_token()
651
664
  return MemoryEvolutionResult(
@@ -890,12 +903,20 @@ class ContextDB:
890
903
  target = await self._evolution_target(
891
904
  target_memory_id,
892
905
  user_id=user_id,
893
- require_current=False,
906
+ require_current=True,
894
907
  )
895
908
 
896
909
  slot = canonicalize_slot(entity, attribute)
897
- if slot is not None:
898
- if target is not None:
910
+ has_slot_reference = bool(entity and entity.strip()) and bool(
911
+ attribute and attribute.strip()
912
+ )
913
+ if has_slot_reference and slot is None:
914
+ raise EvolutionTargetRequiredError(
915
+ "NOOP entity/attribute must resolve to a slot"
916
+ )
917
+
918
+ if target is not None:
919
+ if slot is not None:
899
920
  target_slot = canonicalize_slot(
900
921
  target.entity_key,
901
922
  target.attribute_key,
@@ -907,17 +928,27 @@ class ContextDB:
907
928
  raise EvolutionOperationConflictError(
908
929
  "target memory and requested entity/attribute identify different slots"
909
930
  )
910
- rows = await self._require_store().list_by_slot(
911
- slot.entity,
912
- slot.attribute,
913
- user_id=user_id,
931
+ return target
932
+
933
+ if slot is None:
934
+ return None
935
+
936
+ rows = await self._require_store().list_by_slot(
937
+ slot.entity,
938
+ slot.attribute,
939
+ user_id=user_id,
940
+ )
941
+ current_at = self.clock()
942
+ current = [row for row in rows if row.is_valid_at(current_at)]
943
+ if not current:
944
+ raise EvolutionTargetNotFoundError(
945
+ f"{slot.entity}/{slot.attribute}"
914
946
  )
915
- current = [row for row in rows if row.is_valid_at(self.clock())]
916
- if not current:
917
- raise EvolutionTargetNotFoundError(
918
- f"{slot.entity}/{slot.attribute}"
919
- )
920
- return target
947
+ if len(current) != 1:
948
+ raise EvolutionOperationConflictError(
949
+ "slot NOOP is ambiguous; pass target_memory_id"
950
+ )
951
+ return current[0]
921
952
 
922
953
  async def _evolve_delete_transactional(
923
954
  self,
@@ -1897,6 +1928,9 @@ class ContextDB:
1897
1928
  siblings = await store.list_by_entities(
1898
1929
  entity_keys,
1899
1930
  user_id=user_id,
1931
+ exclude_ids=seen,
1932
+ valid_at=moment,
1933
+ limit=extra,
1900
1934
  )
1901
1935
  siblings_by_entity: dict[str, list[MemoryItem]] = {}
1902
1936
  for sibling in siblings:
@@ -1909,8 +1943,6 @@ class ContextDB:
1909
1943
  if not item.entity_key:
1910
1944
  continue
1911
1945
  for sibling in siblings_by_entity.get(item.entity_key, []):
1912
- if sibling.id in seen or not sibling.is_valid_at(moment):
1913
- continue
1914
1946
  seen.add(sibling.id)
1915
1947
  extras.append(sibling)
1916
1948
  if len(extras) >= extra:
@@ -15,7 +15,7 @@ heuristics below get ~80% of the signal for zero cost.
15
15
  from __future__ import annotations
16
16
 
17
17
  import re
18
- from dataclasses import dataclass, field
18
+ from dataclasses import dataclass, field, replace
19
19
  from datetime import datetime
20
20
  from typing import TYPE_CHECKING
21
21
 
@@ -166,7 +166,10 @@ class RetrievalEngine:
166
166
  """RRF-fuse per-graph rankings, then multiply by salience."""
167
167
  weights = self.classifier.classify(query)
168
168
  seed_items = await self.store.search_by_embedding(
169
- query_embedding, top_k=top_k * 2, user_id=user_id
169
+ query_embedding,
170
+ top_k=top_k * 2,
171
+ user_id=user_id,
172
+ copy_items=False,
170
173
  )
171
174
  semantic_ranking = [(item.id, 1.0 / (rank + 1)) for rank, item in enumerate(seed_items)]
172
175
  rankings: dict[str, list[tuple[str, float]]] = {"semantic": semantic_ranking}
@@ -201,25 +204,34 @@ class RetrievalEngine:
201
204
  if not fused:
202
205
  # No fusion signal (empty store or zero weights): rank the seeds
203
206
  # directly, still applying salience.
204
- return [
207
+ scored = [
205
208
  self._score(
206
209
  item, 1.0 / (rank + 1), per_graph_ranks, now, seed_cosine.get(item.id, 0.0)
207
210
  )
208
211
  for rank, item in enumerate(seed_items[:top_k])
209
212
  ]
210
-
211
- scored: list[ScoredMemory] = []
212
- for mid, rrf_score in fused:
213
- item = seed_by_id.get(mid)
214
- if item is None:
215
- item = await self.store.get_raw(mid)
216
- if item is None:
217
- continue
218
- scored.append(
219
- self._score(item, rrf_score, per_graph_ranks, now, seed_cosine.get(mid, 0.0))
220
- )
221
- scored.sort(key=lambda s: s.final_score, reverse=True)
222
- return scored[:top_k]
213
+ else:
214
+ scored = []
215
+ for mid, rrf_score in fused:
216
+ item = seed_by_id.get(mid)
217
+ if item is None:
218
+ item = await self.store.get_raw(mid)
219
+ if item is None:
220
+ continue
221
+ scored.append(
222
+ self._score(
223
+ item,
224
+ rrf_score,
225
+ per_graph_ranks,
226
+ now,
227
+ seed_cosine.get(mid, 0.0),
228
+ )
229
+ )
230
+ scored.sort(key=lambda s: s.final_score, reverse=True)
231
+ return [
232
+ replace(score, item=score.item.model_copy(deep=True))
233
+ for score in scored[:top_k]
234
+ ]
223
235
 
224
236
  def _score(
225
237
  self,
@@ -225,8 +225,11 @@ class TrustEngine:
225
225
  speakers = list(candidate.corroborated_by)
226
226
  if incoming_speaker in speakers:
227
227
  await self._log_noop(
228
+ candidate.id,
228
229
  user_id,
229
230
  reason="same_speaker_same_value",
231
+ entity=item.entity_key,
232
+ attribute=item.attribute_key,
230
233
  )
231
234
  return candidate, "noop"
232
235
  speakers.append(incoming_speaker)
@@ -495,7 +498,13 @@ class TrustEngine:
495
498
  speakers = list(candidate.corroborated_by)
496
499
  if incoming_speaker in speakers:
497
500
  reason = "same_speaker_same_value"
498
- await self._log_noop(user_id, reason=reason)
501
+ await self._log_noop(
502
+ candidate.id,
503
+ user_id,
504
+ reason=reason,
505
+ entity=slot.entity,
506
+ attribute=slot.attribute,
507
+ )
499
508
  return EvolutionWrite(
500
509
  applied_operation=EvolutionOperation.NOOP,
501
510
  outcome=EvolutionOutcome.NOOP,
@@ -543,7 +552,13 @@ class TrustEngine:
543
552
  speakers = list(candidate.corroborated_by)
544
553
  if incoming_speaker in speakers:
545
554
  reason = "same_speaker_same_value"
546
- await self._log_noop(user_id, reason=reason)
555
+ await self._log_noop(
556
+ candidate.id,
557
+ user_id,
558
+ reason=reason,
559
+ entity=slot.entity,
560
+ attribute=slot.attribute,
561
+ )
547
562
  return EvolutionWrite(
548
563
  applied_operation=EvolutionOperation.NOOP,
549
564
  outcome=EvolutionOutcome.NOOP,
@@ -768,17 +783,26 @@ class TrustEngine:
768
783
 
769
784
  async def _log_noop(
770
785
  self,
786
+ memory_id: str,
771
787
  user_id: str | None,
772
788
  *,
773
789
  reason: str,
790
+ entity: str | None,
791
+ attribute: str | None,
774
792
  ) -> None:
775
- """Append a content-free NOOP audit without touching memory state."""
793
+ """Append scoped, content-free NOOP lineage without mutating memory."""
776
794
  if self.audit is not None:
795
+ details: dict[str, object] = {
796
+ "operation": EvolutionOperation.NOOP.value,
797
+ "reason": reason,
798
+ }
799
+ slot = canonicalize_slot(entity, attribute)
800
+ if slot is not None:
801
+ details["entity"] = slot.entity
802
+ details["attribute"] = slot.attribute
777
803
  await self.audit.log(
778
804
  operation="NOOP",
805
+ memory_id=memory_id,
779
806
  user_id=user_id,
780
- details={
781
- "operation": EvolutionOperation.NOOP.value,
782
- "reason": reason,
783
- },
807
+ details=details,
784
808
  )
@@ -12,6 +12,7 @@ from contextdb.core.models import MemoryConsistencyToken, MemoryStatus
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from contextlib import AbstractAsyncContextManager
15
+ from datetime import datetime
15
16
 
16
17
  from contextdb.core.models import MemoryItem, MemoryType
17
18
 
@@ -54,11 +55,15 @@ class BaseStore(ABC):
54
55
  top_k: int = 10,
55
56
  filters: dict[str, object] | None = None,
56
57
  user_id: str | None = None,
58
+ *,
59
+ copy_items: bool = True,
57
60
  ) -> list[MemoryItem]:
58
61
  """Return top-k most similar memories by cosine similarity.
59
62
 
60
63
  ``user_id`` filters the result. A store constructed with a fixed
61
64
  user scope ignores a conflicting per-call value and stays scoped.
65
+ ``copy_items=False`` is reserved for internal read-only ranking;
66
+ public callers receive detached items by default.
62
67
  """
63
68
 
64
69
  @abstractmethod
@@ -148,16 +153,31 @@ class BaseStore(ABC):
148
153
  entity_keys: list[str],
149
154
  status: MemoryStatus | None = MemoryStatus.ACTIVE,
150
155
  user_id: str | None = None,
156
+ *,
157
+ exclude_ids: set[str] | None = None,
158
+ valid_at: datetime | None = None,
159
+ limit: int | None = None,
151
160
  ) -> list[MemoryItem]:
161
+ """List scoped entity memories with optional bounded composition filters."""
162
+
163
+ if limit is not None and limit <= 0:
164
+ return []
165
+ excluded = exclude_ids or set()
152
166
  items: list[MemoryItem] = []
153
167
  for entity_key in dict.fromkeys(entity_keys):
154
- items.extend(
155
- await self.list_by_entity(
156
- entity_key,
157
- status=status,
158
- user_id=user_id,
159
- )
168
+ candidates = await self.list_by_entity(
169
+ entity_key,
170
+ status=status,
171
+ user_id=user_id,
160
172
  )
173
+ for item in candidates:
174
+ if item.id in excluded:
175
+ continue
176
+ if valid_at is not None and not item.is_valid_at(valid_at):
177
+ continue
178
+ items.append(item)
179
+ if limit is not None and len(items) >= limit:
180
+ return items
161
181
  return items
162
182
 
163
183
  async def list_pending_consolidation(self, limit: int = 100) -> list[MemoryItem]:
@@ -940,6 +940,8 @@ class PostgresStore(BaseStore):
940
940
  top_k: int = 10,
941
941
  filters: dict[str, object] | None = None,
942
942
  user_id: str | None = None,
943
+ *,
944
+ copy_items: bool = True,
943
945
  ) -> list[MemoryItem]:
944
946
  index = await self._ensure_index()
945
947
  query = np.asarray(embedding, dtype=np.float32)
@@ -968,7 +970,7 @@ class PostgresStore(BaseStore):
968
970
  continue
969
971
  if filters and not _passes_filters(item, filters):
970
972
  continue
971
- results.append(item.model_copy(deep=True))
973
+ results.append(item.model_copy(deep=True) if copy_items else item)
972
974
  if len(results) >= top_k:
973
975
  break
974
976
  return results
@@ -1047,22 +1049,40 @@ class PostgresStore(BaseStore):
1047
1049
  entity_keys: list[str],
1048
1050
  status: MemoryStatus | None = MemoryStatus.ACTIVE,
1049
1051
  user_id: str | None = None,
1052
+ *,
1053
+ exclude_ids: set[str] | None = None,
1054
+ valid_at: datetime | None = None,
1055
+ limit: int | None = None,
1050
1056
  ) -> list[MemoryItem]:
1051
1057
  if status != MemoryStatus.ACTIVE:
1052
1058
  return await super().list_by_entities(
1053
1059
  entity_keys,
1054
1060
  status=status,
1055
1061
  user_id=user_id,
1062
+ exclude_ids=exclude_ids,
1063
+ valid_at=valid_at,
1064
+ limit=limit,
1056
1065
  )
1066
+ if limit is not None and limit <= 0:
1067
+ return []
1057
1068
  if not self._index_loaded:
1058
1069
  await self._ensure_index()
1059
- keys = set(entity_keys)
1060
- return [
1061
- item.model_copy(deep=True)
1062
- for item in self._index_items.values()
1063
- if item.entity_key in keys
1064
- and self._item_scope_allows(item, user_id)
1065
- ]
1070
+ excluded = exclude_ids or set()
1071
+ items: list[MemoryItem] = []
1072
+ for entity_key in dict.fromkeys(entity_keys):
1073
+ for item in self._index_items.values():
1074
+ if item.entity_key != entity_key:
1075
+ continue
1076
+ if not self._item_scope_allows(item, user_id):
1077
+ continue
1078
+ if item.id in excluded:
1079
+ continue
1080
+ if valid_at is not None and not item.is_valid_at(valid_at):
1081
+ continue
1082
+ items.append(item.model_copy(deep=True))
1083
+ if limit is not None and len(items) >= limit:
1084
+ return items
1085
+ return items
1066
1086
 
1067
1087
  async def list_pending_consolidation(self, limit: int = 100) -> list[MemoryItem]:
1068
1088
  sql = (
@@ -821,6 +821,8 @@ class SQLiteStore(BaseStore):
821
821
  top_k: int = 10,
822
822
  filters: dict[str, object] | None = None,
823
823
  user_id: str | None = None,
824
+ *,
825
+ copy_items: bool = True,
824
826
  ) -> list[MemoryItem]:
825
827
  conn = self._require_conn()
826
828
  index = await self._ensure_index()
@@ -33,9 +33,10 @@ canonicalized, and injection-shaped content is demoted.
33
33
 
34
34
  For an empty slot, ADD stores a new memory. If the same speaker repeats the
35
35
  same canonical slot value, the applied operation is NOOP: no row is inserted
36
- or updated and the memory version does not advance. The audit entry contains
37
- only the fixed operation and reason. A new independent speaker asserting the
38
- same value corroborates the current memory and applies as UPDATE.
36
+ or updated and the memory version does not advance. The audit entry points to
37
+ the existing memory and contains only the fixed operation and reason plus its
38
+ canonical entity and attribute identifiers. A new independent speaker
39
+ asserting the same value corroborates the current memory and applies as UPDATE.
39
40
 
40
41
  ADD fails with `EvolutionOperationConflictError` when the slot already holds a
41
42
  different value. Use UPDATE to correct an occupied slot.
@@ -83,9 +84,19 @@ the broader `forget_user()` and `verify_forgotten()` contracts.
83
84
 
84
85
  ## NOOP
85
86
 
86
- NOOP requires a nonempty reason of at most 256 characters. Optional target or
87
- slot references are scope-checked. The reason is passed through the configured
88
- PII processor before the content-free `NOOP` audit entry is appended.
87
+ NOOP requires a nonempty reason of at most 256 characters. An optional
88
+ `target_memory_id` must resolve to a current memory in the caller's scope. A
89
+ slot-only reference resolves only when exactly one current occupant exists;
90
+ an ambiguous contested slot raises `EvolutionOperationConflictError` and
91
+ requires an explicit target. The resolved memory is returned and its ID is
92
+ attached to the audit entry. A NOOP without a target or slot remains valid,
93
+ returns no memory, and has no audit `memory_id`.
94
+
95
+ The reason is passed through the configured PII processor and bounded to 256
96
+ characters before the content-free `NOOP` audit entry is appended. When a
97
+ memory resolves to a slot, the details include only the operation, processed
98
+ reason, and canonical entity and attribute identifiers. They never include
99
+ memory content, old or new values, raw user IDs, or PII annotation originals.
89
100
 
90
101
  NOOP does not change memory rows, vectors, or the memory version. Its result
91
102
  token therefore reports the same memory version visible before the operation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontextdb"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "The unified context layer for AI agents — memory you can act on without treating wishes as facts"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"