pycontextdb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. contextdb/__init__.py +72 -0
  2. contextdb/agents/__init__.py +8 -0
  3. contextdb/agents/memory_bus.py +80 -0
  4. contextdb/agents/rl_manager.py +89 -0
  5. contextdb/cli.py +107 -0
  6. contextdb/client.py +516 -0
  7. contextdb/core/__init__.py +41 -0
  8. contextdb/core/config.py +89 -0
  9. contextdb/core/exceptions.py +29 -0
  10. contextdb/core/models.py +151 -0
  11. contextdb/dynamics/__init__.py +25 -0
  12. contextdb/dynamics/evolution.py +168 -0
  13. contextdb/dynamics/formation.py +193 -0
  14. contextdb/dynamics/retrieval.py +130 -0
  15. contextdb/graphs/__init__.py +17 -0
  16. contextdb/graphs/base.py +46 -0
  17. contextdb/graphs/causal.py +224 -0
  18. contextdb/graphs/entity.py +251 -0
  19. contextdb/graphs/semantic.py +156 -0
  20. contextdb/graphs/temporal.py +173 -0
  21. contextdb/integrations/__init__.py +10 -0
  22. contextdb/integrations/autogen.py +39 -0
  23. contextdb/integrations/crewai.py +41 -0
  24. contextdb/integrations/langchain.py +132 -0
  25. contextdb/integrations/openai_tools.py +124 -0
  26. contextdb/memory/__init__.py +9 -0
  27. contextdb/memory/experiential.py +102 -0
  28. contextdb/memory/factual.py +58 -0
  29. contextdb/memory/working.py +90 -0
  30. contextdb/privacy/__init__.py +9 -0
  31. contextdb/privacy/audit.py +199 -0
  32. contextdb/privacy/pii_detector.py +173 -0
  33. contextdb/privacy/retention.py +99 -0
  34. contextdb/py.typed +0 -0
  35. contextdb/store/__init__.py +15 -0
  36. contextdb/store/base.py +67 -0
  37. contextdb/store/sqlite_store.py +517 -0
  38. contextdb/store/vector_index.py +241 -0
  39. contextdb/utils/__init__.py +22 -0
  40. contextdb/utils/embeddings.py +159 -0
  41. contextdb/utils/llm.py +139 -0
  42. contextdb/utils/migrations.py +159 -0
  43. pycontextdb-0.1.0.dist-info/METADATA +589 -0
  44. pycontextdb-0.1.0.dist-info/RECORD +47 -0
  45. pycontextdb-0.1.0.dist-info/WHEEL +4 -0
  46. pycontextdb-0.1.0.dist-info/entry_points.txt +2 -0
  47. pycontextdb-0.1.0.dist-info/licenses/LICENSE +190 -0
