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
@@ -0,0 +1,34 @@
1
+ # When to consult the GraphEngine brain (opt-in snippet)
2
+
3
+ Paste into `CLAUDE.md` / `AGENTS.md` if you want the assistant to reach for
4
+ the brain proactively:
5
+
6
+ ```markdown
7
+ ## Knowledge brain
8
+ An GraphEngine MCP server (tools: search_brain, get_node, neighbors,
9
+ brain_status) is available. Consult it BEFORE starting non-trivial design,
10
+ architecture or research work — it may already hold prior decisions,
11
+ constraints and related ideas. Skip it for routine edits, typo fixes and
12
+ mechanical refactors. `search_brain` scores are rank-fusion scores, not
13
+ similarities. The brain is read-only from here; changes go through the
14
+ owner (`ig ingest`, `ig pending`).
15
+ ```
16
+
17
+ ## If the server runs in write mode (`ig mcp --write`)
18
+
19
+ ```markdown
20
+ ## Knowledge brain (write mode)
21
+ The GraphEngine MCP server also has `remember`, `recall` and `forget`.
22
+ - `remember` — keep a durable finding you would otherwise lose. It is recorded
23
+ with source=agent, so provenance stays visible. Do not use it for notes that
24
+ belong in the repo, the PR or the chat.
25
+ - `recall` — search whose hits COUNT AS USE (they feed the promotion signal).
26
+ Prefer `recall` over `search_brain` when you actually act on the result;
27
+ `search_brain` for pure exploration.
28
+ - `forget` — only with a real reason, and only for a memory that is wrong or
29
+ superseded. It tombstones the node instead of deleting it; the owner can see
30
+ the history. Never use it to "clean up" something you merely dislike.
31
+ ```
32
+
33
+ There is deliberately NO per-command hook: noisy PreToolUse nudges are the
34
+ known anti-pattern that gets MCP tools abandoned.
@@ -0,0 +1,66 @@
1
+ """Pure response shaping for the MCP tools (report #1 §3.4).
2
+
3
+ Deliberately SDK-free: every function here is unit-testable without `mcp`
4
+ installed — the same split that keeps `evals.py` independent of the server.
5
+ All tool payloads flow through these helpers so caps and escaping live in
6
+ exactly one place.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ # Caps (report #1 §3.9): measured 745 B for a compact k=5 search payload;
13
+ # every cap here exists so a single tool call can never blow a context.
14
+ DEFAULT_SNIPPET_CHARS = int(os.environ.get("IG_MCP_MAX_SNIPPET_CHARS", "200"))
15
+ DEFAULT_NEIGHBOR_LIMIT = int(os.environ.get("IG_MCP_MAX_NEIGHBORS", "20"))
16
+ MAX_NEIGHBOR_LIMIT = 50
17
+ DEFAULT_NODE_CHARS = 2000
18
+ MAX_NODE_CHARS = 8000
19
+
20
+ ERROR_HINTS = {
21
+ "brain_missing": "Set IG_BRAIN_PATH to a brain directory (or run `ig init`).",
22
+ "node_not_found": "Use search_brain to find node ids.",
23
+ }
24
+
25
+
26
+ def err(code: str, message: str, hint: str | None = None) -> dict:
27
+ """The error envelope: {ok:false, error:{code,message,hint}}."""
28
+ return {"ok": False,
29
+ "error": {"code": code, "message": message,
30
+ "hint": hint or ERROR_HINTS.get(code)}}
31
+
32
+
33
+ ELLIPSIS = "\u2026"
34
+
35
+
36
+ def _escape(text: str) -> str:
37
+ """Escape the characters that break markdown tables / HTML injection."""
38
+ return (text.replace("|", "\\|")
39
+ .replace("<", "\\<")
40
+ .replace(">", "\\>")
41
+ .replace("&", "\\&"))
42
+
43
+
44
+ def snippet(text: str, limit: int = DEFAULT_SNIPPET_CHARS) -> str:
45
+ """Single-line snippet: newlines collapsed FIRST (a newline in a JSON
46
+ string is legal but destroys table/terminal rendering), then
47
+ TRUNCATE-then-ESCAPE (audit #55 order: a pipe at the cap cannot survive
48
+ unescaped into markdown output)."""
49
+ if not text:
50
+ return ""
51
+ one_line = " ".join(text.split())
52
+ if len(one_line) > limit:
53
+ one_line = one_line[:limit] + ELLIPSIS
54
+ return _escape(one_line)
55
+
56
+
57
+ def node_not_found(node_id: str) -> dict:
58
+ return err("node_not_found", f"No node with id {node_id!r}.")
59
+
60
+
61
+ def invalid(message: str) -> dict:
62
+ return err("invalid_params", message)
63
+
64
+
65
+ def brain_missing(path: str) -> dict:
66
+ return err("brain_missing", f"No brain found at {path!r}.")
@@ -0,0 +1,407 @@
1
+ """MCP server: the brain as a tool for AI assistants.
2
+
3
+ stdio transport only — the brain is single-user and private; an HTTP listener
4
+ would add an auth/exposure surface for zero benefit.
5
+
6
+ READ-ONLY BY DEFAULT, and that is a decision, not an oversight: ingest commits
7
+ AND pushes to a private repo, and model-initiated writes with no human review
8
+ are the single highest-risk thing this surface could do. `ig mcp --write` (or
9
+ `IG_MCP_WRITE=1`) adds the agent memory path — `remember` / `recall` / `forget`
10
+ (Welle C) — which keeps three invariants: provenance (`source="agent"`,
11
+ `origin="agent"`, so "what did a model write?" stays a query), never-destructive
12
+ (`forget` tombstones and invalidates, it never deletes), and one commit per
13
+ write with the reason in the message.
14
+
15
+ Engine construction is LAZY (first tool call, not import): `list_tools` and
16
+ `brain_status` must never pay the sentence-transformers model load. The
17
+ process-wide cache in runtime.make_engine() makes the load a once-per-server
18
+ cost instead of once-per-call.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from functools import wraps
24
+
25
+ from mcp.server.fastmcp import FastMCP
26
+ from mcp.types import ToolAnnotations
27
+
28
+ from . import format as fmt
29
+ from ..runtime import brain_path, make_engine, reset_engine_cache
30
+
31
+ _INSTRUCTIONS_READ = (
32
+ "Consult the brain before starting non-trivial design or research work; "
33
+ "skip it for routine edits. All tools are read-only."
34
+ )
35
+ _INSTRUCTIONS_WRITE = (
36
+ "Consult the brain before starting non-trivial design or research work; "
37
+ "skip it for routine edits. Write tools are ENABLED: `remember` keeps a "
38
+ "durable finding (recorded with source=agent), `recall` is a search whose "
39
+ "hits count as use, and `forget` requires a reason — it tombstones a node "
40
+ "instead of deleting it."
41
+ )
42
+
43
+
44
+ def _write_enabled() -> bool:
45
+ """Write mode is opt-in per server process (CLI `--write` sets this env)."""
46
+ return os.environ.get("IG_MCP_WRITE", "0") == "1"
47
+
48
+
49
+ mcp = FastMCP(
50
+ "graph_engine",
51
+ instructions=_INSTRUCTIONS_WRITE if _write_enabled() else _INSTRUCTIONS_READ,
52
+ )
53
+
54
+ # The write tools are registered ONLY in write mode, so `tools/list` always
55
+ # reflects the server's actual capability (a client that sees a write tool
56
+ # should ask its user before calling it — never for a tool it cannot use).
57
+ _WRITE_REGISTERED = False
58
+ MAX_REMEMBER_CHARS = int(os.environ.get("IG_MCP_MAX_REMEMBER_CHARS", "20000"))
59
+
60
+
61
+ def _cache_vectors() -> bool:
62
+ """DECIDED (2026-09-15): the MCP default is STRICTLY read-only — a
63
+ model-initiated write into the private repo (dirty files → autostash
64
+ churn) must not be the default, and the long-lived stdio server holds
65
+ vectors in the process-wide engine cache anyway. IG_MCP_CACHE_VECTORS=1
66
+ opts back in to disk-cache filling (slower first search per clone)."""
67
+ return os.environ.get("IG_MCP_CACHE_VECTORS", "0") == "1"
68
+
69
+
70
+ def _access_guard(fn, action: str):
71
+ @wraps(fn)
72
+ def guarded(*args, **kwargs):
73
+ from .. import access, runtime, namespaces
74
+ graph_token = namespaces.request_graph.set(namespaces.selected())
75
+ try:
76
+ if access.policy_path():
77
+ runtime.make_brain().authorize(action)
78
+ return fn(*args, **kwargs)
79
+ except PermissionError:
80
+ return fmt.err("forbidden", "Access denied")
81
+ finally:
82
+ namespaces.request_graph.reset(graph_token)
83
+ return guarded
84
+
85
+
86
+ def _tool(fn):
87
+ """Register with the readOnlyHint annotation so untrusted-server clients
88
+ skip approval prompts."""
89
+ return mcp.tool(annotations={"readOnlyHint": True})(_access_guard(fn, "read"))
90
+
91
+
92
+ def _hits_payload(brain, hits: list[tuple[str, float]]) -> list[dict]:
93
+ """Shape retrieval hits for a tool response (search_brain AND recall)."""
94
+ id2node = {n.id: n for n in brain.read_nodes()}
95
+ results = []
96
+ for nid, score in hits:
97
+ node = id2node.get(nid)
98
+ if node is None:
99
+ continue
100
+ results.append({
101
+ "id": node.id,
102
+ "score": round(float(score), 4),
103
+ "snippet": fmt.snippet(node.text),
104
+ "status": node.status,
105
+ "type": node.ntype,
106
+ "tags": node.tags,
107
+ "created": node.created,
108
+ "recall_count": node.recall_count or 0,
109
+ })
110
+ return results
111
+
112
+
113
+ @_tool
114
+ def search_brain(query: str, k: int = 5) -> dict:
115
+ """Hybrid search (dense + BM25, RRF-fused) over the knowledge brain.
116
+
117
+ `score` is a rank-fusion score (reciprocal-rank fusion), NOT a similarity
118
+ — do not compare it across queries or read it as a confidence.
119
+ """
120
+ query = (query or "").strip()
121
+ if not query:
122
+ return fmt.invalid("query must be a non-empty string")
123
+ if len(query) > 500:
124
+ return fmt.invalid("query exceeds 500 characters")
125
+ if not isinstance(k, int) or k < 1 or k > 20:
126
+ return fmt.invalid("k must be an integer between 1 and 20")
127
+ brain = brain_path()
128
+ if not os.path.isdir(brain):
129
+ return fmt.brain_missing(brain)
130
+ try:
131
+ from ..retrieval import retrieve
132
+ engine = make_engine()
133
+ hits = retrieve(engine, query, k=k, persist=_cache_vectors())
134
+ except RuntimeError as exc: # embedder unavailable etc.
135
+ return fmt.err("embedder_unavailable", str(exc)[-200:])
136
+ except Exception as exc: # noqa: BLE001 — the envelope contract:
137
+ return fmt.err("internal", # a raw traceback over JSON-RPC surfaces
138
+ f"{type(exc).__name__}: {exc}") # as an opaque error
139
+ if not hits:
140
+ return {"ok": True, "query": query, "count": 0, "results": [],
141
+ "hint": f"No node matched. Try fewer/more specific terms; "
142
+ f"the brain has {len(engine.brain.read_nodes())} nodes."}
143
+ results = _hits_payload(engine.brain, hits)
144
+ return {"ok": True, "query": query, "count": len(results),
145
+ "results": results}
146
+
147
+
148
+ @_tool
149
+ def get_node(id: str, max_chars: int = fmt.DEFAULT_NODE_CHARS) -> dict:
150
+ """One node by id: full text (capped) + its live edges."""
151
+ node_id = (id or "").strip()
152
+ if not node_id:
153
+ return fmt.invalid("id must be a non-empty string")
154
+ if not isinstance(max_chars, int) or max_chars < 100 or max_chars > fmt.MAX_NODE_CHARS:
155
+ return fmt.invalid(f"max_chars must be an integer between 100 and "
156
+ f"{fmt.MAX_NODE_CHARS}")
157
+ from ..runtime import make_brain
158
+ brain = make_brain()
159
+ node = brain.read_node(node_id)
160
+ if node is None:
161
+ return fmt.node_not_found(node_id)
162
+ text = node.text or ""
163
+ edges = [e for e in brain.read_edges()
164
+ if e.is_current and not e.rejected
165
+ and node_id in (e.source, e.target)]
166
+ out_edges = []
167
+ id2node = {n.id: n for n in brain.read_nodes()}
168
+ for e in edges:
169
+ other = e.target if e.source == node_id else e.source
170
+ other_node = id2node.get(other)
171
+ out_edges.append({
172
+ "id": e.id,
173
+ "kind": e.kind,
174
+ "direction": "out" if e.source == node_id else "in",
175
+ "other_id": other,
176
+ "other_snippet": fmt.snippet(other_node.text, 80) if other_node else "?",
177
+ "pending": e.pending,
178
+ "confidence": e.confidence,
179
+ "valid_from": e.valid_from,
180
+ "valid_to": e.valid_to,
181
+ **({"predicate": e.predicate,
182
+ "evidence": [{"node_id": item.get("node_id"),
183
+ "text": fmt.snippet(item.get("text", ""), 200)}
184
+ for item in e.evidence[:5]]} if e.kind == "fact" else {}),
185
+ })
186
+ return {"ok": True,
187
+ "node": {"id": node.id, "text": text[:max_chars],
188
+ "created": node.created, "source": node.source,
189
+ "sources": node.sources, "type": node.ntype,
190
+ "status": node.status, "tags": node.tags,
191
+ **({"entity_name": fmt.snippet(node.entity_name or "", 200),
192
+ "entity_type": node.entity_type,
193
+ "aliases": [fmt.snippet(a, 200) for a in node.aliases[:20]]}
194
+ if node.ntype == "entity" else {})},
195
+ "truncated": len(text) > max_chars,
196
+ "text_chars": len(text),
197
+ "edges": out_edges,
198
+ "degree": len(out_edges)}
199
+
200
+
201
+ @_tool
202
+ def neighbors(id: str, hops: int = 1, kinds: list[str] | None = None,
203
+ include_pending: bool = False,
204
+ limit: int = fmt.DEFAULT_NEIGHBOR_LIMIT) -> dict:
205
+ """Graph neighborhood of a node over UNDIRECTED live edges (1-2 hops).
206
+
207
+ This is the graph question vector search cannot answer: what is this
208
+ idea actually connected to?
209
+ """
210
+ node_id = (id or "").strip()
211
+ if not node_id:
212
+ return fmt.invalid("id must be a non-empty string")
213
+ if not isinstance(hops, int) or hops < 1 or hops > 2:
214
+ return fmt.invalid("hops must be 1 or 2")
215
+ if not isinstance(limit, int) or limit < 1 or limit > fmt.MAX_NEIGHBOR_LIMIT:
216
+ return fmt.invalid(f"limit must be an integer between 1 and "
217
+ f"{fmt.MAX_NEIGHBOR_LIMIT}")
218
+ from ..runtime import make_brain
219
+ from ..graph import neighbors as graph_neighbors
220
+ brain = make_brain()
221
+ if brain.read_node(node_id) is None:
222
+ return fmt.node_not_found(node_id)
223
+ found = graph_neighbors(brain, node_id, hops=hops, kinds=kinds,
224
+ include_pending=include_pending, limit=limit)
225
+ kinds_present: dict[str, int] = {}
226
+ for nb in found:
227
+ kinds_present[nb.kind] = kinds_present.get(nb.kind, 0) + 1
228
+ return {"ok": True, "id": node_id, "hops": hops,
229
+ "count": len(found),
230
+ "truncated": len(found) >= limit,
231
+ "neighbors": [{"id": nb.id, "snippet": fmt.snippet(nb.snippet),
232
+ "kind": nb.kind, "direction": nb.direction,
233
+ "pending": nb.pending,
234
+ "confidence": nb.confidence, "hops": nb.hops,
235
+ **({"predicate": nb.predicate} if nb.predicate else {})}
236
+ for nb in found],
237
+ "kinds_present": kinds_present}
238
+
239
+
240
+ @_tool
241
+ def brain_status() -> dict:
242
+ """Cheap orientation: size, connectivity, pending-review load.
243
+
244
+ Returns the brain path BASENAME only — the brain is private and this
245
+ string lands in a model's context.
246
+ """
247
+ from ..hygiene import connectivity, status_counts
248
+ brain_p = brain_path()
249
+ if not os.path.isdir(brain_p):
250
+ return fmt.brain_missing(brain_p)
251
+ from ..runtime import make_brain
252
+ brain = make_brain()
253
+ conn = connectivity(brain)
254
+ counts = status_counts(brain)
255
+ pending = sum(1 for e in brain.read_edges() if e.pending)
256
+ return {"ok": True, "total": conn.total, "edges": conn.edges,
257
+ "max_degree": conn.max_degree,
258
+ "mean_degree": round(conn.mean_degree, 2),
259
+ "orphans": len(conn.orphans), "islands": len(conn.islands),
260
+ "weak": len(conn.weak), "pending_edges": pending,
261
+ "status": {k: counts.get(k, 0)
262
+ for k in ("active", "probation", "stale", "tombstone")},
263
+ "brain_path_basename": os.path.basename(brain_p.rstrip("/"))}
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Write path (Welle C) — registered ONLY when write mode is on
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def _write_disabled() -> dict:
271
+ return fmt.err("write_disabled",
272
+ "This MCP server is read-only. Start it with `ig mcp --write` "
273
+ "(or IG_MCP_WRITE=1) to enable remember/recall/forget.")
274
+
275
+
276
+ def _write_guard() -> dict | None:
277
+ """Common preconditions for a write tool: mode on + brain present."""
278
+ if not _write_enabled():
279
+ return _write_disabled()
280
+ path = brain_path()
281
+ if not os.path.isdir(path):
282
+ return fmt.brain_missing(path)
283
+ return None
284
+
285
+
286
+ def remember(text: str, tags: list[str] | None = None) -> dict:
287
+ """Store one note in the brain on the agent's behalf (write mode only).
288
+
289
+ The node is recorded with `source="agent"` — "what did a model write?" stays
290
+ a query. Dedupe-aware: a near-duplicate merges into the existing node (the
291
+ response says so) instead of creating a second one. One commit per write.
292
+ """
293
+ text = (text or "").strip()
294
+ if not text:
295
+ return fmt.invalid("text must be a non-empty string")
296
+ if len(text) > MAX_REMEMBER_CHARS:
297
+ return fmt.invalid(f"text exceeds {MAX_REMEMBER_CHARS} characters")
298
+ if tags is not None and not isinstance(tags, list):
299
+ return fmt.invalid("tags must be a list of strings")
300
+ blocked = _write_guard()
301
+ if blocked:
302
+ return blocked
303
+ from ..agent_memory import remember as _remember
304
+ try:
305
+ res = _remember(make_engine(), text, tags=tags)
306
+ except ValueError as exc:
307
+ return fmt.invalid(str(exc)[-200:])
308
+ except Exception as exc: # noqa: BLE001 — envelope contract
309
+ return fmt.err("internal", f"{type(exc).__name__}: {exc}")
310
+ out = {"ok": True, "node_id": res["node_id"], "status": res["status"],
311
+ "edges": res["edges"], "duplicate": res["duplicate"]}
312
+ if res["duplicate"]:
313
+ out["hint"] = ("Near-duplicate: merged into the existing node "
314
+ "(no second node created).")
315
+ return out
316
+
317
+
318
+ def recall(query: str, k: int = 5) -> dict:
319
+ """Search the brain — and COUNT the hits as use (write mode only).
320
+
321
+ This is the difference to `search_brain`: a recall feeds the promotion
322
+ signal (`recall_count`), so a memory the agent actually relied on can later
323
+ be promoted by the dream pass. Use `search_brain` for pure exploration.
324
+ """
325
+ query = (query or "").strip()
326
+ if not query:
327
+ return fmt.invalid("query must be a non-empty string")
328
+ if len(query) > 500:
329
+ return fmt.invalid("query exceeds 500 characters")
330
+ if not isinstance(k, int) or k < 1 or k > 20:
331
+ return fmt.invalid("k must be an integer between 1 and 20")
332
+ blocked = _write_guard()
333
+ if blocked:
334
+ return blocked
335
+ from ..agent_memory import recall as _recall
336
+ try:
337
+ engine = make_engine()
338
+ hits = _recall(engine, query, k=k, persist=_cache_vectors())
339
+ results = _hits_payload(engine.brain, hits)
340
+ except RuntimeError as exc:
341
+ return fmt.err("embedder_unavailable", str(exc)[-200:])
342
+ except Exception as exc: # noqa: BLE001
343
+ return fmt.err("internal", f"{type(exc).__name__}: {exc}")
344
+ return {"ok": True, "query": query, "count": len(results),
345
+ "tracked": True, "results": results}
346
+
347
+
348
+ def forget(id: str, reason: str) -> dict:
349
+ """Remove a node from every live view — WITHOUT deleting it (write mode only).
350
+
351
+ Sets the node to `tombstone` (it leaves search, graph and hygiene) and
352
+ invalidates its live edges; the file and the history stay. `reason` is
353
+ mandatory and is recorded in the commit message.
354
+ """
355
+ node_id = (id or "").strip()
356
+ if not node_id:
357
+ return fmt.invalid("id must be a non-empty string")
358
+ if not (reason or "").strip():
359
+ return fmt.invalid("reason must be a non-empty string — it is recorded "
360
+ "in the commit message")
361
+ blocked = _write_guard()
362
+ if blocked:
363
+ return blocked
364
+ from ..runtime import make_brain
365
+ from ..agent_memory import forget as _forget
366
+ brain = make_brain()
367
+ if brain.read_node(node_id) is None:
368
+ return fmt.node_not_found(node_id)
369
+ try:
370
+ res = _forget(brain, node_id, reason=reason)
371
+ except ValueError as exc:
372
+ return fmt.invalid(str(exc)[-200:])
373
+ except Exception as exc: # noqa: BLE001
374
+ return fmt.err("internal", f"{type(exc).__name__}: {exc}")
375
+ return {"ok": True, "node_id": res["node_id"], "status": res["status"],
376
+ "edges_invalidated": res["edges_invalidated"],
377
+ "already_forgotten": res.get("already", False),
378
+ "hint": "Tombstoned, not deleted: the node file and its history remain."}
379
+
380
+
381
+ def _write_tool(fn):
382
+ """Register a WRITE tool: `readOnlyHint: False` so clients ask their user
383
+ before letting a model write into the private brain."""
384
+ return mcp.tool(annotations=ToolAnnotations(readOnlyHint=False))(_access_guard(fn, "write"))
385
+
386
+
387
+ def register_write_tools() -> int:
388
+ """Register remember/recall/forget. Called by `ig mcp --write`. Idempotent."""
389
+ global _WRITE_REGISTERED
390
+ if not _write_enabled():
391
+ raise RuntimeError("write mode is off — set IG_MCP_WRITE=1 first")
392
+ if _WRITE_REGISTERED:
393
+ return 0
394
+ for fn in (remember, recall, forget):
395
+ _write_tool(fn)
396
+ _WRITE_REGISTERED = True
397
+ return 3
398
+
399
+
400
+ def main() -> None:
401
+ """Entry point (`ig mcp` / `ig-mcp`): stdio JSON-RPC, nothing else on
402
+ stdout — stdout IS the transport."""
403
+ mcp.run(transport="stdio")
404
+
405
+
406
+ if __name__ == "__main__":
407
+ main()