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/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")