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.
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/CHANGELOG.md +14 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/PKG-INFO +1 -1
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/__init__.py +1 -1
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/client.py +51 -19
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/retrieval.py +28 -16
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/trust.py +31 -7
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/base.py +26 -6
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/postgres_store.py +28 -8
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/sqlite_store.py +2 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/memory_evolution.md +17 -6
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/pyproject.toml +1 -1
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_memory_evolution.py +184 -1
- pycontextdb-0.4.2/tests/unit/test_pg_sql.py +228 -0
- pycontextdb-0.4.2/tests/unit/test_retrieval.py +84 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_store.py +39 -2
- pycontextdb-0.4.0/tests/unit/test_pg_sql.py +0 -94
- pycontextdb-0.4.0/tests/unit/test_retrieval.py +0 -30
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/CODEOWNERS +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/copilot-instructions.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/pull_request_template.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/workflows/ci.yml +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.github/workflows/publish.yml +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.gitignore +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/.pre-commit-config.yaml +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/COMMERCIAL.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/CONTRIBUTING.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/LICENSE +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/NOTICE +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/OPEN_CORE.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/README.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/.cursorrules +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/CLAUDE.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/README.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/llms-full.txt +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/ai-tools/llms.txt +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/README.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/architecture.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/benchmarks.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/hero.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/performance.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/problem.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/assets/quickstart.png +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/run_benchmarks.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/benchmarks/trust_bakeoff.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/memory_bus.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/agents/rl_manager.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/cli.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/base.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/connectors/postgres.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/clock.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/config.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/exceptions.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/models.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/policy.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/core/slots.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/evolution.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/formation.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/dynamics/salience.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/base.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/causal.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/entity.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/semantic.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/graphs/temporal.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/act.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/autogen.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/crewai.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/langchain.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/livekit.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/openai_tools.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/pipecat.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/integrations/prompting.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/mcp.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/experiential.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/factual.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/memory/working.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/pool.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/audit.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/injection.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/pii_detector.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/privacy/retention.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/py.typed +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/serve.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/factory.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/pg_sql.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/store/vector_index.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/embeddings.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/llm.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/contextdb/utils/migrations.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/api.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/architecture.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/connectors.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/index.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/migrations.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/multi_tenant.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/postgres.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/prompts.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/quickstart.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/serve.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/docs/trust_policy.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/customer_support.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/multi_agent_team.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/phone_agent.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/examples/research_assistant.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/CLA-entity.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/CLA-individual.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/legal/signed/README.md +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/mkdocs.yml +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/scripts/check_open_core_boundary.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/scripts/check_release.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/conftest.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/evals/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/evals/test_trust_model.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/test_client.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/integration/test_langchain_integration.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/pg_util.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/test_version.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/__init__.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_audit.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_config.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_connectors.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_decision_scope.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_embeddings.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_evolution.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_formation.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_graphs.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_host_api.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_init.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_memory_bus.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_models.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_open_core_boundary.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_pii_detector.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_concurrency.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_connector.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_postgres_memory_evolution.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_retention.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_scoped_id_access.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_serve.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_trust_policy.py +0 -0
- {pycontextdb-0.4.0 → pycontextdb-0.4.2}/tests/unit/test_vector_index.py +0 -0
- {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.
|
|
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
|
|
@@ -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=
|
|
906
|
+
require_current=True,
|
|
894
907
|
)
|
|
895
908
|
|
|
896
909
|
slot = canonicalize_slot(entity, attribute)
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
for item in self._index_items.values()
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
|
37
|
-
only the fixed operation and reason
|
|
38
|
-
|
|
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.
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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"
|