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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Temporal graph — edges carry ordering and proximity in event-time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from contextdb.core.models import Edge, MemoryItem
|
|
10
|
+
from contextdb.graphs.base import BaseGraph
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from contextdb.store.sqlite_store import SQLiteStore
|
|
14
|
+
|
|
15
|
+
_SCHEMA = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS temporal_edges (
|
|
17
|
+
source_id TEXT NOT NULL,
|
|
18
|
+
target_id TEXT NOT NULL,
|
|
19
|
+
relation TEXT NOT NULL,
|
|
20
|
+
weight REAL NOT NULL,
|
|
21
|
+
time_diff_seconds REAL NOT NULL,
|
|
22
|
+
metadata TEXT DEFAULT '{}',
|
|
23
|
+
created_at TEXT NOT NULL,
|
|
24
|
+
PRIMARY KEY (source_id, target_id)
|
|
25
|
+
);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_temporal_source ON temporal_edges(source_id);
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TemporalGraph(BaseGraph):
|
|
31
|
+
"""Link memories that occur near each other in event-time."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
store: SQLiteStore,
|
|
36
|
+
proximity_window: timedelta = timedelta(hours=24),
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(store)
|
|
39
|
+
self.proximity_window = proximity_window
|
|
40
|
+
|
|
41
|
+
async def initialize(self) -> None:
|
|
42
|
+
conn = self.store._require_conn()
|
|
43
|
+
await conn.executescript(_SCHEMA)
|
|
44
|
+
await conn.commit()
|
|
45
|
+
|
|
46
|
+
async def add_node(self, memory_id: str, data: dict[str, Any]) -> None:
|
|
47
|
+
event_time = data.get("event_time")
|
|
48
|
+
if event_time is None:
|
|
49
|
+
return
|
|
50
|
+
nearby = await self._find_temporally_nearby(memory_id, event_time)
|
|
51
|
+
for other_id, other_time in nearby:
|
|
52
|
+
diff = (event_time - other_time).total_seconds()
|
|
53
|
+
if abs(diff) < 300:
|
|
54
|
+
relation = "CONCURRENT"
|
|
55
|
+
elif diff > 0:
|
|
56
|
+
relation = "AFTER"
|
|
57
|
+
else:
|
|
58
|
+
relation = "BEFORE"
|
|
59
|
+
weight = 1.0 / (1.0 + abs(diff) / 3600.0)
|
|
60
|
+
await self.add_edge(
|
|
61
|
+
Edge(
|
|
62
|
+
source_id=memory_id,
|
|
63
|
+
target_id=other_id,
|
|
64
|
+
graph_type="temporal",
|
|
65
|
+
weight=weight,
|
|
66
|
+
metadata={"relation": relation, "time_diff_seconds": diff},
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def _find_temporally_nearby(
|
|
71
|
+
self, memory_id: str, event_time: datetime
|
|
72
|
+
) -> list[tuple[str, datetime]]:
|
|
73
|
+
conn = self.store._require_conn()
|
|
74
|
+
window_start = (event_time - self.proximity_window).isoformat()
|
|
75
|
+
window_end = (event_time + self.proximity_window).isoformat()
|
|
76
|
+
cursor = await conn.execute(
|
|
77
|
+
"SELECT id, event_time FROM memories "
|
|
78
|
+
"WHERE id != ? AND event_time IS NOT NULL "
|
|
79
|
+
"AND event_time BETWEEN ? AND ? AND status = 'ACTIVE'",
|
|
80
|
+
(memory_id, window_start, window_end),
|
|
81
|
+
)
|
|
82
|
+
rows = await cursor.fetchall()
|
|
83
|
+
return [(row["id"], datetime.fromisoformat(row["event_time"])) for row in rows]
|
|
84
|
+
|
|
85
|
+
async def add_edge(self, edge: Edge) -> None:
|
|
86
|
+
conn = self.store._require_conn()
|
|
87
|
+
meta = edge.metadata or {}
|
|
88
|
+
await conn.execute(
|
|
89
|
+
"INSERT OR REPLACE INTO temporal_edges "
|
|
90
|
+
"(source_id, target_id, relation, weight, time_diff_seconds, metadata, created_at) "
|
|
91
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
92
|
+
(
|
|
93
|
+
edge.source_id,
|
|
94
|
+
edge.target_id,
|
|
95
|
+
str(meta.get("relation", "ADJACENT")),
|
|
96
|
+
edge.weight,
|
|
97
|
+
float(meta.get("time_diff_seconds", 0.0)),
|
|
98
|
+
json.dumps(meta),
|
|
99
|
+
edge.created_at.isoformat(),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
await conn.commit()
|
|
103
|
+
|
|
104
|
+
async def remove_node(self, memory_id: str) -> None:
|
|
105
|
+
conn = self.store._require_conn()
|
|
106
|
+
await conn.execute(
|
|
107
|
+
"DELETE FROM temporal_edges WHERE source_id = ? OR target_id = ?",
|
|
108
|
+
(memory_id, memory_id),
|
|
109
|
+
)
|
|
110
|
+
await conn.commit()
|
|
111
|
+
|
|
112
|
+
async def get_edges(self, memory_id: str) -> list[Edge]:
|
|
113
|
+
conn = self.store._require_conn()
|
|
114
|
+
cursor = await conn.execute(
|
|
115
|
+
"SELECT source_id, target_id, weight, metadata, created_at, relation, "
|
|
116
|
+
"time_diff_seconds FROM temporal_edges WHERE source_id = ?",
|
|
117
|
+
(memory_id,),
|
|
118
|
+
)
|
|
119
|
+
rows = await cursor.fetchall()
|
|
120
|
+
out: list[Edge] = []
|
|
121
|
+
for row in rows:
|
|
122
|
+
meta = json.loads(row["metadata"] or "{}")
|
|
123
|
+
meta.setdefault("relation", row["relation"])
|
|
124
|
+
meta.setdefault("time_diff_seconds", row["time_diff_seconds"])
|
|
125
|
+
out.append(
|
|
126
|
+
Edge(
|
|
127
|
+
source_id=row["source_id"],
|
|
128
|
+
target_id=row["target_id"],
|
|
129
|
+
graph_type="temporal",
|
|
130
|
+
weight=float(row["weight"]),
|
|
131
|
+
metadata=meta,
|
|
132
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
async def get_neighbors(
|
|
138
|
+
self,
|
|
139
|
+
memory_id: str,
|
|
140
|
+
depth: int = 1,
|
|
141
|
+
max_results: int = 20,
|
|
142
|
+
) -> list[tuple[str, float]]:
|
|
143
|
+
conn = self.store._require_conn()
|
|
144
|
+
cursor = await conn.execute(
|
|
145
|
+
"SELECT target_id, weight FROM temporal_edges WHERE source_id = ? "
|
|
146
|
+
"ORDER BY weight DESC LIMIT ?",
|
|
147
|
+
(memory_id, max_results),
|
|
148
|
+
)
|
|
149
|
+
rows = await cursor.fetchall()
|
|
150
|
+
return [(row["target_id"], float(row["weight"])) for row in rows]
|
|
151
|
+
|
|
152
|
+
async def get_timeline(
|
|
153
|
+
self,
|
|
154
|
+
entity: str | None = None,
|
|
155
|
+
start: datetime | None = None,
|
|
156
|
+
end: datetime | None = None,
|
|
157
|
+
) -> list[MemoryItem]:
|
|
158
|
+
store = self.store
|
|
159
|
+
items = await store.list_memories(limit=10000)
|
|
160
|
+
filtered = [m for m in items if m.event_time is not None]
|
|
161
|
+
if start is not None:
|
|
162
|
+
filtered = [m for m in filtered if m.event_time and m.event_time >= start]
|
|
163
|
+
if end is not None:
|
|
164
|
+
filtered = [m for m in filtered if m.event_time and m.event_time <= end]
|
|
165
|
+
if entity is not None:
|
|
166
|
+
ent = entity.lower()
|
|
167
|
+
filtered = [
|
|
168
|
+
m
|
|
169
|
+
for m in filtered
|
|
170
|
+
if ent in (e.lower() for e in m.entity_mentions) or ent in m.content.lower()
|
|
171
|
+
]
|
|
172
|
+
filtered.sort(key=lambda m: m.event_time or datetime.min.replace(tzinfo=timezone.utc))
|
|
173
|
+
return filtered
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""AutoGen memory adapter.
|
|
2
|
+
|
|
3
|
+
AutoGen passes strings around as agent "messages" and expects memory to
|
|
4
|
+
surface a list of relevant prior messages on demand. We wrap ContextDB to
|
|
5
|
+
match that shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from contextdb.client import ContextDB
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextDBAutoGenMemory:
|
|
17
|
+
"""AutoGen-style memory facade over ContextDB."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client: ContextDB, top_k: int = 5) -> None:
|
|
20
|
+
self.client = client
|
|
21
|
+
self.top_k = top_k
|
|
22
|
+
|
|
23
|
+
async def add_message(self, role: str, content: str) -> str:
|
|
24
|
+
item = await self.client.add(
|
|
25
|
+
content=f"{role}: {content}",
|
|
26
|
+
metadata={"role": role},
|
|
27
|
+
)
|
|
28
|
+
return item.id
|
|
29
|
+
|
|
30
|
+
async def get_relevant(self, query: str) -> list[str]:
|
|
31
|
+
hits = await self.client.search(query, top_k=self.top_k)
|
|
32
|
+
return [m.content for m in hits]
|
|
33
|
+
|
|
34
|
+
async def clear(self) -> None:
|
|
35
|
+
await self.client._ensure_init()
|
|
36
|
+
store = self.client._require_store()
|
|
37
|
+
memories = await store.list_memories(user_id=self.client.user_id, limit=100000)
|
|
38
|
+
for memory in memories:
|
|
39
|
+
await store.delete(memory.id, hard=True)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""CrewAI memory adapter.
|
|
2
|
+
|
|
3
|
+
CrewAI's memory contract is intentionally small (``save`` / ``search`` /
|
|
4
|
+
``reset``). We mirror those method names on a thin wrapper so that a
|
|
5
|
+
``Crew`` can consume a :class:`ContextDBCrewMemory` without importing
|
|
6
|
+
``crewai`` here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from contextdb.client import ContextDB
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextDBCrewMemory:
|
|
18
|
+
"""CrewAI-compatible memory wrapper backed by ContextDB."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: ContextDB, top_k: int = 5) -> None:
|
|
21
|
+
self.client = client
|
|
22
|
+
self.top_k = top_k
|
|
23
|
+
|
|
24
|
+
async def save(self, value: str, metadata: dict[str, Any] | None = None) -> str:
|
|
25
|
+
item = await self.client.add(content=value, metadata=metadata)
|
|
26
|
+
return item.id
|
|
27
|
+
|
|
28
|
+
async def search(self, query: str, limit: int | None = None) -> list[dict[str, Any]]:
|
|
29
|
+
top_k = limit if limit is not None else self.top_k
|
|
30
|
+
hits = await self.client.search(query, top_k=top_k)
|
|
31
|
+
return [
|
|
32
|
+
{"id": m.id, "content": m.content, "metadata": m.metadata}
|
|
33
|
+
for m in hits
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
async def reset(self) -> None:
|
|
37
|
+
await self.client._ensure_init()
|
|
38
|
+
store = self.client._require_store()
|
|
39
|
+
memories = await store.list_memories(user_id=self.client.user_id, limit=100000)
|
|
40
|
+
for memory in memories:
|
|
41
|
+
await store.delete(memory.id, hard=True)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""LangChain memory adapter.
|
|
2
|
+
|
|
3
|
+
Exposes :class:`ContextDBMemory`, a small class that conforms to LangChain's
|
|
4
|
+
conversational memory interface (``load_memory_variables`` /
|
|
5
|
+
``save_context``) backed by :class:`~contextdb.client.ContextDB`. We do
|
|
6
|
+
**not** import ``langchain`` here — keeping the dependency optional — and
|
|
7
|
+
instead structure the adapter so that LangChain's duck-typed interface is
|
|
8
|
+
met by method signatures alone.
|
|
9
|
+
|
|
10
|
+
Both sync and async entry points are provided. LangChain code paths still
|
|
11
|
+
exist that call memories synchronously, so the sync wrappers bridge to the
|
|
12
|
+
async client via :func:`asyncio.run` / ``run_until_complete``. The async
|
|
13
|
+
methods (``aload_memory_variables`` / ``asave_context`` / ``aclear``) are
|
|
14
|
+
preferred whenever the caller is already inside an event loop.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from collections.abc import Awaitable
|
|
21
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from contextdb.client import ContextDB
|
|
25
|
+
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_sync(coro: Awaitable[_T]) -> _T:
|
|
30
|
+
"""Execute an awaitable from synchronous code.
|
|
31
|
+
|
|
32
|
+
Uses ``asyncio.run`` when no loop is running. When called from inside an
|
|
33
|
+
already-running loop (e.g. a Jupyter cell) we fall back to creating a
|
|
34
|
+
dedicated loop on a worker thread — ``asyncio.run`` refuses to nest, and
|
|
35
|
+
``run_until_complete`` on a running loop deadlocks. Callers inside a
|
|
36
|
+
loop should prefer the ``a*`` methods.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
running = asyncio.get_running_loop()
|
|
40
|
+
except RuntimeError:
|
|
41
|
+
running = None
|
|
42
|
+
if running is None:
|
|
43
|
+
return asyncio.run(coro) # type: ignore[arg-type]
|
|
44
|
+
|
|
45
|
+
import threading
|
|
46
|
+
|
|
47
|
+
result: list[_T] = []
|
|
48
|
+
error: list[BaseException] = []
|
|
49
|
+
|
|
50
|
+
def _worker() -> None:
|
|
51
|
+
loop = asyncio.new_event_loop()
|
|
52
|
+
try:
|
|
53
|
+
result.append(loop.run_until_complete(coro))
|
|
54
|
+
except BaseException as exc: # noqa: BLE001
|
|
55
|
+
error.append(exc)
|
|
56
|
+
finally:
|
|
57
|
+
loop.close()
|
|
58
|
+
|
|
59
|
+
thread = threading.Thread(target=_worker)
|
|
60
|
+
thread.start()
|
|
61
|
+
thread.join()
|
|
62
|
+
if error:
|
|
63
|
+
raise error[0]
|
|
64
|
+
return result[0]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ContextDBMemory:
|
|
68
|
+
"""LangChain-compatible memory backed by ContextDB.
|
|
69
|
+
|
|
70
|
+
Usage:
|
|
71
|
+
>>> from contextdb import init
|
|
72
|
+
>>> db = init()
|
|
73
|
+
>>> memory = ContextDBMemory(db, session_id="chat-1")
|
|
74
|
+
>>> chain = ConversationChain(llm=llm, memory=memory)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
memory_key: str = "history"
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
client: ContextDB,
|
|
82
|
+
session_id: str,
|
|
83
|
+
max_tokens: int = 2000,
|
|
84
|
+
top_k: int = 5,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.client = client
|
|
87
|
+
self.session_id = session_id
|
|
88
|
+
self.max_tokens = max_tokens
|
|
89
|
+
self.top_k = top_k
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def memory_variables(self) -> list[str]:
|
|
93
|
+
return [self.memory_key]
|
|
94
|
+
|
|
95
|
+
# -- Async (preferred) ------------------------------------------------- #
|
|
96
|
+
|
|
97
|
+
async def aload_memory_variables(self, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
query = str(inputs.get("input", "")) or str(next(iter(inputs.values()), ""))
|
|
99
|
+
hits = await self.client.search(query, top_k=self.top_k) if query else []
|
|
100
|
+
working = self.client.working(self.session_id, max_tokens=self.max_tokens)
|
|
101
|
+
window = await working.context_window()
|
|
102
|
+
history = "\n".join(m.content for m in hits)
|
|
103
|
+
return {self.memory_key: f"{history}\n{window}".strip()}
|
|
104
|
+
|
|
105
|
+
async def asave_context(
|
|
106
|
+
self, inputs: dict[str, Any], outputs: dict[str, Any]
|
|
107
|
+
) -> None:
|
|
108
|
+
working = self.client.working(self.session_id, max_tokens=self.max_tokens)
|
|
109
|
+
user_text = str(inputs.get("input", "")).strip()
|
|
110
|
+
bot_text = str(outputs.get("output", "")).strip()
|
|
111
|
+
if user_text:
|
|
112
|
+
await working.push(f"User: {user_text}")
|
|
113
|
+
if bot_text:
|
|
114
|
+
await working.push(f"Assistant: {bot_text}")
|
|
115
|
+
|
|
116
|
+
async def aclear(self) -> None:
|
|
117
|
+
working = self.client.working(self.session_id, max_tokens=self.max_tokens)
|
|
118
|
+
await working.clear()
|
|
119
|
+
|
|
120
|
+
# -- Sync (LangChain legacy paths) ------------------------------------- #
|
|
121
|
+
|
|
122
|
+
def load_memory_variables(self, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
123
|
+
"""Synchronous wrapper over :meth:`aload_memory_variables`."""
|
|
124
|
+
return _run_sync(self.aload_memory_variables(inputs))
|
|
125
|
+
|
|
126
|
+
def save_context(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> None:
|
|
127
|
+
"""Synchronous wrapper over :meth:`asave_context`."""
|
|
128
|
+
_run_sync(self.asave_context(inputs, outputs))
|
|
129
|
+
|
|
130
|
+
def clear(self) -> None:
|
|
131
|
+
"""Synchronous wrapper over :meth:`aclear`."""
|
|
132
|
+
_run_sync(self.aclear())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""OpenAI function-calling tool definitions for ContextDB.
|
|
2
|
+
|
|
3
|
+
Returns a list of JSON Schema tool specs that can be passed directly into
|
|
4
|
+
``openai.chat.completions.create(..., tools=[...])``. Each tool name maps to
|
|
5
|
+
a bound async callable returned by :func:`make_tool_handlers`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from contextdb.client import ContextDB
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def tool_schemas() -> list[dict[str, Any]]:
|
|
18
|
+
"""OpenAI tools JSON schema for ContextDB memory operations."""
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
"type": "function",
|
|
22
|
+
"function": {
|
|
23
|
+
"name": "memory_add",
|
|
24
|
+
"description": "Store a new fact, observation, or experience in long-term memory.",
|
|
25
|
+
"parameters": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {
|
|
28
|
+
"content": {"type": "string"},
|
|
29
|
+
"memory_type": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": ["FACTUAL", "EXPERIENTIAL", "WORKING"],
|
|
32
|
+
},
|
|
33
|
+
"entity_mentions": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {"type": "string"},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"required": ["content"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "function",
|
|
44
|
+
"function": {
|
|
45
|
+
"name": "memory_search",
|
|
46
|
+
"description": "Search long-term memory semantically and temporally.",
|
|
47
|
+
"parameters": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {
|
|
50
|
+
"query": {"type": "string"},
|
|
51
|
+
"top_k": {"type": "integer", "default": 5},
|
|
52
|
+
},
|
|
53
|
+
"required": ["query"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"type": "function",
|
|
59
|
+
"function": {
|
|
60
|
+
"name": "memory_get_entity",
|
|
61
|
+
"description": "Retrieve the profile and associated memories for a named entity.",
|
|
62
|
+
"parameters": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {"name": {"type": "string"}},
|
|
65
|
+
"required": ["name"],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {
|
|
72
|
+
"name": "memory_forget",
|
|
73
|
+
"description": "Bulk-delete memories by entity or age.",
|
|
74
|
+
"parameters": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"entity": {"type": "string"},
|
|
78
|
+
"older_than_days": {"type": "integer"},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
ToolHandler = Callable[[dict[str, Any]], Awaitable[Any]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_tool_handlers(client: ContextDB) -> dict[str, ToolHandler]:
|
|
90
|
+
"""Bind tool names to async callables executing against ``client``."""
|
|
91
|
+
from datetime import timedelta
|
|
92
|
+
|
|
93
|
+
from contextdb.core.models import MemoryType
|
|
94
|
+
|
|
95
|
+
async def _add(args: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
mt_value = args.get("memory_type", "FACTUAL")
|
|
97
|
+
mt = MemoryType(mt_value if isinstance(mt_value, str) else "FACTUAL")
|
|
98
|
+
item = await client.add(
|
|
99
|
+
content=args["content"],
|
|
100
|
+
memory_type=mt,
|
|
101
|
+
entity_mentions=args.get("entity_mentions"),
|
|
102
|
+
)
|
|
103
|
+
return {"id": item.id}
|
|
104
|
+
|
|
105
|
+
async def _search(args: dict[str, Any]) -> list[dict[str, Any]]:
|
|
106
|
+
hits = await client.search(args["query"], top_k=int(args.get("top_k", 5)))
|
|
107
|
+
return [{"id": m.id, "content": m.content} for m in hits]
|
|
108
|
+
|
|
109
|
+
async def _get_entity(args: dict[str, Any]) -> dict[str, Any]:
|
|
110
|
+
return await client.get_entity(args["name"])
|
|
111
|
+
|
|
112
|
+
async def _forget(args: dict[str, Any]) -> dict[str, int]:
|
|
113
|
+
older_than: timedelta | None = None
|
|
114
|
+
if "older_than_days" in args:
|
|
115
|
+
older_than = timedelta(days=int(args["older_than_days"]))
|
|
116
|
+
deleted = await client.forget(entity=args.get("entity"), older_than=older_than)
|
|
117
|
+
return {"deleted": deleted}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"memory_add": _add,
|
|
121
|
+
"memory_search": _search,
|
|
122
|
+
"memory_get_entity": _get_entity,
|
|
123
|
+
"memory_forget": _forget,
|
|
124
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Typed memory surfaces: factual, experiential, working."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextdb.memory.experiential import ExperientialMemory
|
|
6
|
+
from contextdb.memory.factual import FactualMemory
|
|
7
|
+
from contextdb.memory.working import WorkingMemory
|
|
8
|
+
|
|
9
|
+
__all__ = ["ExperientialMemory", "FactualMemory", "WorkingMemory"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Experiential memory — trajectories, reflections, outcomes.
|
|
2
|
+
|
|
3
|
+
Experiential memories capture *what happened* during an agent run: the
|
|
4
|
+
action taken, the outcome observed, and an optional post-hoc reflection.
|
|
5
|
+
They are the substrate behind "agent learns from its own rollouts" —
|
|
6
|
+
Memory-R1 and MAGMA both derive their gains from a well-organized
|
|
7
|
+
experiential store.
|
|
8
|
+
|
|
9
|
+
Two memories are written per trajectory:
|
|
10
|
+
|
|
11
|
+
* A :class:`~contextdb.core.models.MemoryType.EXPERIENTIAL` item holding the
|
|
12
|
+
structured trajectory (action / outcome / context).
|
|
13
|
+
* An optional linked reflection memory that the agent writes after the fact.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from contextdb.core.models import MemoryItem, MemoryType
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from contextdb.client import ContextDB
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExperientialMemory:
|
|
28
|
+
"""Trajectories (action+outcome) and reflections (post-hoc insights)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: ContextDB, user_id: str | None = None) -> None:
|
|
31
|
+
self.client = client
|
|
32
|
+
self.user_id = user_id
|
|
33
|
+
|
|
34
|
+
async def record_trajectory(
|
|
35
|
+
self,
|
|
36
|
+
action: str,
|
|
37
|
+
outcome: str,
|
|
38
|
+
context: dict[str, Any] | None = None,
|
|
39
|
+
success: bool | None = None,
|
|
40
|
+
) -> MemoryItem:
|
|
41
|
+
content = f"Action: {action}\nOutcome: {outcome}"
|
|
42
|
+
meta: dict[str, Any] = {
|
|
43
|
+
"action": action,
|
|
44
|
+
"outcome": outcome,
|
|
45
|
+
"context": context or {},
|
|
46
|
+
}
|
|
47
|
+
if success is not None:
|
|
48
|
+
meta["success"] = success
|
|
49
|
+
return await self.client.add(
|
|
50
|
+
content=content,
|
|
51
|
+
memory_type=MemoryType.EXPERIENTIAL,
|
|
52
|
+
metadata=meta,
|
|
53
|
+
event_time=datetime.now(tz=timezone.utc),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def add_reflection(
|
|
57
|
+
self,
|
|
58
|
+
trajectory_id: str,
|
|
59
|
+
insight: str,
|
|
60
|
+
metadata: dict[str, Any] | None = None,
|
|
61
|
+
) -> MemoryItem:
|
|
62
|
+
meta = dict(metadata or {})
|
|
63
|
+
meta["reflection_on"] = trajectory_id
|
|
64
|
+
return await self.client.add(
|
|
65
|
+
content=insight,
|
|
66
|
+
memory_type=MemoryType.EXPERIENTIAL,
|
|
67
|
+
metadata=meta,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def recall_similar(
|
|
71
|
+
self,
|
|
72
|
+
situation: str,
|
|
73
|
+
top_k: int = 5,
|
|
74
|
+
) -> list[MemoryItem]:
|
|
75
|
+
return await self.client.search(
|
|
76
|
+
situation, top_k=top_k, memory_type=MemoryType.EXPERIENTIAL
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def list_trajectories(self, limit: int = 100) -> list[MemoryItem]:
|
|
80
|
+
await self.client._ensure_init()
|
|
81
|
+
store = self.client._require_store()
|
|
82
|
+
items = await store.list_memories(
|
|
83
|
+
user_id=self.user_id, memory_type=MemoryType.EXPERIENTIAL, limit=limit
|
|
84
|
+
)
|
|
85
|
+
return [m for m in items if "reflection_on" not in m.metadata]
|
|
86
|
+
|
|
87
|
+
async def list_reflections(
|
|
88
|
+
self,
|
|
89
|
+
trajectory_id: str | None = None,
|
|
90
|
+
limit: int = 100,
|
|
91
|
+
) -> list[MemoryItem]:
|
|
92
|
+
await self.client._ensure_init()
|
|
93
|
+
store = self.client._require_store()
|
|
94
|
+
items = await store.list_memories(
|
|
95
|
+
user_id=self.user_id, memory_type=MemoryType.EXPERIENTIAL, limit=limit
|
|
96
|
+
)
|
|
97
|
+
reflections = [m for m in items if "reflection_on" in m.metadata]
|
|
98
|
+
if trajectory_id is not None:
|
|
99
|
+
reflections = [
|
|
100
|
+
m for m in reflections if m.metadata.get("reflection_on") == trajectory_id
|
|
101
|
+
]
|
|
102
|
+
return reflections
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Factual memory — durable statements of fact.
|
|
2
|
+
|
|
3
|
+
The simplest of the three memory surfaces: a typed filter over
|
|
4
|
+
:meth:`contextdb.client.ContextDB.add` / :meth:`~contextdb.client.ContextDB.search`
|
|
5
|
+
that forces ``memory_type=FACTUAL``. Convenience wrappers add a few
|
|
6
|
+
fact-specific affordances (``recall``, ``update_fact``, ``list_facts``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from contextdb.core.models import MemoryItem, MemoryType
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from contextdb.client import ContextDB
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FactualMemory:
|
|
20
|
+
"""Thin typed layer over the general-purpose client."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: ContextDB, user_id: str | None = None) -> None:
|
|
23
|
+
self.client = client
|
|
24
|
+
self.user_id = user_id
|
|
25
|
+
|
|
26
|
+
async def add(
|
|
27
|
+
self,
|
|
28
|
+
content: str,
|
|
29
|
+
metadata: dict[str, Any] | None = None,
|
|
30
|
+
entity_mentions: list[str] | None = None,
|
|
31
|
+
confidence: float = 1.0,
|
|
32
|
+
) -> MemoryItem:
|
|
33
|
+
meta = dict(metadata or {})
|
|
34
|
+
meta.setdefault("confidence", confidence)
|
|
35
|
+
return await self.client.add(
|
|
36
|
+
content=content,
|
|
37
|
+
memory_type=MemoryType.FACTUAL,
|
|
38
|
+
metadata=meta,
|
|
39
|
+
entity_mentions=entity_mentions,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def recall(self, query: str, top_k: int = 5) -> list[MemoryItem]:
|
|
43
|
+
return await self.client.search(query, top_k=top_k, memory_type=MemoryType.FACTUAL)
|
|
44
|
+
|
|
45
|
+
async def update_fact(
|
|
46
|
+
self,
|
|
47
|
+
memory_id: str,
|
|
48
|
+
content: str | None = None,
|
|
49
|
+
metadata: dict[str, Any] | None = None,
|
|
50
|
+
) -> MemoryItem:
|
|
51
|
+
return await self.client.update(memory_id, content=content, metadata=metadata)
|
|
52
|
+
|
|
53
|
+
async def list_facts(self, limit: int = 100) -> list[MemoryItem]:
|
|
54
|
+
await self.client._ensure_init()
|
|
55
|
+
store = self.client._require_store()
|
|
56
|
+
return await store.list_memories(
|
|
57
|
+
user_id=self.user_id, memory_type=MemoryType.FACTUAL, limit=limit
|
|
58
|
+
)
|