rootmemory 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.
- rootmemory/__init__.py +84 -0
- rootmemory/api/__init__.py +25 -0
- rootmemory/api/agents.py +38 -0
- rootmemory/api/beliefs.py +66 -0
- rootmemory/api/claims.py +87 -0
- rootmemory/api/deps.py +21 -0
- rootmemory/api/errors.py +36 -0
- rootmemory/api/graph.py +32 -0
- rootmemory/api/invalidation.py +47 -0
- rootmemory/api/observations.py +46 -0
- rootmemory/api/promotion.py +37 -0
- rootmemory/api/provenance.py +88 -0
- rootmemory/api/security.py +53 -0
- rootmemory/cli.py +96 -0
- rootmemory/client.py +219 -0
- rootmemory/config.py +64 -0
- rootmemory/db/__init__.py +0 -0
- rootmemory/db/base.py +42 -0
- rootmemory/db/migrations/env.py +59 -0
- rootmemory/db/migrations/script.py.mako +26 -0
- rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
- rootmemory/db/session.py +138 -0
- rootmemory/errors.py +27 -0
- rootmemory/integrations/__init__.py +16 -0
- rootmemory/integrations/async_client.py +268 -0
- rootmemory/integrations/langchain_tools.py +237 -0
- rootmemory/integrations/langgraph_memory.py +179 -0
- rootmemory/integrations/langgraph_store.py +402 -0
- rootmemory/logging_config.py +30 -0
- rootmemory/main.py +133 -0
- rootmemory/models/__init__.py +22 -0
- rootmemory/models/agent.py +30 -0
- rootmemory/models/audit.py +51 -0
- rootmemory/models/belief.py +39 -0
- rootmemory/models/claim.py +52 -0
- rootmemory/models/decision.py +38 -0
- rootmemory/models/edge.py +43 -0
- rootmemory/models/enums.py +104 -0
- rootmemory/models/node_ref.py +42 -0
- rootmemory/models/observation.py +51 -0
- rootmemory/py.typed +0 -0
- rootmemory/repositories/__init__.py +19 -0
- rootmemory/repositories/agent_repo.py +44 -0
- rootmemory/repositories/audit_repo.py +43 -0
- rootmemory/repositories/belief_repo.py +63 -0
- rootmemory/repositories/claim_repo.py +102 -0
- rootmemory/repositories/decision_repo.py +76 -0
- rootmemory/repositories/edge_repo.py +77 -0
- rootmemory/repositories/observation_repo.py +76 -0
- rootmemory/schemas/__init__.py +52 -0
- rootmemory/schemas/agent.py +26 -0
- rootmemory/schemas/belief.py +49 -0
- rootmemory/schemas/claim.py +54 -0
- rootmemory/schemas/common.py +27 -0
- rootmemory/schemas/observation.py +69 -0
- rootmemory/schemas/promotion.py +50 -0
- rootmemory/schemas/provenance.py +51 -0
- rootmemory/services/__init__.py +35 -0
- rootmemory/services/belief_service.py +161 -0
- rootmemory/services/contradiction_service.py +148 -0
- rootmemory/services/decision_service.py +65 -0
- rootmemory/services/graph_service.py +316 -0
- rootmemory/services/independence_service.py +159 -0
- rootmemory/services/invalidation_service.py +164 -0
- rootmemory/services/normalization_service.py +234 -0
- rootmemory/services/promotion_service.py +318 -0
- rootmemory/services/provenance_service.py +282 -0
- rootmemory/services/scoring_service.py +53 -0
- rootmemory/static/index.html +866 -0
- rootmemory-0.1.0.dist-info/METADATA +266 -0
- rootmemory-0.1.0.dist-info/RECORD +75 -0
- rootmemory-0.1.0.dist-info/WHEEL +5 -0
- rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
- rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
- rootmemory-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""A LangGraph ``BaseStore`` backed by RootMemory.
|
|
2
|
+
|
|
3
|
+
This is the drop-in for the long-term memory slot::
|
|
4
|
+
|
|
5
|
+
agent = create_react_agent(model, tools, store=RootMemoryStore(memory, agent_id))
|
|
6
|
+
|
|
7
|
+
Two things are worth understanding before using it.
|
|
8
|
+
|
|
9
|
+
**Which slot this is.** ``create_react_agent`` has two memory parameters.
|
|
10
|
+
``checkpointer=`` holds thread state - the message history of one conversation -
|
|
11
|
+
and this is *not* that; keep using ``MemorySaver`` or a Postgres checkpointer
|
|
12
|
+
there. ``store=`` is long-term memory shared across threads, and that is what
|
|
13
|
+
this fills.
|
|
14
|
+
|
|
15
|
+
**What the store interface can and cannot enforce.** ``BaseStore.put`` is a
|
|
16
|
+
plain key-value write with nowhere to say "here is what I read first". So this
|
|
17
|
+
adapter infers it: the store remembers every item it has served to the agent,
|
|
18
|
+
and cites those as the parents of whatever the agent writes next. An agent that
|
|
19
|
+
writes a conclusion after reading a colleague's note is therefore recorded as
|
|
20
|
+
deriving from that note, automatically, with no cooperation required.
|
|
21
|
+
|
|
22
|
+
That gives you provenance capture for free. It does not give you enforcement:
|
|
23
|
+
a store cannot stop a model from ignoring what it read. For that, use the tools
|
|
24
|
+
in ``langchain_tools`` (which make citation a required argument) or the node
|
|
25
|
+
wrapper in ``langgraph_memory``. The three are complementary - this one is the
|
|
26
|
+
lowest-effort way in.
|
|
27
|
+
|
|
28
|
+
Namespaces
|
|
29
|
+
----------
|
|
30
|
+
|
|
31
|
+
``("observations",)`` evidence from outside; writing here records a source
|
|
32
|
+
``("beliefs", <claim_key>)`` conclusions about one proposition
|
|
33
|
+
``("claims",)`` read-only view of claims, with the honest counts
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import uuid
|
|
40
|
+
from collections.abc import Iterable
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
from typing import TYPE_CHECKING, Any
|
|
43
|
+
|
|
44
|
+
from rootmemory.client import RootMemory
|
|
45
|
+
from rootmemory.db.session import get_session_factory
|
|
46
|
+
from rootmemory.errors import ImmutableRecordError, NodeNotFoundError, ProvenanceRequiredError
|
|
47
|
+
from rootmemory.models.node_ref import NodeRef
|
|
48
|
+
from rootmemory.services.promotion_service import PromotionPolicy
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
51
|
+
from langgraph.store.base import Item, Op, Result, SearchItem
|
|
52
|
+
|
|
53
|
+
OBSERVATIONS = "observations"
|
|
54
|
+
BELIEFS = "beliefs"
|
|
55
|
+
CLAIMS = "claims"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _require_langgraph() -> Any:
|
|
59
|
+
try:
|
|
60
|
+
from langgraph.store import base
|
|
61
|
+
except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
|
|
62
|
+
raise ModuleNotFoundError(
|
|
63
|
+
"The LangGraph store adapter needs langgraph. Install it with: "
|
|
64
|
+
'pip install "rootmemory[langgraph]"'
|
|
65
|
+
) from exc
|
|
66
|
+
return base
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _base_store_class() -> Any:
|
|
70
|
+
return _require_langgraph().BaseStore
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RootMemoryStore(_base_store_class()): # type: ignore[misc]
|
|
74
|
+
"""LangGraph long-term memory whose writes carry provenance.
|
|
75
|
+
|
|
76
|
+
``BaseStore`` only requires ``batch`` and ``abatch``; every convenience
|
|
77
|
+
method (``get``, ``put``, ``search``, ``aput`` ...) is built on those, so
|
|
78
|
+
implementing the pair makes the whole interface work.
|
|
79
|
+
|
|
80
|
+
One instance per agent: read tracking is per-instance, which is what lets a
|
|
81
|
+
write cite the reads that preceded it.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
#: How many recent reads may be cited by a single write.
|
|
85
|
+
max_cited_reads = 16
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
agent_id: uuid.UUID,
|
|
90
|
+
session_factory: Any = None,
|
|
91
|
+
policy: PromotionPolicy | None = None,
|
|
92
|
+
auto_cite_reads: bool = True,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.agent_id = agent_id
|
|
95
|
+
self._session_factory = session_factory or get_session_factory()
|
|
96
|
+
self._policy = policy
|
|
97
|
+
self._auto_cite_reads = auto_cite_reads
|
|
98
|
+
#: ids this agent has been shown, most recent last
|
|
99
|
+
self._reads: list[uuid.UUID] = []
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# BaseStore contract
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
|
106
|
+
base = _require_langgraph()
|
|
107
|
+
session = self._session_factory()
|
|
108
|
+
try:
|
|
109
|
+
memory = RootMemory(session, self._policy)
|
|
110
|
+
results = [self._apply(memory, op, base) for op in ops]
|
|
111
|
+
session.commit()
|
|
112
|
+
return results
|
|
113
|
+
except Exception:
|
|
114
|
+
session.rollback()
|
|
115
|
+
raise
|
|
116
|
+
finally:
|
|
117
|
+
session.close()
|
|
118
|
+
|
|
119
|
+
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
|
120
|
+
materialized = list(ops)
|
|
121
|
+
return await asyncio.to_thread(self.batch, materialized)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _apply(self, memory: RootMemory, op: Op, base: Any) -> Result:
|
|
126
|
+
if isinstance(op, base.GetOp):
|
|
127
|
+
return self._get(memory, op, base)
|
|
128
|
+
if isinstance(op, base.PutOp):
|
|
129
|
+
self._put(memory, op)
|
|
130
|
+
return None
|
|
131
|
+
if isinstance(op, base.SearchOp):
|
|
132
|
+
return self._search(memory, op, base)
|
|
133
|
+
if isinstance(op, base.ListNamespacesOp):
|
|
134
|
+
return self._namespaces(memory)
|
|
135
|
+
raise NotImplementedError(f"unsupported store operation: {type(op).__name__}")
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# reads
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _get(self, memory: RootMemory, op: Any, base: Any) -> Item | None:
|
|
142
|
+
node_id = _as_uuid(op.key)
|
|
143
|
+
if node_id is None:
|
|
144
|
+
return None
|
|
145
|
+
root = op.namespace[0] if op.namespace else ""
|
|
146
|
+
|
|
147
|
+
if root == OBSERVATIONS:
|
|
148
|
+
observation = memory.observations.get(node_id)
|
|
149
|
+
if observation is None:
|
|
150
|
+
return None
|
|
151
|
+
self._note_read(observation.id)
|
|
152
|
+
return _item(op.namespace, op.key, self._observation_value(observation), base)
|
|
153
|
+
|
|
154
|
+
if root == BELIEFS:
|
|
155
|
+
belief = memory.find_belief(node_id)
|
|
156
|
+
if belief is None:
|
|
157
|
+
return None
|
|
158
|
+
self._note_read(belief.id)
|
|
159
|
+
return _item(op.namespace, op.key, self._belief_value(memory, belief), base)
|
|
160
|
+
|
|
161
|
+
if root == CLAIMS:
|
|
162
|
+
claim = memory.claims.get(node_id)
|
|
163
|
+
if claim is None:
|
|
164
|
+
return None
|
|
165
|
+
return _item(op.namespace, op.key, self._claim_value(memory, claim.id), base)
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _search(self, memory: RootMemory, op: Any, base: Any) -> list[SearchItem]:
|
|
170
|
+
prefix = op.namespace_prefix
|
|
171
|
+
root = prefix[0] if prefix else ""
|
|
172
|
+
query = (op.query or "").lower()
|
|
173
|
+
items: list[SearchItem] = []
|
|
174
|
+
|
|
175
|
+
if root in ("", OBSERVATIONS):
|
|
176
|
+
for observation in memory.observations.list():
|
|
177
|
+
if query and query not in observation.content.lower():
|
|
178
|
+
continue
|
|
179
|
+
self._note_read(observation.id)
|
|
180
|
+
items.append(
|
|
181
|
+
_search_item(
|
|
182
|
+
(OBSERVATIONS,),
|
|
183
|
+
str(observation.id),
|
|
184
|
+
self._observation_value(observation),
|
|
185
|
+
base,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if root in ("", BELIEFS):
|
|
190
|
+
claim_key = prefix[1] if len(prefix) > 1 else None
|
|
191
|
+
for belief in memory.list_beliefs():
|
|
192
|
+
if claim_key and belief.normalized_claim_key != claim_key:
|
|
193
|
+
continue
|
|
194
|
+
if query and query not in belief.claim_text.lower():
|
|
195
|
+
continue
|
|
196
|
+
self._note_read(belief.id)
|
|
197
|
+
items.append(
|
|
198
|
+
_search_item(
|
|
199
|
+
(BELIEFS, belief.normalized_claim_key),
|
|
200
|
+
str(belief.id),
|
|
201
|
+
self._belief_value(memory, belief),
|
|
202
|
+
base,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if root in ("", CLAIMS):
|
|
207
|
+
for claim in memory.claims.list():
|
|
208
|
+
if query and query not in claim.canonical_text.lower():
|
|
209
|
+
continue
|
|
210
|
+
items.append(
|
|
211
|
+
_search_item(
|
|
212
|
+
(CLAIMS,), str(claim.id), self._claim_value(memory, claim.id), base
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return items[op.offset : op.offset + op.limit]
|
|
217
|
+
|
|
218
|
+
def _namespaces(self, memory: RootMemory) -> list[tuple[str, ...]]:
|
|
219
|
+
keys = {b.normalized_claim_key for b in memory.list_beliefs()}
|
|
220
|
+
return [(OBSERVATIONS,), (CLAIMS,), *[(BELIEFS, key) for key in sorted(keys)]]
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# writes
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def _put(self, memory: RootMemory, op: Any) -> None:
|
|
227
|
+
if op.value is None:
|
|
228
|
+
# Memory is append-only: nothing is ever deleted (spec section 46).
|
|
229
|
+
raise ImmutableRecordError(
|
|
230
|
+
"RootMemory does not delete. Retract the underlying source instead, "
|
|
231
|
+
"which downgrades what was built on it and leaves the history intact."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
namespace = op.namespace
|
|
235
|
+
root = namespace[0] if namespace else ""
|
|
236
|
+
value = dict(op.value)
|
|
237
|
+
|
|
238
|
+
if root == OBSERVATIONS:
|
|
239
|
+
observation = memory.create_observation(
|
|
240
|
+
source_type=str(value.get("source_type", "agent_input")),
|
|
241
|
+
content=str(value.get("content", "")),
|
|
242
|
+
source_actor=value.get("source_actor"),
|
|
243
|
+
source_uri=value.get("source_uri"),
|
|
244
|
+
created_by_agent_id=self.agent_id,
|
|
245
|
+
reliability_score=float(value.get("reliability_score", 1.0)),
|
|
246
|
+
source_family_id=value.get("source_family_id"),
|
|
247
|
+
)
|
|
248
|
+
self._note_read(observation.id)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
if root == BELIEFS:
|
|
252
|
+
claim_key = namespace[1] if len(namespace) > 1 else str(value.get("claim_key", ""))
|
|
253
|
+
if not claim_key:
|
|
254
|
+
raise ProvenanceRequiredError(
|
|
255
|
+
"a belief needs a claim key: write to ('beliefs', '<claim_key>')"
|
|
256
|
+
)
|
|
257
|
+
parents = self._resolve_parents(memory, value)
|
|
258
|
+
if not parents:
|
|
259
|
+
raise ProvenanceRequiredError(
|
|
260
|
+
"nothing to cite: this agent has not read any evidence, and no "
|
|
261
|
+
"'derived_from' was supplied. Read from the store first, or pass "
|
|
262
|
+
"derived_from explicitly."
|
|
263
|
+
)
|
|
264
|
+
belief = memory.create_belief(
|
|
265
|
+
agent_id=self.agent_id,
|
|
266
|
+
claim_text=str(value.get("content", value.get("text", ""))),
|
|
267
|
+
claim_key=claim_key,
|
|
268
|
+
confidence=float(value.get("confidence", 0.5)),
|
|
269
|
+
derived_from=parents,
|
|
270
|
+
llm_generated=bool(value.get("llm_generated", True)),
|
|
271
|
+
meta={"store_key": op.key},
|
|
272
|
+
)
|
|
273
|
+
claim_text = str(value.get("claim_text", claim_key))
|
|
274
|
+
claim = memory.get_or_create_claim(claim_key, claim_text)
|
|
275
|
+
if str(value.get("stance", "support")) == "contradict":
|
|
276
|
+
memory.contradict_claim(claim.id, belief.id)
|
|
277
|
+
else:
|
|
278
|
+
memory.support_claim(claim.id, belief.id)
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
raise NotImplementedError(
|
|
282
|
+
f"writes to namespace {namespace!r} are not supported; use "
|
|
283
|
+
f"('{OBSERVATIONS}',) for evidence or ('{BELIEFS}', '<claim_key>') for a conclusion"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _resolve_parents(self, memory: RootMemory, value: dict) -> list[Any]:
|
|
287
|
+
"""What this write should cite.
|
|
288
|
+
|
|
289
|
+
An explicit ``derived_from`` always wins. Otherwise the agent cites what
|
|
290
|
+
it was actually shown, which is the honest reading of "where did this
|
|
291
|
+
come from".
|
|
292
|
+
"""
|
|
293
|
+
explicit = value.get("derived_from")
|
|
294
|
+
if explicit:
|
|
295
|
+
ids = [_as_uuid(str(x)) for x in explicit]
|
|
296
|
+
missing = [x for x, parsed in zip(explicit, ids, strict=True) if parsed is None]
|
|
297
|
+
if missing:
|
|
298
|
+
raise NodeNotFoundError(f"derived_from contains unusable ids: {missing}")
|
|
299
|
+
candidates = [i for i in ids if i is not None]
|
|
300
|
+
elif self._auto_cite_reads:
|
|
301
|
+
candidates = list(self._reads[-self.max_cited_reads :])
|
|
302
|
+
else:
|
|
303
|
+
candidates = []
|
|
304
|
+
|
|
305
|
+
parents: list[Any] = []
|
|
306
|
+
for node_id in candidates:
|
|
307
|
+
observation = memory.observations.get(node_id)
|
|
308
|
+
if observation is not None:
|
|
309
|
+
parents.append(observation)
|
|
310
|
+
continue
|
|
311
|
+
belief = memory.find_belief(node_id)
|
|
312
|
+
if belief is not None:
|
|
313
|
+
parents.append(belief)
|
|
314
|
+
elif explicit:
|
|
315
|
+
raise NodeNotFoundError(f"no observation or belief with id {node_id}")
|
|
316
|
+
return parents
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
# values handed back to the agent
|
|
320
|
+
# ------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def _observation_value(self, observation: Any) -> dict[str, Any]:
|
|
323
|
+
return {
|
|
324
|
+
"kind": "observation",
|
|
325
|
+
"content": observation.content,
|
|
326
|
+
"source_type": observation.source_type,
|
|
327
|
+
"source_actor": observation.source_actor,
|
|
328
|
+
"validity_status": observation.validity_status,
|
|
329
|
+
"reliability_score": observation.reliability_score,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
def _belief_value(self, memory: RootMemory, belief: Any) -> dict[str, Any]:
|
|
333
|
+
roots = memory.provenance.find_evidence_root_observations(NodeRef.belief(belief.id))
|
|
334
|
+
return {
|
|
335
|
+
"kind": "belief",
|
|
336
|
+
"content": belief.claim_text,
|
|
337
|
+
"claim_key": belief.normalized_claim_key,
|
|
338
|
+
"confidence": belief.confidence,
|
|
339
|
+
"status": belief.epistemic_status,
|
|
340
|
+
"held_by_agent_id": str(belief.agent_id),
|
|
341
|
+
# The agent is always told how much real evidence is underneath.
|
|
342
|
+
"independent_source_count": len({o.independence_key for o in roots}),
|
|
343
|
+
"evidence": [
|
|
344
|
+
{"source_type": o.source_type, "content": o.content, "status": o.validity_status}
|
|
345
|
+
for o in roots
|
|
346
|
+
],
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
def _claim_value(self, memory: RootMemory, claim_id: uuid.UUID) -> dict[str, Any]:
|
|
350
|
+
context = memory.get_claim_context(claim_id)
|
|
351
|
+
payload: dict[str, Any] = dict(context.as_dict())
|
|
352
|
+
support: dict[str, Any] = payload["support"]
|
|
353
|
+
payload["warning"] = (
|
|
354
|
+
"agreement between agents is not evidence; judge this on independent_sources"
|
|
355
|
+
)
|
|
356
|
+
if context.support.shares_single_source:
|
|
357
|
+
payload["false_corroboration"] = (
|
|
358
|
+
f"{support['belief_count']} beliefs from {support['agreeing_agents']} agents "
|
|
359
|
+
"all descend from one source"
|
|
360
|
+
)
|
|
361
|
+
return payload
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _note_read(self, node_id: uuid.UUID) -> None:
|
|
366
|
+
if node_id in self._reads:
|
|
367
|
+
self._reads.remove(node_id)
|
|
368
|
+
self._reads.append(node_id)
|
|
369
|
+
|
|
370
|
+
def forget_reads(self) -> None:
|
|
371
|
+
"""Clear the read history.
|
|
372
|
+
|
|
373
|
+
Call this between independent tasks so a new conclusion does not cite
|
|
374
|
+
evidence the agent looked at while doing something unrelated.
|
|
375
|
+
"""
|
|
376
|
+
self._reads.clear()
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def cited_reads(self) -> list[uuid.UUID]:
|
|
380
|
+
"""What the next write would cite, oldest first."""
|
|
381
|
+
return list(self._reads[-self.max_cited_reads :])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _item(namespace: tuple[str, ...], key: str, value: dict, base: Any) -> Item:
|
|
385
|
+
now = datetime.now(UTC)
|
|
386
|
+
return base.Item(
|
|
387
|
+
value=value, key=key, namespace=namespace, created_at=now, updated_at=now
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _search_item(namespace: tuple[str, ...], key: str, value: dict, base: Any) -> SearchItem:
|
|
392
|
+
now = datetime.now(UTC)
|
|
393
|
+
return base.SearchItem(
|
|
394
|
+
namespace=namespace, key=key, value=value, created_at=now, updated_at=now
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _as_uuid(value: str) -> uuid.UUID | None:
|
|
399
|
+
try:
|
|
400
|
+
return uuid.UUID(value)
|
|
401
|
+
except (ValueError, AttributeError, TypeError):
|
|
402
|
+
return None
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Structured logging setup (spec section 50)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_logging(level: str = "INFO") -> None:
|
|
12
|
+
"""Configure structlog + stdlib logging once at process start."""
|
|
13
|
+
logging.basicConfig(format="%(message)s", level=getattr(logging, level.upper(), logging.INFO))
|
|
14
|
+
structlog.configure(
|
|
15
|
+
processors=[
|
|
16
|
+
structlog.contextvars.merge_contextvars,
|
|
17
|
+
structlog.processors.add_log_level,
|
|
18
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
19
|
+
structlog.processors.JSONRenderer(),
|
|
20
|
+
],
|
|
21
|
+
wrapper_class=structlog.make_filtering_bound_logger(
|
|
22
|
+
getattr(logging, level.upper(), logging.INFO)
|
|
23
|
+
),
|
|
24
|
+
cache_logger_on_first_use=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_logger(name: str, **initial: Any) -> Any:
|
|
29
|
+
"""Return a bound structlog logger."""
|
|
30
|
+
return structlog.get_logger(name).bind(**initial)
|
rootmemory/main.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""FastAPI application entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, FastAPI, Request, Response
|
|
12
|
+
from fastapi.responses import FileResponse, RedirectResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
|
|
15
|
+
from rootmemory.api import ROUTERS
|
|
16
|
+
from rootmemory.api.errors import register_error_handlers
|
|
17
|
+
from rootmemory.api.security import require_api_key
|
|
18
|
+
from rootmemory.config import get_settings
|
|
19
|
+
from rootmemory.db.session import init_db
|
|
20
|
+
from rootmemory.logging_config import configure_logging, get_logger
|
|
21
|
+
from rootmemory.services.promotion_service import PromotionPolicy
|
|
22
|
+
|
|
23
|
+
DESCRIPTION = """
|
|
24
|
+
A provenance-aware memory layer for multi-agent AI systems.
|
|
25
|
+
|
|
26
|
+
It prevents agents from mistaking repeated copies of the same inference for
|
|
27
|
+
independent evidence: every belief is traced back to the observations it rests
|
|
28
|
+
on, and only claims backed by enough *independent* sources enter shared memory.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
logger = get_logger("rootmemory.api")
|
|
32
|
+
|
|
33
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@asynccontextmanager
|
|
37
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
38
|
+
settings = get_settings()
|
|
39
|
+
configure_logging(settings.log_level)
|
|
40
|
+
# Convenient for local/dev and SQLite; PostgreSQL deployments run Alembic.
|
|
41
|
+
init_db()
|
|
42
|
+
logger.info(
|
|
43
|
+
"startup",
|
|
44
|
+
database=settings.database_url.split("@")[-1],
|
|
45
|
+
policy=PromotionPolicy.from_settings(settings).as_dict(),
|
|
46
|
+
auth_enabled=settings.auth_enabled,
|
|
47
|
+
claim_normalization=settings.claim_normalization,
|
|
48
|
+
)
|
|
49
|
+
if not settings.auth_enabled:
|
|
50
|
+
logger.warning(
|
|
51
|
+
"auth_disabled",
|
|
52
|
+
detail=(
|
|
53
|
+
"No API_KEYS configured: every endpoint is open, including "
|
|
54
|
+
"observation invalidation. Set API_KEYS before exposing this."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
yield
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
app = FastAPI(
|
|
61
|
+
title="RootMemory",
|
|
62
|
+
version="0.1.0",
|
|
63
|
+
description=DESCRIPTION,
|
|
64
|
+
lifespan=lifespan,
|
|
65
|
+
)
|
|
66
|
+
register_error_handlers(app)
|
|
67
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
68
|
+
|
|
69
|
+
for router in ROUTERS:
|
|
70
|
+
app.include_router(router, dependencies=[Depends(require_api_key)])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.middleware("http")
|
|
74
|
+
async def log_requests(
|
|
75
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
76
|
+
) -> Response:
|
|
77
|
+
"""Structured request logging (spec section 50)."""
|
|
78
|
+
request_id = str(uuid.uuid4())
|
|
79
|
+
started = time.perf_counter()
|
|
80
|
+
response = await call_next(request)
|
|
81
|
+
response.headers["X-Request-ID"] = request_id
|
|
82
|
+
logger.info(
|
|
83
|
+
"request",
|
|
84
|
+
request_id=request_id,
|
|
85
|
+
method=request.method,
|
|
86
|
+
path=request.url.path,
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
duration_ms=round((time.perf_counter() - started) * 1000, 2),
|
|
89
|
+
)
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.get("/", include_in_schema=False)
|
|
94
|
+
def index() -> RedirectResponse:
|
|
95
|
+
return RedirectResponse("/ui")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.get("/ui", tags=["meta"], include_in_schema=False)
|
|
99
|
+
def portal() -> FileResponse:
|
|
100
|
+
"""The visualization portal.
|
|
101
|
+
|
|
102
|
+
Served from the API itself so the page talks to the same origin and sees
|
|
103
|
+
whatever is in the configured database.
|
|
104
|
+
"""
|
|
105
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.get("/health", tags=["meta"])
|
|
109
|
+
def health() -> dict[str, str]:
|
|
110
|
+
"""Liveness. Deliberately does no database work and needs no API key, so a
|
|
111
|
+
load balancer can call it cheaply."""
|
|
112
|
+
return {"status": "ok"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.get("/ready", tags=["meta"])
|
|
116
|
+
def ready() -> dict[str, object]:
|
|
117
|
+
"""Readiness: verifies the database is actually reachable."""
|
|
118
|
+
from sqlalchemy import text as sql_text
|
|
119
|
+
|
|
120
|
+
from rootmemory.db.session import get_engine
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
with get_engine().connect() as connection:
|
|
124
|
+
connection.execute(sql_text("SELECT 1"))
|
|
125
|
+
except Exception as exc: # noqa: BLE001 - the probe reports, never raises
|
|
126
|
+
return {"status": "unavailable", "database": "unreachable", "detail": str(exc)}
|
|
127
|
+
return {"status": "ok", "database": "reachable"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.get("/policy", tags=["meta"])
|
|
131
|
+
def policy() -> dict[str, object]:
|
|
132
|
+
"""The promotion policy currently in force."""
|
|
133
|
+
return PromotionPolicy.from_settings().as_dict()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ORM models. Importing this package registers every mapper."""
|
|
2
|
+
|
|
3
|
+
from rootmemory.models.agent import Agent
|
|
4
|
+
from rootmemory.models.audit import PromotionAudit, RepairReport
|
|
5
|
+
from rootmemory.models.belief import Belief
|
|
6
|
+
from rootmemory.models.claim import Claim, ClaimBeliefLink
|
|
7
|
+
from rootmemory.models.decision import Decision, DecisionDependency
|
|
8
|
+
from rootmemory.models.edge import ProvenanceEdge
|
|
9
|
+
from rootmemory.models.observation import Observation
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Agent",
|
|
13
|
+
"Belief",
|
|
14
|
+
"Claim",
|
|
15
|
+
"ClaimBeliefLink",
|
|
16
|
+
"Decision",
|
|
17
|
+
"DecisionDependency",
|
|
18
|
+
"Observation",
|
|
19
|
+
"PromotionAudit",
|
|
20
|
+
"ProvenanceEdge",
|
|
21
|
+
"RepairReport",
|
|
22
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Agent registry (spec section 15)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Float, String
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
10
|
+
|
|
11
|
+
from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Agent(Base):
|
|
15
|
+
"""An actor that can create observations, beliefs and decisions.
|
|
16
|
+
|
|
17
|
+
``trust_score`` is deliberately *not* used by the promotion gate. A trusted
|
|
18
|
+
agent can still repeat another agent's bad conclusion, so trust must never
|
|
19
|
+
stand in for provenance (spec section 15).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__tablename__ = "agents"
|
|
23
|
+
|
|
24
|
+
id: Mapped[uuid.UUID] = pk_column()
|
|
25
|
+
name: Mapped[str] = mapped_column(String(200), unique=True, index=True)
|
|
26
|
+
role: Mapped[str] = mapped_column(String(200), default="")
|
|
27
|
+
description: Mapped[str] = mapped_column(String(2000), default="")
|
|
28
|
+
trust_score: Mapped[float] = mapped_column(Float, default=0.5)
|
|
29
|
+
created_at: Mapped[datetime] = timestamp_column()
|
|
30
|
+
meta: Mapped[dict] = json_column()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Audit records: every promotion decision and every repair is written down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import JSON, Float, ForeignKey, Integer, String, Uuid
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
10
|
+
|
|
11
|
+
from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PromotionAudit(Base):
|
|
15
|
+
"""Immutable record of one promotion evaluation (spec section 30).
|
|
16
|
+
|
|
17
|
+
Both agreement counts are stored, because the difference between them is
|
|
18
|
+
the whole point of the system (spec section 64).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__tablename__ = "promotion_audits"
|
|
22
|
+
|
|
23
|
+
id: Mapped[uuid.UUID] = pk_column()
|
|
24
|
+
claim_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("claims.id"), index=True)
|
|
25
|
+
evaluated_at: Mapped[datetime] = timestamp_column()
|
|
26
|
+
supporting_belief_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
27
|
+
agreeing_agent_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
28
|
+
independent_support_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
29
|
+
contradiction_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
30
|
+
independent_contradiction_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
31
|
+
aggregate_confidence: Mapped[float] = mapped_column(Float, default=0.0)
|
|
32
|
+
result: Mapped[str] = mapped_column(String(20), index=True)
|
|
33
|
+
reasons: Mapped[list] = mapped_column(JSON, default=list)
|
|
34
|
+
policy_version: Mapped[str] = mapped_column(String(50), default="v1")
|
|
35
|
+
policy_snapshot: Mapped[dict] = json_column()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RepairReport(Base):
|
|
39
|
+
"""What a source invalidation touched (spec section 26)."""
|
|
40
|
+
|
|
41
|
+
__tablename__ = "repair_reports"
|
|
42
|
+
|
|
43
|
+
id: Mapped[uuid.UUID] = pk_column()
|
|
44
|
+
trigger_node_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), index=True)
|
|
45
|
+
trigger_node_type: Mapped[str] = mapped_column(String(20), default="observation")
|
|
46
|
+
trigger_reason: Mapped[str] = mapped_column(String(2000), default="")
|
|
47
|
+
affected_beliefs: Mapped[list] = mapped_column(JSON, default=list)
|
|
48
|
+
affected_claims: Mapped[list] = mapped_column(JSON, default=list)
|
|
49
|
+
affected_decisions: Mapped[list] = mapped_column(JSON, default=list)
|
|
50
|
+
details: Mapped[dict] = json_column()
|
|
51
|
+
created_at: Mapped[datetime] = timestamp_column()
|