contextdb/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """ContextDB — The unified context layer for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from contextdb.client import ContextDB
8
+ from contextdb.core.config import ContextDBConfig
9
+ from contextdb.core.exceptions import (
10
+ ConfigError,
11
+ ContextDBError,
12
+ MemoryNotFoundError,
13
+ PrivacyError,
14
+ StorageError,
15
+ )
16
+ from contextdb.core.models import (
17
+ Edge,
18
+ Entity,
19
+ MemoryItem,
20
+ MemoryStatus,
21
+ MemoryType,
22
+ PIIAnnotation,
23
+ PIIType,
24
+ RetentionPolicy,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "ConfigError",
31
+ "ContextDB",
32
+ "ContextDBConfig",
33
+ "ContextDBError",
34
+ "Edge",
35
+ "Entity",
36
+ "MemoryItem",
37
+ "MemoryNotFoundError",
38
+ "MemoryStatus",
39
+ "MemoryType",
40
+ "PIIAnnotation",
41
+ "PIIType",
42
+ "PrivacyError",
43
+ "RetentionPolicy",
44
+ "StorageError",
45
+ "__version__",
46
+ "init",
47
+ ]
48
+
49
+
50
+ def init(
51
+ user_id: str | None = None,
52
+ config: ContextDBConfig | None = None,
53
+ **kwargs: Any,
54
+ ) -> ContextDB:
55
+ """Create a :class:`ContextDB` client.
56
+
57
+ The client is lazy — resources are provisioned on the first ``await`` on
58
+ any I/O method, so ``init()`` itself does not touch the disk or network.
59
+
60
+ Args:
61
+ user_id: Optional user scope. Every write carries this as a filter.
62
+ config: Pre-built configuration. When ``None`` one is constructed
63
+ from ``kwargs`` and environment variables prefixed with
64
+ ``CONTEXTDB_``.
65
+ **kwargs: Forwarded to :class:`ContextDBConfig` if ``config`` is
66
+ not provided.
67
+
68
+ Returns:
69
+ A fully-configured :class:`ContextDB` client.
70
+ """
71
+ resolved = config or ContextDBConfig(**kwargs)
72
+ return ContextDB(resolved, user_id=user_id)
@@ -0,0 +1,8 @@
1
+ """Multi-agent primitives: memory bus, RL-driven memory manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextdb.agents.memory_bus import MemoryBus
6
+ from contextdb.agents.rl_manager import RLMemoryManager
7
+
8
+ __all__ = ["MemoryBus", "RLMemoryManager"]
@@ -0,0 +1,80 @@
1
+ """In-process pub/sub bus for multi-agent memory sharing.
2
+
3
+ The bus is deliberately minimal: ``publish`` + ``subscribe`` with optional
4
+ filters. Agents that share a :class:`MemoryBus` can broadcast new memories,
5
+ events, or reflections to peers without coupling directly to each other.
6
+
7
+ For cross-process sharing, swap the backing queue for Redis or NATS — the
8
+ :class:`MemoryBus` contract stays identical. That's out of scope for v0.1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+ from uuid import uuid4
18
+
19
+
20
+ @dataclass
21
+ class _Subscription:
22
+ id: str
23
+ topic: str
24
+ callback: Callable[[dict[str, Any]], Awaitable[None]]
25
+ filters: dict[str, Any] = field(default_factory=dict)
26
+
27
+
28
+ class MemoryBus:
29
+ """Async, in-process pub/sub across cooperating agents."""
30
+
31
+ def __init__(self) -> None:
32
+ self._subscriptions: dict[str, list[_Subscription]] = {}
33
+ self._lock = asyncio.Lock()
34
+
35
+ async def publish(self, topic: str, payload: dict[str, Any]) -> int:
36
+ """Deliver ``payload`` to all matching subscribers. Returns delivery count."""
37
+ async with self._lock:
38
+ subs = list(self._subscriptions.get(topic, []))
39
+ wildcard = list(self._subscriptions.get("*", []))
40
+ delivered = 0
41
+ for sub in subs + wildcard:
42
+ if not _matches_filters(payload, sub.filters):
43
+ continue
44
+ try:
45
+ await sub.callback(payload)
46
+ delivered += 1
47
+ except Exception: # noqa: BLE001
48
+ # Subscriber failure must not break the bus.
49
+ continue
50
+ return delivered
51
+
52
+ async def subscribe(
53
+ self,
54
+ topic: str,
55
+ callback: Callable[[dict[str, Any]], Awaitable[None]],
56
+ filters: dict[str, Any] | None = None,
57
+ ) -> str:
58
+ sub = _Subscription(
59
+ id=str(uuid4()), topic=topic, callback=callback, filters=filters or {}
60
+ )
61
+ async with self._lock:
62
+ self._subscriptions.setdefault(topic, []).append(sub)
63
+ return sub.id
64
+
65
+ async def unsubscribe(self, subscription_id: str) -> bool:
66
+ async with self._lock:
67
+ for topic, subs in self._subscriptions.items():
68
+ for i, sub in enumerate(subs):
69
+ if sub.id == subscription_id:
70
+ del self._subscriptions[topic][i]
71
+ return True
72
+ return False
73
+
74
+ async def topics(self) -> list[str]:
75
+ async with self._lock:
76
+ return [t for t, subs in self._subscriptions.items() if subs]
77
+
78
+
79
+ def _matches_filters(payload: dict[str, Any], filters: dict[str, Any]) -> bool:
80
+ return all(payload.get(key) == expected for key, expected in filters.items())
@@ -0,0 +1,89 @@
1
+ """RL-guided memory manager — decide ADD / UPDATE / DELETE / NOOP per write.
2
+
3
+ In v0.1 we ship the inference side of the RL manager: a policy prompt that
4
+ asks the LLM to pick one of four actions for a new piece of content given
5
+ the current top-20 candidate memories. This mirrors Memory-R1's action
6
+ space (Yu et al., 2024) and gives us a drop-in surface for swapping in a
7
+ trained model later without reshuffling the client.
8
+
9
+ Training (PPO over trajectories of ADD/UPDATE/DELETE/NOOP outcomes) lives
10
+ in a separate research harness — not shipped with the library.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from contextdb.core.models import MemoryItem
20
+ from contextdb.utils.llm import LLMProvider
21
+
22
+
23
+ _POLICY_PROMPT = """You are a memory manager. Given new content and the top
24
+ candidate existing memories, choose exactly one action and return strict JSON.
25
+
26
+ Actions:
27
+ - "ADD" — store the content as a new memory.
28
+ - "UPDATE" — merge into an existing memory; must include target_memory_id and merged content.
29
+ - "DELETE" — discard an existing memory superseded by this content; must include target_memory_id.
30
+ - "NOOP" — the content is redundant; do nothing.
31
+
32
+ Schema:
33
+ {"action": "ADD|UPDATE|DELETE|NOOP",
34
+ "target_memory_id": "string|null",
35
+ "content": "string|null",
36
+ "reasoning": "string"}
37
+
38
+ New content: "{content}"
39
+
40
+ Candidate memories (id :: content):
41
+ {candidates}
42
+ """
43
+
44
+
45
+ def _safe_json(text: str) -> dict[str, Any]:
46
+ text = text.strip()
47
+ if text.startswith("```"):
48
+ lines = text.splitlines()
49
+ text = "\n".join(line for line in lines if not line.startswith("```"))
50
+ try:
51
+ loaded = json.loads(text)
52
+ return loaded if isinstance(loaded, dict) else {}
53
+ except json.JSONDecodeError:
54
+ start = text.find("{")
55
+ end = text.rfind("}")
56
+ if start != -1 and end != -1 and end > start:
57
+ try:
58
+ loaded = json.loads(text[start : end + 1])
59
+ return loaded if isinstance(loaded, dict) else {}
60
+ except json.JSONDecodeError:
61
+ return {}
62
+ return {}
63
+
64
+
65
+ class RLMemoryManager:
66
+ """LLM-driven inference-time policy over ADD/UPDATE/DELETE/NOOP."""
67
+
68
+ def __init__(self, llm: LLMProvider, max_candidates: int = 10) -> None:
69
+ self.llm = llm
70
+ self.max_candidates = max_candidates
71
+
72
+ async def decide(
73
+ self,
74
+ content: str,
75
+ candidates: list[MemoryItem],
76
+ ) -> dict[str, Any]:
77
+ cand_snippets = "\n".join(
78
+ f"- {m.id} :: {m.content[:200]}" for m in candidates[: self.max_candidates]
79
+ ) or "(none)"
80
+ prompt = _POLICY_PROMPT.replace("{content}", content).replace(
81
+ "{candidates}", cand_snippets
82
+ )
83
+ response = await self.llm.generate(prompt, temperature=0.0, max_tokens=400)
84
+ decision = _safe_json(response)
85
+ action = str(decision.get("action", "ADD")).upper()
86
+ if action not in {"ADD", "UPDATE", "DELETE", "NOOP"}:
87
+ action = "ADD"
88
+ decision["action"] = action
89
+ return decision
contextdb/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Tiny command-line interface for ContextDB.
2
+
3
+ The CLI is intentionally minimal — enough to add/search/stats/export/import
4
+ from a shell, but not a replacement for the Python SDK. We use argparse
5
+ rather than click/typer so the library doesn't grow another dependency.
6
+
7
+ Usage:
8
+ contextdb add "My birthday is March 5"
9
+ contextdb search "when is my birthday"
10
+ contextdb stats
11
+ contextdb export dump.json
12
+ contextdb import dump.json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from contextdb import init
25
+ from contextdb.core.models import MemoryType
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(prog="contextdb")
30
+ parser.add_argument("--storage-url", default=None, help="Override storage URL.")
31
+ parser.add_argument("--user-id", default=None, help="Optional user_id scope.")
32
+ sub = parser.add_subparsers(dest="command", required=True)
33
+
34
+ p_add = sub.add_parser("add", help="Add a memory.")
35
+ p_add.add_argument("content")
36
+ p_add.add_argument(
37
+ "--type",
38
+ choices=[t.value for t in MemoryType],
39
+ default=MemoryType.FACTUAL.value,
40
+ )
41
+ p_add.add_argument("--source", default="")
42
+
43
+ p_search = sub.add_parser("search", help="Search memories.")
44
+ p_search.add_argument("query")
45
+ p_search.add_argument("--top-k", type=int, default=5)
46
+
47
+ sub.add_parser("stats", help="Print store statistics.")
48
+
49
+ p_export = sub.add_parser("export", help="Export the store to JSON.")
50
+ p_export.add_argument("path")
51
+
52
+ p_import = sub.add_parser("import", help="Import a ContextDB JSON dump.")
53
+ p_import.add_argument("path")
54
+
55
+ return parser
56
+
57
+
58
+ async def _run(args: argparse.Namespace) -> int:
59
+ kwargs: dict[str, Any] = {}
60
+ if args.storage_url:
61
+ kwargs["storage_url"] = args.storage_url
62
+ client = init(user_id=args.user_id, **kwargs)
63
+ try:
64
+ if args.command == "add":
65
+ item = await client.add(
66
+ content=args.content,
67
+ memory_type=MemoryType(args.type),
68
+ source=args.source,
69
+ )
70
+ print(json.dumps({"id": item.id, "content": item.content}, indent=2))
71
+ elif args.command == "search":
72
+ hits = await client.search(args.query, top_k=args.top_k)
73
+ print(
74
+ json.dumps(
75
+ [{"id": m.id, "content": m.content} for m in hits], indent=2
76
+ )
77
+ )
78
+ elif args.command == "stats":
79
+ stats = await client.stats()
80
+ print(json.dumps(stats, indent=2, default=str))
81
+ elif args.command == "export":
82
+ from contextdb.utils.migrations import JSONExporter
83
+
84
+ exporter = JSONExporter(client)
85
+ count = await exporter.export(Path(args.path))
86
+ print(f"Exported {count} memories to {args.path}")
87
+ elif args.command == "import":
88
+ from contextdb.utils.migrations import JSONImporter
89
+
90
+ importer = JSONImporter(client)
91
+ count = await importer.import_path(Path(args.path))
92
+ print(f"Imported {count} memories from {args.path}")
93
+ else:
94
+ return 1
95
+ return 0
96
+ finally:
97
+ await client.close()
98
+
99
+
100
+ def main(argv: list[str] | None = None) -> int:
101
+ parser = _build_parser()
102
+ args = parser.parse_args(argv)
103
+ return asyncio.run(_run(args))
104
+
105
+
106
+ if __name__ == "__main__": # pragma: no cover
107
+ sys.exit(main())