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.
- contextdb/__init__.py +72 -0
- contextdb/agents/__init__.py +8 -0
- contextdb/agents/memory_bus.py +80 -0
- contextdb/agents/rl_manager.py +89 -0
- contextdb/cli.py +107 -0
- contextdb/client.py +516 -0
- contextdb/core/__init__.py +41 -0
- contextdb/core/config.py +89 -0
- contextdb/core/exceptions.py +29 -0
- contextdb/core/models.py +151 -0
- contextdb/dynamics/__init__.py +25 -0
- contextdb/dynamics/evolution.py +168 -0
- contextdb/dynamics/formation.py +193 -0
- contextdb/dynamics/retrieval.py +130 -0
- contextdb/graphs/__init__.py +17 -0
- contextdb/graphs/base.py +46 -0
- contextdb/graphs/causal.py +224 -0
- contextdb/graphs/entity.py +251 -0
- contextdb/graphs/semantic.py +156 -0
- contextdb/graphs/temporal.py +173 -0
- contextdb/integrations/__init__.py +10 -0
- contextdb/integrations/autogen.py +39 -0
- contextdb/integrations/crewai.py +41 -0
- contextdb/integrations/langchain.py +132 -0
- contextdb/integrations/openai_tools.py +124 -0
- contextdb/memory/__init__.py +9 -0
- contextdb/memory/experiential.py +102 -0
- contextdb/memory/factual.py +58 -0
- contextdb/memory/working.py +90 -0
- contextdb/privacy/__init__.py +9 -0
- contextdb/privacy/audit.py +199 -0
- contextdb/privacy/pii_detector.py +173 -0
- contextdb/privacy/retention.py +99 -0
- contextdb/py.typed +0 -0
- contextdb/store/__init__.py +15 -0
- contextdb/store/base.py +67 -0
- contextdb/store/sqlite_store.py +517 -0
- contextdb/store/vector_index.py +241 -0
- contextdb/utils/__init__.py +22 -0
- contextdb/utils/embeddings.py +159 -0
- contextdb/utils/llm.py +139 -0
- contextdb/utils/migrations.py +159 -0
- pycontextdb-0.1.0.dist-info/METADATA +589 -0
- pycontextdb-0.1.0.dist-info/RECORD +47 -0
- pycontextdb-0.1.0.dist-info/WHEEL +4 -0
- pycontextdb-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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())
|