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
|
@@ -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()
|