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.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. 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]}