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/brain.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Brain layer: the private git repo as memory.
|
|
2
|
+
|
|
3
|
+
Structure inside the brain repo:
|
|
4
|
+
nodes/<id>.md — one idea per file (YAML frontmatter + text)
|
|
5
|
+
edges.jsonl — one edge per line (machine-written, pending flag)
|
|
6
|
+
INDEX.md — generated table of contents (humans + GitHub search)
|
|
7
|
+
|
|
8
|
+
Sync model: pull before every write, commit+push after.
|
|
9
|
+
For tests: mode="local" works without git in a temp dir.
|
|
10
|
+
|
|
11
|
+
Crash safety (Audit #2): all whole-file writes go through
|
|
12
|
+
_atomic_write() — first to a temp file in the same directory, then
|
|
13
|
+
os.replace(). A crash mid-write leaves either the old or the new
|
|
14
|
+
file, never a half-written one.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import uuid
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _now_iso() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
VALID_STATUS = ("probation", "active", "stale", "tombstone")
|
|
34
|
+
STORAGE_TIERS = ("main", "recall", "archival")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
38
|
+
"""Write content atomically: temp file in the same directory + os.replace()."""
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
tmp = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex[:8])
|
|
41
|
+
try:
|
|
42
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
43
|
+
f.write(content)
|
|
44
|
+
f.flush()
|
|
45
|
+
os.fsync(f.fileno())
|
|
46
|
+
os.replace(tmp, path)
|
|
47
|
+
finally:
|
|
48
|
+
if tmp.exists():
|
|
49
|
+
tmp.unlink()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Node:
|
|
53
|
+
def __init__(self, text: str, id: str | None = None, created: str | None = None,
|
|
54
|
+
source: str = "human", tags: list[str] | None = None,
|
|
55
|
+
sources: list[str] | None = None, ntype: str = "semantic",
|
|
56
|
+
status: str = "probation",
|
|
57
|
+
recall_count: int = 0, recall_queries: list[str] | None = None,
|
|
58
|
+
last_recalled: str | None = None,
|
|
59
|
+
observed_at: str | None = None, context: str | None = None,
|
|
60
|
+
entity_name: str | None = None, entity_type: str | None = None,
|
|
61
|
+
aliases: list[str] | None = None,
|
|
62
|
+
storage_tier: str = "recall", tier_locked: bool = False):
|
|
63
|
+
if storage_tier not in STORAGE_TIERS:
|
|
64
|
+
raise ValueError(f"Unknown storage tier: {storage_tier!r}")
|
|
65
|
+
self.storage_tier = storage_tier
|
|
66
|
+
self.tier_locked = bool(tier_locked)
|
|
67
|
+
self.text = text
|
|
68
|
+
self.id = id or uuid.uuid4().hex[:12]
|
|
69
|
+
self.created = created or _now_iso()
|
|
70
|
+
self.source = source
|
|
71
|
+
self.tags = tags or []
|
|
72
|
+
# Audit #54 (churn fix): new nodes record their source IMMEDIATELY in
|
|
73
|
+
# `sources` (and to_markdown always writes the list) — otherwise the
|
|
74
|
+
# file gains a purely cosmetic sources: line on the first dup ingest.
|
|
75
|
+
# from_markdown passes [] explicitly → old files without the line
|
|
76
|
+
# stay unchanged until they really mutate.
|
|
77
|
+
self.sources = sources if sources is not None else [source]
|
|
78
|
+
# Memory taxonomy plus structured entity identities.
|
|
79
|
+
self.ntype = ntype if ntype in ("semantic", "episodic", "procedural", "entity") else "semantic"
|
|
80
|
+
# V2#2 memory hygiene: dual buffer — new nodes start in probation,
|
|
81
|
+
# get promoted after dedup/verification, or end up as tombstones.
|
|
82
|
+
self.status = status if status in VALID_STATUS else "probation"
|
|
83
|
+
# Recall tracking (the signal a memory system needs and this one never
|
|
84
|
+
# had): how often a node was actually retrieved, over how many DISTINCT
|
|
85
|
+
# queries, and when last. OpenClaw's dreaming promotion gates are built
|
|
86
|
+
# on exactly these three numbers; without them "promote what is used"
|
|
87
|
+
# cannot be decided. Derived data: written by `recall.aggregate()` from
|
|
88
|
+
# the local ledger, never on the read path itself.
|
|
89
|
+
self.recall_count = int(recall_count or 0)
|
|
90
|
+
self.recall_queries = list(recall_queries or [])
|
|
91
|
+
self.last_recalled = last_recalled
|
|
92
|
+
# Episodic layer (Phase 1): when/where this observation was made.
|
|
93
|
+
# Only meaningful for ntype="episodic"; None for semantic/procedural.
|
|
94
|
+
self.observed_at = observed_at
|
|
95
|
+
self.context = context
|
|
96
|
+
if entity_name is not None and not isinstance(entity_name, str):
|
|
97
|
+
raise ValueError("entity_name must be a string")
|
|
98
|
+
if entity_type is not None and not isinstance(entity_type, str):
|
|
99
|
+
raise ValueError("entity_type must be a string")
|
|
100
|
+
if aliases is not None and (not isinstance(aliases, list)
|
|
101
|
+
or not all(isinstance(a, str) for a in aliases)):
|
|
102
|
+
raise ValueError("aliases must be a list of strings")
|
|
103
|
+
self.entity_name = entity_name
|
|
104
|
+
self.entity_type = entity_type
|
|
105
|
+
self.aliases = list(aliases or [])
|
|
106
|
+
|
|
107
|
+
def to_markdown(self) -> str:
|
|
108
|
+
# This frontmatter format stores scalars and simple lists on one line.
|
|
109
|
+
# Untrusted source/tags must never introduce another metadata key (in
|
|
110
|
+
# particular an id with different access permissions). Check at write
|
|
111
|
+
# time too: merge_node updates sources on an existing Node instance.
|
|
112
|
+
for name in ("id", "created", "source", "last_recalled", "observed_at", "context"):
|
|
113
|
+
self._validate_metadata(name, getattr(self, name))
|
|
114
|
+
for name in ("tags", "sources", "recall_queries"):
|
|
115
|
+
for value in getattr(self, name):
|
|
116
|
+
self._validate_metadata(name, value)
|
|
117
|
+
tags = "[" + ", ".join(self.tags) + "]" if self.tags else "[]"
|
|
118
|
+
lines = [f"id: {self.id}", f"created: {self.created}",
|
|
119
|
+
f"source: {self.source}", f"type: {self.ntype}",
|
|
120
|
+
f"status: {self.status}"]
|
|
121
|
+
lines.append(f"storage_tier: {self.storage_tier}")
|
|
122
|
+
if self.tier_locked:
|
|
123
|
+
lines.append("tier_locked: true")
|
|
124
|
+
# always write sources (even empty) — otherwise the file gains a
|
|
125
|
+
# purely cosmetic sources: line on the first dup ingest (history churn,
|
|
126
|
+
# Audit #54): from_markdown returns [], merge_node inserts node.source,
|
|
127
|
+
# the rewrite "changes" the file without any content gain.
|
|
128
|
+
lines.append("sources: [" + ", ".join(self.sources) + "]")
|
|
129
|
+
lines.append(f"tags: {tags}")
|
|
130
|
+
if self.ntype == "entity":
|
|
131
|
+
# JSON values are also valid YAML and safely preserve commas,
|
|
132
|
+
# quotes, colons and Unicode in names/aliases.
|
|
133
|
+
lines.append("entity_name: " + json.dumps(self.entity_name, ensure_ascii=False))
|
|
134
|
+
lines.append("entity_type: " + json.dumps(self.entity_type, ensure_ascii=False))
|
|
135
|
+
lines.append("aliases: " + json.dumps(self.aliases, ensure_ascii=False))
|
|
136
|
+
# Episodic metadata only when present — semantic/procedural nodes
|
|
137
|
+
# stay unchanged (no frontmatter churn).
|
|
138
|
+
if self.ntype == "episodic":
|
|
139
|
+
if self.observed_at:
|
|
140
|
+
lines.append(f"observed_at: {self.observed_at}")
|
|
141
|
+
if self.context:
|
|
142
|
+
lines.append(f"context: {self.context}")
|
|
143
|
+
# Recall stats only when they exist — writing them unconditionally
|
|
144
|
+
# would rewrite all ~2k node files for zero information.
|
|
145
|
+
if self.recall_count:
|
|
146
|
+
lines.append(f"recalls: {self.recall_count}")
|
|
147
|
+
lines.append("recall_queries: [" + ", ".join(self.recall_queries) + "]")
|
|
148
|
+
if self.last_recalled:
|
|
149
|
+
lines.append(f"last_recalled: {self.last_recalled}")
|
|
150
|
+
return "---\n" + "\n".join(lines) + "\n---\n\n" + f"{self.text}\n"
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _validate_metadata(name: str, value: str | None) -> None:
|
|
154
|
+
if value is not None and (not isinstance(value, str) or any(
|
|
155
|
+
char in value for char in "\r\n\v\f\x1c\x1d\x1e\x85\u2028\u2029")):
|
|
156
|
+
raise ValueError(f"{name} must be a single-line string")
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_markdown(cls, raw: str) -> "Node":
|
|
160
|
+
m = re.match(r"^---\n(.*?)\n---\n\n?(.*)$", raw, re.DOTALL)
|
|
161
|
+
if not m:
|
|
162
|
+
raise ValueError("No frontmatter found")
|
|
163
|
+
meta_raw, text = m.group(1), m.group(2)
|
|
164
|
+
meta: dict = {}
|
|
165
|
+
for line in meta_raw.splitlines():
|
|
166
|
+
if ":" in line:
|
|
167
|
+
key, _, val = line.partition(":")
|
|
168
|
+
if key.strip() in meta:
|
|
169
|
+
raise ValueError(f"Duplicate frontmatter key: {key.strip()}")
|
|
170
|
+
meta[key.strip()] = val.strip()
|
|
171
|
+
# Audit #56: a hand-edited file without id: should raise an
|
|
172
|
+
# understandable error (with path context so the caller can skip it),
|
|
173
|
+
# not a bare KeyError.
|
|
174
|
+
if "id" not in meta:
|
|
175
|
+
raise ValueError("Frontmatter without 'id:' — skipping file")
|
|
176
|
+
tags = [t.strip() for t in meta.get("tags", "[]").strip("[]").split(",") if t.strip()]
|
|
177
|
+
sources = [s.strip() for s in meta.get("sources", "").strip("[]").split(",") if s.strip()]
|
|
178
|
+
try:
|
|
179
|
+
recall_count = int(meta.get("recalls", "0") or 0)
|
|
180
|
+
except ValueError:
|
|
181
|
+
recall_count = 0
|
|
182
|
+
recall_queries = [q.strip() for q in
|
|
183
|
+
meta.get("recall_queries", "").strip("[]").split(",") if q.strip()]
|
|
184
|
+
return cls(text=text.strip(), id=meta["id"], created=meta.get("created"),
|
|
185
|
+
source=meta.get("source", "human"), tags=tags, sources=sources,
|
|
186
|
+
ntype=meta.get("type", "semantic"), status=meta.get("status", "probation"),
|
|
187
|
+
recall_count=recall_count, recall_queries=recall_queries,
|
|
188
|
+
last_recalled=meta.get("last_recalled"),
|
|
189
|
+
observed_at=meta.get("observed_at"),
|
|
190
|
+
context=meta.get("context"),
|
|
191
|
+
entity_name=json.loads(meta.get("entity_name", "null")),
|
|
192
|
+
entity_type=json.loads(meta.get("entity_type", "null")),
|
|
193
|
+
aliases=json.loads(meta.get("aliases", "[]")),
|
|
194
|
+
storage_tier=meta.get("storage_tier", "recall"),
|
|
195
|
+
tier_locked=meta.get("tier_locked", "false").lower() == "true")
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> dict:
|
|
198
|
+
result = {"id": self.id, "text": self.text, "created": self.created,
|
|
199
|
+
"source": self.source, "tags": self.tags, "sources": self.sources,
|
|
200
|
+
"type": self.ntype, "status": self.status,
|
|
201
|
+
"storage_tier": self.storage_tier, "tier_locked": self.tier_locked,
|
|
202
|
+
"recall_count": self.recall_count,
|
|
203
|
+
"recall_queries": self.recall_queries,
|
|
204
|
+
"last_recalled": self.last_recalled,
|
|
205
|
+
"observed_at": self.observed_at, "context": self.context}
|
|
206
|
+
if self.ntype == "entity":
|
|
207
|
+
result.update(entity_name=self.entity_name, entity_type=self.entity_type,
|
|
208
|
+
aliases=self.aliases)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Edge:
|
|
213
|
+
def __init__(self, source: str, target: str, kind: str,
|
|
214
|
+
pending: bool = True, id: str | None = None,
|
|
215
|
+
valid_from: str | None = None, valid_to: str | None = None,
|
|
216
|
+
confidence: float | None = None,
|
|
217
|
+
invalidated_by: str | None = None, rejected: bool = False,
|
|
218
|
+
origin: str | None = None, predicate: str | None = None,
|
|
219
|
+
evidence: list[dict] | None = None,
|
|
220
|
+
extracted_validity: dict | None = None,
|
|
221
|
+
invalidated_at: str | None = None):
|
|
222
|
+
self.source = source
|
|
223
|
+
self.target = target
|
|
224
|
+
self.kind = kind
|
|
225
|
+
self.pending = pending
|
|
226
|
+
self.id = id or uuid.uuid4().hex[:12]
|
|
227
|
+
# Provenance of the EDGE ITSELF (who created it): "suggester" (cosine
|
|
228
|
+
# kNN), "intent" (marker heuristic), "manual" (ig link / declared
|
|
229
|
+
# relations / demo seed), "consolidator" (a dream pass). None = legacy
|
|
230
|
+
# edge written before the field existed. Reports and maintenance passes
|
|
231
|
+
# must treat heuristic edges as rewritable and manual ones as
|
|
232
|
+
# user-authored — without this, "which edges are machine guesses?" can
|
|
233
|
+
# only be reconstructed from the text (done by hand once, 2026-09-18).
|
|
234
|
+
self.origin = origin
|
|
235
|
+
# Bi-temporality (Zep/Graphiti lesson): fact validity kept separate
|
|
236
|
+
# from commit time (which the git history provides for free).
|
|
237
|
+
self.valid_from = valid_from or _now_iso()
|
|
238
|
+
self.valid_to = valid_to
|
|
239
|
+
# V2#3: confidence (cosine) of auto suggestions; None for manual links.
|
|
240
|
+
self.confidence = confidence
|
|
241
|
+
# V1#1: provenance — which edge/event invalidated this edge.
|
|
242
|
+
self.invalidated_by = invalidated_by
|
|
243
|
+
self.rejected = rejected
|
|
244
|
+
self.predicate = predicate
|
|
245
|
+
self.evidence = list(evidence or [])
|
|
246
|
+
# Original extractor input is an immutable identity key, including
|
|
247
|
+
# null dates. Its presence opts facts into bounded validity semantics;
|
|
248
|
+
# legacy edges still interpret any valid_to as an invalidation.
|
|
249
|
+
self.extracted_validity = (dict(extracted_validity)
|
|
250
|
+
if extracted_validity is not None else None)
|
|
251
|
+
self.invalidated_at = invalidated_at
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def is_invalidated(self) -> bool:
|
|
255
|
+
return (self.invalidated_at is not None
|
|
256
|
+
or (self.extracted_validity is None and self.valid_to is not None))
|
|
257
|
+
|
|
258
|
+
def is_valid_at(self, at: datetime) -> bool:
|
|
259
|
+
"""Half-open validity interval, additionally cut off by invalidation."""
|
|
260
|
+
if at.tzinfo is None:
|
|
261
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
262
|
+
for value, is_start in ((self.valid_from, True), (self.valid_to, False),
|
|
263
|
+
(self.invalidated_at, False)):
|
|
264
|
+
if value is None:
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
stamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
268
|
+
if stamp.tzinfo is None:
|
|
269
|
+
stamp = stamp.replace(tzinfo=timezone.utc)
|
|
270
|
+
except (ValueError, TypeError, AttributeError):
|
|
271
|
+
continue # Preserve temporal queries' tolerance of legacy timestamps.
|
|
272
|
+
if (is_start and at < stamp) or (not is_start and at >= stamp):
|
|
273
|
+
return False
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def is_current(self) -> bool:
|
|
278
|
+
"""Validity only: callers choose whether to include pending/rejected edges."""
|
|
279
|
+
if self.is_invalidated:
|
|
280
|
+
return False
|
|
281
|
+
return (self.extracted_validity is None
|
|
282
|
+
or self.is_valid_at(datetime.now(timezone.utc)))
|
|
283
|
+
|
|
284
|
+
def invalidate(self, at: str) -> None:
|
|
285
|
+
self.invalidated_at = at
|
|
286
|
+
if self.extracted_validity is None:
|
|
287
|
+
self.valid_to = at # Preserve the legacy on-disk/API contract.
|
|
288
|
+
|
|
289
|
+
def to_dict(self) -> dict:
|
|
290
|
+
result = {"id": self.id, "source": self.source, "target": self.target,
|
|
291
|
+
"kind": self.kind, "pending": self.pending,
|
|
292
|
+
"valid_from": self.valid_from, "valid_to": self.valid_to,
|
|
293
|
+
"confidence": self.confidence,
|
|
294
|
+
"invalidated_by": self.invalidated_by}
|
|
295
|
+
if self.origin is not None:
|
|
296
|
+
result["origin"] = self.origin
|
|
297
|
+
if self.rejected:
|
|
298
|
+
result["rejected"] = True
|
|
299
|
+
if self.predicate is not None:
|
|
300
|
+
result["predicate"] = self.predicate
|
|
301
|
+
if self.evidence:
|
|
302
|
+
result["evidence"] = self.evidence
|
|
303
|
+
if self.extracted_validity is not None:
|
|
304
|
+
result["extracted_validity"] = dict(self.extracted_validity)
|
|
305
|
+
if self.invalidated_at is not None:
|
|
306
|
+
result["invalidated_at"] = self.invalidated_at
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _jsonl_dumps(obj: dict) -> str:
|
|
311
|
+
return json.dumps(obj, ensure_ascii=False)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class Brain:
|
|
315
|
+
"""The private repo as storage. mode="git" syncs, mode="local" is FS-only."""
|
|
316
|
+
|
|
317
|
+
def __init__(self, path: str, remote: str | None = None, mode: str = "local"):
|
|
318
|
+
self.path = Path(path)
|
|
319
|
+
self.remote = remote
|
|
320
|
+
self.mode = mode
|
|
321
|
+
# Instance lock: serializes RMW mutations even when MULTIPLE
|
|
322
|
+
# BrainEngine instances share the same brain (the server builds a new
|
|
323
|
+
# engine per request). Multi-step chains (ingest) must stay consistent
|
|
324
|
+
# across several calls — the engine-wide BRAIN_LOCK and this instance
|
|
325
|
+
# lock combine cleanly for that (RLock).
|
|
326
|
+
self._lock = threading.RLock()
|
|
327
|
+
|
|
328
|
+
def authorize(self, action: str, node_id: str | None = None) -> None:
|
|
329
|
+
"""Unprotected local brain; SecuredBrain enforces this shared hook."""
|
|
330
|
+
|
|
331
|
+
def can_access(self, action: str, node_id: str | None = None) -> bool:
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
# ---------- Git sync ----------
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _remote_has_main(remote: str) -> bool:
|
|
338
|
+
"""True when the remote already carries a brain (a `main` branch)."""
|
|
339
|
+
r = subprocess.run(["git", "ls-remote", "--heads", remote, "main"],
|
|
340
|
+
capture_output=True, text=True)
|
|
341
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
342
|
+
|
|
343
|
+
def clone_if_missing(self) -> None:
|
|
344
|
+
# Audit #20: crashed-clone detection — a half-cloned directory
|
|
345
|
+
# (without .git) blocked every further clone attempt forever.
|
|
346
|
+
if self.path.exists() and (self.path / ".git").exists():
|
|
347
|
+
return
|
|
348
|
+
if not self.remote:
|
|
349
|
+
raise ValueError("No remote given and no clone present.")
|
|
350
|
+
if self.path.exists() and any(self.path.iterdir()) and not (self.path / ".git").exists():
|
|
351
|
+
raise RuntimeError(
|
|
352
|
+
f"{self.path} exists but is not a brain repo (no .git) and "
|
|
353
|
+
"not empty — please inspect/remove it manually instead of overwriting it.")
|
|
354
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
subprocess.run(["git", "clone", "--quiet", self.remote, str(self.path)], check=True)
|
|
356
|
+
|
|
357
|
+
def init(self, remote: str | None = None, commit: bool = True) -> None:
|
|
358
|
+
"""Creates a fresh brain repo at the path (onboarding: `ig init`).
|
|
359
|
+
|
|
360
|
+
Sets up the structure (nodes/, edges.jsonl, vectors.jsonl, INDEX.md),
|
|
361
|
+
`git init` + branch main, optionally an origin remote, and commits the
|
|
362
|
+
initial state. Idempotent: an existing repo is not overwritten. Without
|
|
363
|
+
a remote the first commit stays local (push=False).
|
|
364
|
+
|
|
365
|
+
If the remote already carries a brain (a `main` branch), it is cloned
|
|
366
|
+
instead of initialized: two machines running `ig init --remote <same
|
|
367
|
+
brain>` used to create two unrelated root commits, so the second push
|
|
368
|
+
was rejected as a non-fast-forward.
|
|
369
|
+
"""
|
|
370
|
+
if (self.mode == "git" and remote and not (self.path / ".git").exists()
|
|
371
|
+
and self._remote_has_main(remote)):
|
|
372
|
+
self.remote = remote
|
|
373
|
+
self.clone_if_missing()
|
|
374
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
(self.path / "nodes").mkdir(parents=True, exist_ok=True)
|
|
376
|
+
if not self.edges_file.exists():
|
|
377
|
+
self.write_edges([])
|
|
378
|
+
if not self.vectors_file.exists():
|
|
379
|
+
self.write_vectors({})
|
|
380
|
+
self.rebuild_index()
|
|
381
|
+
if self.mode == "git" and not (self.path / ".git").exists():
|
|
382
|
+
subprocess.run(["git", "-C", str(self.path), "init", "--quiet"], check=True)
|
|
383
|
+
subprocess.run(["git", "-C", str(self.path), "symbolic-ref",
|
|
384
|
+
"HEAD", "refs/heads/main"], check=True)
|
|
385
|
+
if remote:
|
|
386
|
+
self.remote = remote
|
|
387
|
+
subprocess.run(["git", "-C", str(self.path), "remote", "add",
|
|
388
|
+
"origin", remote], check=True)
|
|
389
|
+
if commit:
|
|
390
|
+
# With remote → push the initial commit; without → commit locally only.
|
|
391
|
+
self.commit_and_push("init: brain repo created", push=bool(remote))
|
|
392
|
+
|
|
393
|
+
def ensure_ready(self) -> None:
|
|
394
|
+
"""Make sure the brain repo exists (init or clone).
|
|
395
|
+
|
|
396
|
+
Called at the start of every write path so `ig ingest` works
|
|
397
|
+
immediately on a fresh machine without manual setup.
|
|
398
|
+
"""
|
|
399
|
+
if self.mode != "git":
|
|
400
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
return
|
|
402
|
+
if (self.path / ".git").exists():
|
|
403
|
+
return
|
|
404
|
+
if self.remote:
|
|
405
|
+
self.clone_if_missing()
|
|
406
|
+
else:
|
|
407
|
+
self.init(remote=None, commit=False)
|
|
408
|
+
|
|
409
|
+
def pull(self) -> None:
|
|
410
|
+
if self.mode != "git":
|
|
411
|
+
return
|
|
412
|
+
# Without origin (freshly `ig init`-ed, local) pull is a no-op.
|
|
413
|
+
has_origin = subprocess.run(
|
|
414
|
+
["git", "-C", str(self.path), "remote", "get-url", "origin"],
|
|
415
|
+
capture_output=True).returncode == 0
|
|
416
|
+
if not has_origin:
|
|
417
|
+
return
|
|
418
|
+
# Audit #20: a plain `pull origin main` hangs on a merge conflict or
|
|
419
|
+
# fails hard after a failed push — afterwards every request 500s
|
|
420
|
+
# until manual repair. `--rebase --autostash` stashes local changes,
|
|
421
|
+
# rebases onto origin/main and restores them; only real conflicts
|
|
422
|
+
# remain visible as errors.
|
|
423
|
+
r = subprocess.run(
|
|
424
|
+
["git", "-C", str(self.path), "pull", "--quiet", "--rebase",
|
|
425
|
+
"--autostash", "origin", "main"],
|
|
426
|
+
capture_output=True, text=True)
|
|
427
|
+
if r.returncode != 0:
|
|
428
|
+
raise RuntimeError(
|
|
429
|
+
"git pull failed (brain repo needs manual attention; "
|
|
430
|
+
"local changes were stashed via autostash):\n"
|
|
431
|
+
+ (r.stderr or r.stdout)[-500:])
|
|
432
|
+
|
|
433
|
+
def commit_and_push(self, message: str, push: bool = True) -> None:
|
|
434
|
+
if self.mode != "git":
|
|
435
|
+
return
|
|
436
|
+
# Bot identity is configurable (IG_BOT_NAME/IG_BOT_EMAIL); no longer
|
|
437
|
+
# a hard-wired personal identity.
|
|
438
|
+
bot_name = os.environ.get("IG_BOT_NAME", "graph-engine-bot")
|
|
439
|
+
bot_email = os.environ.get("IG_BOT_EMAIL", "bot@graph-engine.local")
|
|
440
|
+
env_user = ["-c", f"user.name={bot_name}", "-c", f"user.email={bot_email}"]
|
|
441
|
+
subprocess.run(["git", "-C", str(self.path), *env_user, "add", "-A"], check=True)
|
|
442
|
+
diff = subprocess.run(["git", "-C", str(self.path), *env_user,
|
|
443
|
+
"diff", "--cached", "--quiet"], capture_output=True)
|
|
444
|
+
if diff.returncode == 0:
|
|
445
|
+
return # nothing to commit
|
|
446
|
+
subprocess.run(["git", "-C", str(self.path), *env_user,
|
|
447
|
+
"commit", "--quiet", "-m", message], check=True)
|
|
448
|
+
if not push:
|
|
449
|
+
return
|
|
450
|
+
# Only push when an origin exists (freshly `ig init`-ed without a
|
|
451
|
+
# remote has none — then the first commit stays local).
|
|
452
|
+
has_origin = subprocess.run(
|
|
453
|
+
["git", "-C", str(self.path), "remote", "get-url", "origin"],
|
|
454
|
+
capture_output=True).returncode == 0
|
|
455
|
+
if has_origin:
|
|
456
|
+
# Audit #31: a failed push must not look like a failed ingest — the
|
|
457
|
+
# commit is already local and consistent; the next pull --rebase
|
|
458
|
+
# --autostash replays cleanly. Raise a precise, actionable error.
|
|
459
|
+
r = subprocess.run(["git", "-C", str(self.path), "push", "--quiet",
|
|
460
|
+
"origin", "main"], capture_output=True, text=True)
|
|
461
|
+
if r.returncode != 0:
|
|
462
|
+
raise RuntimeError(
|
|
463
|
+
f"git push failed (commit IS local, nothing lost): "
|
|
464
|
+
f"{(r.stderr or r.stdout).strip()[-300:]}")
|
|
465
|
+
|
|
466
|
+
# ---------- Nodes ----------
|
|
467
|
+
|
|
468
|
+
def node_path(self, node_id: str) -> Path:
|
|
469
|
+
# Audit #18: node IDs land unverified in paths — a crafted ID with
|
|
470
|
+
# '/'/'..' could escape nodes/ (arbitrary read/write with the
|
|
471
|
+
# .md suffix). The guard is path safety, not the 12-hex convention:
|
|
472
|
+
# short/readable IDs (fixtures, hand-built brains) stay valid;
|
|
473
|
+
# anything that could escape nodes/ or create dotfiles is rejected.
|
|
474
|
+
if (not node_id or "/" in node_id or "\\" in node_id
|
|
475
|
+
or node_id in (".", "..") or node_id.startswith(".")
|
|
476
|
+
or "\x00" in node_id or len(node_id) > 200):
|
|
477
|
+
raise ValueError(f"Invalid node ID: {node_id!r} (path-unsafe)")
|
|
478
|
+
return self.path / "nodes" / f"{node_id}.md"
|
|
479
|
+
|
|
480
|
+
def merge_node(self, node: Node, source: str | None = None) -> None:
|
|
481
|
+
"""Duplicate ingest: keep the existing node, log the source.
|
|
482
|
+
|
|
483
|
+
tags/created stay untouched; the new source is added to the
|
|
484
|
+
frontmatter as a `sources:` list (without duplicates)."""
|
|
485
|
+
sources = list(getattr(node, "sources", []) or [])
|
|
486
|
+
if node.source not in sources:
|
|
487
|
+
sources.insert(0, node.source)
|
|
488
|
+
if source and source not in sources:
|
|
489
|
+
sources.append(source)
|
|
490
|
+
node.sources = sources
|
|
491
|
+
self.write_node(node)
|
|
492
|
+
|
|
493
|
+
def write_node(self, node: Node) -> None:
|
|
494
|
+
nodes_dir = self.path / "nodes"
|
|
495
|
+
nodes_dir.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
_atomic_write(self.node_path(node.id), node.to_markdown())
|
|
497
|
+
|
|
498
|
+
def promote_node(self, node_id: str) -> Node | None:
|
|
499
|
+
"""Dual buffer (V2#2): probation -> active after successful dedup check."""
|
|
500
|
+
node = next((n for n in self.read_nodes() if n.id == node_id), None)
|
|
501
|
+
if node is None:
|
|
502
|
+
return None
|
|
503
|
+
node.status = "active"
|
|
504
|
+
self.write_node(node)
|
|
505
|
+
return node
|
|
506
|
+
|
|
507
|
+
def tombstone_node(self, node_id: str) -> Node | None:
|
|
508
|
+
"""Graceful degradation (V2#2): mark the node as forgotten (never hard-delete)."""
|
|
509
|
+
node = next((n for n in self.read_nodes() if n.id == node_id), None)
|
|
510
|
+
if node is None:
|
|
511
|
+
return None
|
|
512
|
+
node.status = "tombstone"
|
|
513
|
+
self.write_node(node)
|
|
514
|
+
return node
|
|
515
|
+
|
|
516
|
+
def read_nodes(self) -> list[Node]:
|
|
517
|
+
nodes_dir = self.path / "nodes"
|
|
518
|
+
if not nodes_dir.exists():
|
|
519
|
+
return []
|
|
520
|
+
out = []
|
|
521
|
+
for p in sorted(nodes_dir.glob("*.md")):
|
|
522
|
+
try:
|
|
523
|
+
node = Node.from_markdown(p.read_text(encoding="utf-8"))
|
|
524
|
+
if node.id == p.stem:
|
|
525
|
+
out.append(node)
|
|
526
|
+
except (ValueError, KeyError):
|
|
527
|
+
continue # skip broken files instead of crashing
|
|
528
|
+
return out
|
|
529
|
+
|
|
530
|
+
def read_node(self, node_id: str) -> Node | None:
|
|
531
|
+
"""Read ONE node by id (report #1): node_path() + from_markdown with
|
|
532
|
+
the same skip-broken tolerance as read_nodes(). A single-node fetch
|
|
533
|
+
must not pay the full read_nodes() glob (0.076 s at live scale)."""
|
|
534
|
+
path = self.node_path(node_id)
|
|
535
|
+
if path is None or not path.exists():
|
|
536
|
+
return None
|
|
537
|
+
try:
|
|
538
|
+
node = Node.from_markdown(path.read_text(encoding="utf-8"))
|
|
539
|
+
return node if node.id == node_id else None
|
|
540
|
+
except (ValueError, KeyError, OSError):
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
# ---------- Embedding-Cache ----------
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def vectors_file(self) -> Path:
|
|
547
|
+
return self.path / "vectors.jsonl"
|
|
548
|
+
|
|
549
|
+
def read_vectors(self) -> dict[str, list[float]]:
|
|
550
|
+
if not self.vectors_file.exists():
|
|
551
|
+
return {}
|
|
552
|
+
out: dict[str, list[float]] = {}
|
|
553
|
+
for line in self.vectors_file.read_text(encoding="utf-8").splitlines():
|
|
554
|
+
if not line.strip():
|
|
555
|
+
continue
|
|
556
|
+
# Audit #17: a corrupt line must not permanently crash the whole
|
|
557
|
+
# store read (read_nodes skips broken files the same way).
|
|
558
|
+
try:
|
|
559
|
+
d = json.loads(line)
|
|
560
|
+
out[d["id"]] = d["vec"]
|
|
561
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
562
|
+
continue
|
|
563
|
+
return out
|
|
564
|
+
|
|
565
|
+
def write_vectors(self, vectors: dict[str, list[float]]) -> None:
|
|
566
|
+
content = "".join(
|
|
567
|
+
_jsonl_dumps({"id": nid, "vec": vec}) + "\n"
|
|
568
|
+
for nid, vec in sorted(vectors.items()))
|
|
569
|
+
_atomic_write(self.vectors_file, content)
|
|
570
|
+
|
|
571
|
+
# Process-wide memo for persist=False reads (report #1): the MCP server is
|
|
572
|
+
# long-lived, but Brain instances are rebuilt per call via runtime.make_brain,
|
|
573
|
+
# so without a memo every search on a cold clone re-embeds ALL nodes
|
|
574
|
+
# (measured 275 s for 1821 nodes on the 2-core VPS — per search). Keyed by
|
|
575
|
+
# (path, vectors.jsonl mtime) so an external cache fill (cron, CLI) or a
|
|
576
|
+
# different brain invalidates it naturally. Bounded to one brain's worth of
|
|
577
|
+
# vectors per path; entries are replaced, never appended unboundedly.
|
|
578
|
+
_VECTOR_MEMO: dict = {}
|
|
579
|
+
|
|
580
|
+
def vectors_for(self, node_ids: set[str], embed_fn, batch_fn=None,
|
|
581
|
+
persist: bool = True) -> dict[str, list[float]]:
|
|
582
|
+
"""Vectors from cache; missing ones are computed and — unless
|
|
583
|
+
`persist=False` — stored. Audit #4: reads nodes/vectors ONCE up front
|
|
584
|
+
instead of per missing ID. Audit #60: when batch_fn is provided, all
|
|
585
|
+
missing nodes are embedded in ONE batch call instead of N sequential
|
|
586
|
+
embed_fn calls (sentence-transformers encodes lists natively).
|
|
587
|
+
`persist=False` (report #1) is the strictly read-only path for the MCP
|
|
588
|
+
server: nothing is written to vectors.jsonl (a cold-cache write into
|
|
589
|
+
the private repo would dirty it and churn the cron rebase); results
|
|
590
|
+
are memoized in-process instead so the NEXT search is warm."""
|
|
591
|
+
vec_file = self.path / "vectors.jsonl"
|
|
592
|
+
memo_key = (str(self.path), vec_file.stat().st_mtime_ns if vec_file.exists() else None)
|
|
593
|
+
memo = Brain._VECTOR_MEMO.get(memo_key)
|
|
594
|
+
if memo is not None:
|
|
595
|
+
return {nid: v for nid, v in memo.items() if nid in node_ids}
|
|
596
|
+
nodes = {n.id: n for n in self.read_nodes()}
|
|
597
|
+
cached = self.read_vectors()
|
|
598
|
+
missing = [nid for nid in node_ids if nid not in cached and nid in nodes]
|
|
599
|
+
if missing and batch_fn is not None:
|
|
600
|
+
vecs = batch_fn([nodes[nid].text for nid in missing])
|
|
601
|
+
for nid, vec in zip(missing, vecs):
|
|
602
|
+
cached[nid] = vec
|
|
603
|
+
dirty = True
|
|
604
|
+
else:
|
|
605
|
+
dirty = False
|
|
606
|
+
for nid in node_ids:
|
|
607
|
+
if nid not in cached:
|
|
608
|
+
node = nodes.get(nid)
|
|
609
|
+
if node is None:
|
|
610
|
+
continue
|
|
611
|
+
cached[nid] = embed_fn(node.text)
|
|
612
|
+
dirty = True
|
|
613
|
+
if dirty and persist:
|
|
614
|
+
self.write_vectors(cached)
|
|
615
|
+
# after a write the mtime changed — memoize under the NEW key so
|
|
616
|
+
# the next persist=False call hits it
|
|
617
|
+
Brain._VECTOR_MEMO[(str(self.path), vec_file.stat().st_mtime_ns)] = dict(cached)
|
|
618
|
+
elif not persist:
|
|
619
|
+
Brain._VECTOR_MEMO[memo_key] = dict(cached)
|
|
620
|
+
return {nid: v for nid, v in cached.items() if nid in node_ids}
|
|
621
|
+
|
|
622
|
+
# ---------- Edges ----------
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def edges_file(self) -> Path:
|
|
626
|
+
return self.path / "edges.jsonl"
|
|
627
|
+
|
|
628
|
+
def read_edges(self, include_rejected: bool = False) -> list[Edge]:
|
|
629
|
+
if not self.edges_file.exists():
|
|
630
|
+
return []
|
|
631
|
+
edges = []
|
|
632
|
+
for line in self.edges_file.read_text(encoding="utf-8").splitlines():
|
|
633
|
+
if not line.strip():
|
|
634
|
+
continue
|
|
635
|
+
# Audit #17: skip-bad-line as in read_nodes — a corrupt line
|
|
636
|
+
# (crash leftover, hand edit) must not make the whole graph API-dead.
|
|
637
|
+
try:
|
|
638
|
+
d = json.loads(line)
|
|
639
|
+
edge = Edge(d["source"], d["target"], d["kind"],
|
|
640
|
+
d.get("pending", False), d["id"],
|
|
641
|
+
valid_from=d.get("valid_from"),
|
|
642
|
+
valid_to=d.get("valid_to"),
|
|
643
|
+
confidence=d.get("confidence"),
|
|
644
|
+
invalidated_by=d.get("invalidated_by"),
|
|
645
|
+
rejected=d.get("rejected", False),
|
|
646
|
+
origin=d.get("origin"), predicate=d.get("predicate"),
|
|
647
|
+
evidence=d.get("evidence", []),
|
|
648
|
+
extracted_validity=d.get("extracted_validity"),
|
|
649
|
+
invalidated_at=d.get("invalidated_at"))
|
|
650
|
+
except (ValueError, KeyError, TypeError):
|
|
651
|
+
continue
|
|
652
|
+
if edge.rejected and not include_rejected:
|
|
653
|
+
continue
|
|
654
|
+
edges.append(edge)
|
|
655
|
+
return edges
|
|
656
|
+
|
|
657
|
+
def write_edges(self, edges: list[Edge]) -> None:
|
|
658
|
+
content = "".join(_jsonl_dumps(e.to_dict()) + "\n" for e in edges)
|
|
659
|
+
_atomic_write(self.edges_file, content)
|
|
660
|
+
|
|
661
|
+
def add_edge(self, edge: Edge) -> None:
|
|
662
|
+
edges = self.read_edges(include_rejected=True)
|
|
663
|
+
edges.append(edge)
|
|
664
|
+
self.write_edges(edges)
|
|
665
|
+
|
|
666
|
+
def invalidate_edge(self, edge_id: str, reason: str | None = None,
|
|
667
|
+
by_edge_id: str | None = None) -> Edge | None:
|
|
668
|
+
"""Invalidate without changing an extracted fact's original validity.
|
|
669
|
+
The edge stays in the file with its full history. `by_edge_id` records
|
|
670
|
+
the provenance of which edge/event invalidated it (V1#1)."""
|
|
671
|
+
with self._lock:
|
|
672
|
+
edges = self.read_edges(include_rejected=True)
|
|
673
|
+
edge = next((e for e in edges if e.id == edge_id and not e.rejected), None)
|
|
674
|
+
if edge is None or edge.is_invalidated:
|
|
675
|
+
return None
|
|
676
|
+
edge.invalidate(_now_iso())
|
|
677
|
+
if by_edge_id:
|
|
678
|
+
edge.invalidated_by = by_edge_id
|
|
679
|
+
self.write_edges(edges)
|
|
680
|
+
return edge
|
|
681
|
+
|
|
682
|
+
def resolve_edge(self, edge_id: str, accept: bool) -> Edge | None:
|
|
683
|
+
with self._lock:
|
|
684
|
+
edges = self.read_edges(include_rejected=True)
|
|
685
|
+
edge = next((e for e in edges if e.id == edge_id and e.pending and not e.rejected
|
|
686
|
+
and not e.is_invalidated), None)
|
|
687
|
+
if edge is None:
|
|
688
|
+
return None
|
|
689
|
+
edge.pending = False
|
|
690
|
+
edge.rejected = not accept
|
|
691
|
+
self.write_edges(edges)
|
|
692
|
+
return edge
|
|
693
|
+
|
|
694
|
+
def restore_edge(self, edge_id: str) -> Edge | None:
|
|
695
|
+
"""Return a saved decision to the inbox without reviving invalidated facts."""
|
|
696
|
+
with self._lock:
|
|
697
|
+
edges = self.read_edges(include_rejected=True)
|
|
698
|
+
edge = next((e for e in edges if e.id == edge_id and not e.pending
|
|
699
|
+
and not e.is_invalidated), None)
|
|
700
|
+
if edge is None:
|
|
701
|
+
return None
|
|
702
|
+
edge.pending = True
|
|
703
|
+
edge.rejected = False
|
|
704
|
+
self.write_edges(edges)
|
|
705
|
+
return edge
|
|
706
|
+
|
|
707
|
+
# ---------- Graph state for the frontend ----------
|
|
708
|
+
|
|
709
|
+
def graph_state(self) -> dict:
|
|
710
|
+
return {
|
|
711
|
+
"nodes": [n.to_dict() for n in self.read_nodes()],
|
|
712
|
+
"edges": [{**e.to_dict(), **({"current": e.is_current,
|
|
713
|
+
"invalidated": e.is_invalidated}
|
|
714
|
+
if e.extracted_validity is not None else {})}
|
|
715
|
+
for e in self.read_edges()],
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
# ---------- Generated table of contents ----------
|
|
719
|
+
|
|
720
|
+
def rebuild_index(self) -> None:
|
|
721
|
+
with self._lock:
|
|
722
|
+
lines = ["# Index", "", "| Idea | Source | Created |", "|---|---|---|"]
|
|
723
|
+
for n in self.read_nodes():
|
|
724
|
+
if n.status == "tombstone":
|
|
725
|
+
continue # forgotten nodes don't belong in the table of contents
|
|
726
|
+
# Audit #55: truncate to 60 chars FIRST, THEN escape — the
|
|
727
|
+
# other way around the slice can cut a \| escape in half and
|
|
728
|
+
# break the table row.
|
|
729
|
+
title = n.text[:60].replace("|", "\\|")
|
|
730
|
+
lines.append(f"| [{title}](nodes/{n.id}.md) | {n.source} | {n.created} |")
|
|
731
|
+
_atomic_write(self.path / "INDEX.md", "\n".join(lines) + "\n")
|