graph-engine-memory 0.6.1__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.
- graph_engine/__init__.py +1 -0
- graph_engine/__main__.py +1275 -0
- graph_engine/access.py +202 -0
- graph_engine/agent_memory.py +103 -0
- graph_engine/brain.py +731 -0
- graph_engine/brain_engine.py +559 -0
- graph_engine/communities.py +503 -0
- graph_engine/consolidation.py +191 -0
- graph_engine/context.py +203 -0
- graph_engine/decay.py +65 -0
- graph_engine/demo.py +165 -0
- graph_engine/dream.py +408 -0
- graph_engine/embedder.py +84 -0
- graph_engine/engine.py +58 -0
- graph_engine/entities.py +376 -0
- graph_engine/evals.py +1206 -0
- graph_engine/feedback.py +155 -0
- graph_engine/gaps.py +209 -0
- graph_engine/graph.py +133 -0
- graph_engine/hygiene.py +213 -0
- graph_engine/inference.py +251 -0
- graph_engine/intent.py +207 -0
- graph_engine/mcp/__init__.py +1 -0
- graph_engine/mcp/agent/instructions.md +34 -0
- graph_engine/mcp/format.py +66 -0
- graph_engine/mcp/server.py +407 -0
- graph_engine/merge.py +220 -0
- graph_engine/model.py +56 -0
- graph_engine/namespaces.py +74 -0
- graph_engine/recall.py +161 -0
- graph_engine/report.py +401 -0
- graph_engine/reranker.py +86 -0
- graph_engine/retrieval.py +296 -0
- graph_engine/review.py +95 -0
- graph_engine/runtime.py +83 -0
- graph_engine/server.py +239 -0
- graph_engine/similarity.py +42 -0
- graph_engine/store.py +63 -0
- graph_engine/suggester.py +63 -0
- graph_engine/temporal.py +216 -0
- graph_engine/tiers.py +135 -0
- graph_engine/web/app.js +578 -0
- graph_engine/web/index.html +169 -0
- graph_engine/web/report.html +39 -0
- graph_engine/web/report.js +50 -0
- graph_engine/web/review.html +104 -0
- graph_engine/web/review.js +211 -0
- graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
- graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
- graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
- graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
- graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
- graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/access.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Opt-in application access control for trusted CLI/MCP and bearer-auth HTTP.
|
|
2
|
+
|
|
3
|
+
The OS account, policy file and Python caller remain trusted. This does not
|
|
4
|
+
sandbox Python or encrypt a brain against someone who can read its files.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .brain import Brain, Edge, Node
|
|
16
|
+
|
|
17
|
+
request_principal: ContextVar[str | None] = ContextVar("ig_principal", default=None)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def policy_path() -> str:
|
|
21
|
+
return os.environ.get("IG_ACCESS_POLICY", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_policy() -> dict:
|
|
25
|
+
try:
|
|
26
|
+
data = json.loads(Path(policy_path()).expanduser().read_text(encoding="utf-8"))
|
|
27
|
+
if not isinstance(data, dict) or not isinstance(data.get("graphs"), dict):
|
|
28
|
+
raise ValueError("missing graphs object")
|
|
29
|
+
for config in data["graphs"].values():
|
|
30
|
+
if not isinstance(config, dict) or not isinstance(config.get("roles"), dict):
|
|
31
|
+
raise ValueError("missing roles object")
|
|
32
|
+
if any(r not in ("admin", "editor", "viewer") for r in config["roles"].values()):
|
|
33
|
+
raise ValueError("unknown role")
|
|
34
|
+
for acl in config.get("nodes", {}).values():
|
|
35
|
+
if not isinstance(acl, dict) or set(acl) - {"read", "write"}:
|
|
36
|
+
raise ValueError("invalid node ACL")
|
|
37
|
+
if any(not isinstance(v, list) or any(not isinstance(p, str) for p in v)
|
|
38
|
+
for v in acl.values()):
|
|
39
|
+
raise ValueError("ACL must contain principal lists")
|
|
40
|
+
return data
|
|
41
|
+
except (OSError, ValueError, TypeError, AttributeError) as exc:
|
|
42
|
+
raise PermissionError("Access policy unavailable or invalid") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def principal() -> str:
|
|
46
|
+
return request_principal.get() if request_principal.get() is not None else os.environ.get("IG_PRINCIPAL", "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def token_principal(authorization: str) -> str:
|
|
50
|
+
if not authorization.startswith("Bearer "):
|
|
51
|
+
raise PermissionError("Bearer authentication required")
|
|
52
|
+
digest = hashlib.sha256(authorization[7:].encode()).hexdigest()
|
|
53
|
+
identity = load_policy().get("tokens", {}).get(digest)
|
|
54
|
+
if not isinstance(identity, str) or not identity:
|
|
55
|
+
raise PermissionError("Invalid bearer token")
|
|
56
|
+
return identity
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def audit(graph: str, identity: str, action: str, allowed: bool,
|
|
60
|
+
node_id: str | None = None) -> None:
|
|
61
|
+
# A separate file beside the trusted policy avoids exporting security logs
|
|
62
|
+
# with a brain git push. Failure to append fails the operation closed.
|
|
63
|
+
row = {"ts": datetime.now(timezone.utc).isoformat(), "principal": identity,
|
|
64
|
+
"graph": graph, "action": action, "allowed": allowed, "node_id": node_id}
|
|
65
|
+
path = Path(policy_path()).expanduser().with_suffix(".audit.jsonl")
|
|
66
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
67
|
+
with os.fdopen(fd, "a", encoding="utf-8") as fh:
|
|
68
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def permitted(graph: str, identity: str, action: str, node_id: str | None = None) -> bool:
|
|
72
|
+
policy = load_policy()
|
|
73
|
+
config = policy["graphs"].get(graph, {})
|
|
74
|
+
role = config.get("roles", {}).get(identity)
|
|
75
|
+
if not identity or role not in ("admin", "editor", "viewer"):
|
|
76
|
+
return False
|
|
77
|
+
if role == "admin":
|
|
78
|
+
return True
|
|
79
|
+
if action == "admin" or (action == "write" and role != "editor"):
|
|
80
|
+
return False
|
|
81
|
+
if action not in ("read", "write"):
|
|
82
|
+
return False
|
|
83
|
+
acl = config.get("nodes", {}).get(node_id, {})
|
|
84
|
+
readers = acl.get("read")
|
|
85
|
+
if readers is not None and identity not in readers and "*" not in readers:
|
|
86
|
+
return False
|
|
87
|
+
users = acl.get(action)
|
|
88
|
+
return users is None or identity in users or "*" in users
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def require(graph: str, identity: str, action: str, node_id: str | None = None) -> None:
|
|
92
|
+
allowed = permitted(graph, identity, action, node_id)
|
|
93
|
+
audit(graph, identity, action, allowed, node_id)
|
|
94
|
+
if not allowed:
|
|
95
|
+
raise PermissionError("Access denied")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SecuredBrain(Brain):
|
|
99
|
+
"""Filter reads and authorize mutations at the shared storage boundary."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, *args, graph_name: str, identity: str, **kwargs) -> None:
|
|
102
|
+
super().__init__(*args, **kwargs)
|
|
103
|
+
self.graph_name, self.identity = graph_name, identity
|
|
104
|
+
self._raw = Brain(str(self.path), self.remote, self.mode)
|
|
105
|
+
self.authorize("read")
|
|
106
|
+
|
|
107
|
+
def authorize(self, action: str, node_id: str | None = None) -> None:
|
|
108
|
+
require(self.graph_name, self.identity, action, node_id)
|
|
109
|
+
|
|
110
|
+
def can_access(self, action: str, node_id: str | None = None) -> bool:
|
|
111
|
+
return permitted(self.graph_name, self.identity, action, node_id)
|
|
112
|
+
|
|
113
|
+
def read_nodes(self) -> list[Node]:
|
|
114
|
+
self.authorize("read")
|
|
115
|
+
return [n for n in self._raw.read_nodes() if self.can_access("read", n.id)]
|
|
116
|
+
|
|
117
|
+
def read_node(self, node_id: str) -> Node | None:
|
|
118
|
+
self.authorize("read", node_id)
|
|
119
|
+
return self._raw.read_node(node_id)
|
|
120
|
+
|
|
121
|
+
def write_node(self, node: Node) -> None:
|
|
122
|
+
self.authorize("write", node.id)
|
|
123
|
+
self._raw.write_node(node)
|
|
124
|
+
|
|
125
|
+
def _edge_allowed(self, edge: Edge, action: str) -> bool:
|
|
126
|
+
# Evidence is embedded text: hiding only the endpoints would leak it.
|
|
127
|
+
ids = [edge.source, edge.target] + [e.get("node_id") for e in edge.evidence]
|
|
128
|
+
return all(isinstance(nid, str) and self.can_access(action, nid) for nid in ids)
|
|
129
|
+
|
|
130
|
+
def read_edges(self, include_rejected: bool = False) -> list[Edge]:
|
|
131
|
+
self.authorize("read")
|
|
132
|
+
return [e for e in self._raw.read_edges(include_rejected) if self._edge_allowed(e, "read")]
|
|
133
|
+
|
|
134
|
+
def write_edges(self, edges: list[Edge]) -> None:
|
|
135
|
+
self.authorize("write")
|
|
136
|
+
with self._lock:
|
|
137
|
+
old = {e.id: e for e in self._raw.read_edges(include_rejected=True)}
|
|
138
|
+
new = {e.id: e for e in edges}
|
|
139
|
+
for eid in old.keys() | new.keys():
|
|
140
|
+
before, after = old.get(eid), new.get(eid)
|
|
141
|
+
if before and after and before.to_dict() == after.to_dict():
|
|
142
|
+
continue
|
|
143
|
+
if before and after is None and not self._edge_allowed(before, "read"):
|
|
144
|
+
new[eid] = before # bulk rewrites must preserve hidden records
|
|
145
|
+
continue
|
|
146
|
+
for edge in (before, after):
|
|
147
|
+
if edge is not None and not self._edge_allowed(edge, "write"):
|
|
148
|
+
audit(self.graph_name, self.identity, "write_edge", False)
|
|
149
|
+
raise PermissionError("Access denied")
|
|
150
|
+
self._raw.write_edges(list(new.values()))
|
|
151
|
+
|
|
152
|
+
def read_vectors(self) -> dict[str, list[float]]:
|
|
153
|
+
self.authorize("read")
|
|
154
|
+
return {nid: v for nid, v in self._raw.read_vectors().items() if self.can_access("read", nid)}
|
|
155
|
+
|
|
156
|
+
def write_vectors(self, vectors: dict[str, list[float]]) -> None:
|
|
157
|
+
self.authorize("write")
|
|
158
|
+
old = self._raw.read_vectors()
|
|
159
|
+
for nid, vector in vectors.items():
|
|
160
|
+
if old.get(nid) != vector:
|
|
161
|
+
self.authorize("write", nid)
|
|
162
|
+
# Preserve vectors outside the caller's view, including node ACLs.
|
|
163
|
+
preserved = {nid: v for nid, v in old.items() if not self.can_access("read", nid)}
|
|
164
|
+
for nid in old.keys() - vectors.keys() - preserved.keys():
|
|
165
|
+
self.authorize("write", nid)
|
|
166
|
+
self._raw.write_vectors({**preserved, **vectors})
|
|
167
|
+
|
|
168
|
+
def vectors_for(self, node_ids: set[str], embed_fn, batch_fn=None,
|
|
169
|
+
persist: bool = True) -> dict[str, list[float]]:
|
|
170
|
+
# Do not use the global unscoped memo or persist search side effects.
|
|
171
|
+
nodes = {n.id: n for n in self.read_nodes() if n.id in node_ids}
|
|
172
|
+
cached = self.read_vectors()
|
|
173
|
+
missing = sorted(nodes.keys() - cached.keys())
|
|
174
|
+
values = batch_fn([nodes[n].text for n in missing]) if batch_fn and missing else [embed_fn(nodes[n].text) for n in missing]
|
|
175
|
+
cached.update(zip(missing, values))
|
|
176
|
+
return {nid: cached[nid] for nid in nodes if nid in cached}
|
|
177
|
+
|
|
178
|
+
def ensure_ready(self) -> None:
|
|
179
|
+
self.authorize("write")
|
|
180
|
+
self._raw.ensure_ready()
|
|
181
|
+
|
|
182
|
+
def init(self, remote: str | None = None, commit: bool = True) -> None:
|
|
183
|
+
self.authorize("admin")
|
|
184
|
+
self._raw.init(remote, commit)
|
|
185
|
+
|
|
186
|
+
def clone_if_missing(self) -> None:
|
|
187
|
+
self.authorize("admin")
|
|
188
|
+
self._raw.clone_if_missing()
|
|
189
|
+
|
|
190
|
+
def pull(self) -> None:
|
|
191
|
+
self.authorize("write")
|
|
192
|
+
self._raw.pull()
|
|
193
|
+
|
|
194
|
+
def commit_and_push(self, message: str, push: bool = True) -> None:
|
|
195
|
+
self.authorize("write")
|
|
196
|
+
self._raw.commit_and_push(message, push)
|
|
197
|
+
|
|
198
|
+
def rebuild_index(self) -> None:
|
|
199
|
+
self.authorize("write")
|
|
200
|
+
# Generate complete internal derived state, not a filtered INDEX that
|
|
201
|
+
# would erase other users' entries. INDEX is never served by HTTP/MCP.
|
|
202
|
+
self._raw.rebuild_index()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The agent-facing write path (Welle C): remember / recall / forget.
|
|
2
|
+
|
|
3
|
+
The engine had a human write path (`ig ingest`, `ig link`) and a strictly
|
|
4
|
+
read-only MCP surface. An agent that CONSULTS the brain should also be able to
|
|
5
|
+
LEAVE something in it — under its own provenance, never destructively, and only
|
|
6
|
+
when the operator opts in (`ig mcp --write`).
|
|
7
|
+
|
|
8
|
+
Provenance is the whole point (Welle A): agent-written nodes carry
|
|
9
|
+
`source="agent"` and agent-declared edges `origin="agent"`, so "what did a model
|
|
10
|
+
put in here, and what did it connect?" stays a query instead of a guess.
|
|
11
|
+
|
|
12
|
+
Never destructive (the house rule): `forget` tombstones the node — it leaves
|
|
13
|
+
every live view (`retrieve`, `graph`, `hygiene`) but stays in the file, and its
|
|
14
|
+
edges are invalidated instead of deleted, so the audit trail survives.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .brain import Brain, Edge, Node, _now_iso
|
|
19
|
+
from .brain_engine import BrainEngine
|
|
20
|
+
|
|
21
|
+
AGENT_SOURCE = "agent" # node provenance: a model wrote this
|
|
22
|
+
AGENT_ORIGIN = "agent" # edge provenance: a model declared this
|
|
23
|
+
FORGET_STATUS = "tombstone" # never delete — hide from live views
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remember(engine: BrainEngine, text: str, *, tags: list[str] | None = None,
|
|
27
|
+
ntype: str = "semantic",
|
|
28
|
+
relations: list[tuple[str, str]] | None = None,
|
|
29
|
+
allow_duplicates: bool = False) -> dict:
|
|
30
|
+
"""Write one note on an agent's behalf.
|
|
31
|
+
|
|
32
|
+
Dedupe-aware (a near-duplicate merges into the existing node instead of
|
|
33
|
+
creating a second one) and provenance-marked: the node carries
|
|
34
|
+
`source="agent"`, declared relations carry `origin="agent"`. Commits once
|
|
35
|
+
(that is `ingest`'s own commit), so an agent write is one history entry.
|
|
36
|
+
"""
|
|
37
|
+
if not (text or "").strip():
|
|
38
|
+
raise ValueError("Empty text cannot be remembered.")
|
|
39
|
+
node, edges, is_dup = engine.ingest(
|
|
40
|
+
text, source=AGENT_SOURCE, tags=tags, ntype=ntype,
|
|
41
|
+
relations=relations, allow_duplicates=allow_duplicates,
|
|
42
|
+
edge_origin=AGENT_ORIGIN)
|
|
43
|
+
agent_edges = [e for e in edges if e.origin == AGENT_ORIGIN]
|
|
44
|
+
return {"node_id": node.id, "duplicate": is_dup, "edges": len(edges),
|
|
45
|
+
"agent_edges": len(agent_edges), "status": node.status,
|
|
46
|
+
"origin": AGENT_ORIGIN, "written": not is_dup}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def recall(engine: BrainEngine, query: str, k: int = 5, *,
|
|
50
|
+
persist: bool = False) -> list[tuple[str, float]]:
|
|
51
|
+
"""A search that COUNTS AS USE.
|
|
52
|
+
|
|
53
|
+
Agent recalls feed the promotion signal (`recall_count`), so a memory the
|
|
54
|
+
agent actually relied on can later be promoted by the dream pass.
|
|
55
|
+
`search_brain` stays the side-effect-free variant for exploration.
|
|
56
|
+
"""
|
|
57
|
+
from .retrieval import retrieve
|
|
58
|
+
return retrieve(engine, query, k=k, track=True, persist=persist)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def forget(brain: Brain, node_id: str, *, reason: str,
|
|
62
|
+
commit: bool = True, push: bool = True) -> dict:
|
|
63
|
+
"""Hide a node from every live view — without deleting it.
|
|
64
|
+
|
|
65
|
+
Sets `status="tombstone"` (out of `retrieve`, `graph`, `hygiene` — the file
|
|
66
|
+
and its history stay) and invalidates every undecided/live edge touching it, in ONE
|
|
67
|
+
commit. `reason` is mandatory and lands in the commit message: the record of
|
|
68
|
+
WHY must survive with the decision. Idempotent: forgetting a tombstone again
|
|
69
|
+
is a no-op, not an error.
|
|
70
|
+
"""
|
|
71
|
+
reason = (reason or "").strip()
|
|
72
|
+
if not reason:
|
|
73
|
+
raise ValueError("forget requires a reason — it lands in the commit message.")
|
|
74
|
+
brain.authorize("write", node_id)
|
|
75
|
+
node = brain.read_node(node_id)
|
|
76
|
+
if node is None:
|
|
77
|
+
raise ValueError(f"No node with id {node_id!r}.")
|
|
78
|
+
if node.status == FORGET_STATUS:
|
|
79
|
+
return {"node_id": node_id, "status": FORGET_STATUS, "edges_invalidated": 0,
|
|
80
|
+
"forgotten": False, "already": True, "reason": reason}
|
|
81
|
+
|
|
82
|
+
now = _now_iso()
|
|
83
|
+
edges = brain.read_edges(include_rejected=True)
|
|
84
|
+
invalidated = 0
|
|
85
|
+
for e in edges:
|
|
86
|
+
if not e.is_invalidated and not e.rejected and node_id in (e.source, e.target):
|
|
87
|
+
# Check every affected record before writing the tombstone. A
|
|
88
|
+
# readable edge can connect to a node the caller cannot modify.
|
|
89
|
+
for affected in [e.source, e.target, *(item.get("node_id") for item in e.evidence)]:
|
|
90
|
+
brain.authorize("write", affected)
|
|
91
|
+
e.invalidate(now) # Also invalidate scheduled/expired extracted facts.
|
|
92
|
+
invalidated += 1
|
|
93
|
+
node.status = FORGET_STATUS
|
|
94
|
+
brain.write_node(node)
|
|
95
|
+
brain.write_edges(edges)
|
|
96
|
+
brain.rebuild_index()
|
|
97
|
+
if commit:
|
|
98
|
+
brain.commit_and_push(
|
|
99
|
+
f"agent forget: {node_id} — {reason} "
|
|
100
|
+
f"({invalidated} edge(s) invalidated, node tombstoned)", push=push)
|
|
101
|
+
return {"node_id": node_id, "status": FORGET_STATUS,
|
|
102
|
+
"edges_invalidated": invalidated, "forgotten": True,
|
|
103
|
+
"already": False, "reason": reason, "text": (node.text or "")[:120]}
|