memgraph-context-graph 0.5.0__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.
- memgraph_context_graph/__init__.py +71 -0
- memgraph_context_graph/adapters/__init__.py +0 -0
- memgraph_context_graph/adapters/_identity.py +520 -0
- memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
- memgraph_context_graph/adapters/_spec.py +441 -0
- memgraph_context_graph/adapters/antigravity_cli.py +152 -0
- memgraph_context_graph/adapters/claude.py +297 -0
- memgraph_context_graph/adapters/claude_code.py +216 -0
- memgraph_context_graph/adapters/claude_code_memory.py +181 -0
- memgraph_context_graph/adapters/codex.py +118 -0
- memgraph_context_graph/adapters/codex_memory.py +185 -0
- memgraph_context_graph/adapters/copilot_cli.py +236 -0
- memgraph_context_graph/adapters/cursor.py +189 -0
- memgraph_context_graph/adapters/grok.py +187 -0
- memgraph_context_graph/adapters/openai.py +240 -0
- memgraph_context_graph/adapters/opencode.py +168 -0
- memgraph_context_graph/cli.py +845 -0
- memgraph_context_graph/events.py +210 -0
- memgraph_context_graph/hooks/__init__.py +1 -0
- memgraph_context_graph/hooks/cli.py +184 -0
- memgraph_context_graph/hooks/runner.py +311 -0
- memgraph_context_graph/hooks/runtime_plugin.py +85 -0
- memgraph_context_graph/link.py +50 -0
- memgraph_context_graph/mcp_server.py +102 -0
- memgraph_context_graph/memory_backend.py +144 -0
- memgraph_context_graph/protocols.py +67 -0
- memgraph_context_graph/spaces.py +214 -0
- memgraph_context_graph/tools.py +130 -0
- memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
- memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
- memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
- memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
- memgraph_context_graph-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Generic command-hook CLI runner, shared by every registered runtime plugin.
|
|
2
|
+
|
|
3
|
+
Every command-hook adapter (``adapters/codex.py``, ``adapters/claude_code.py``)
|
|
4
|
+
used to duplicate this exact stdin-loading / connector-construction / run
|
|
5
|
+
sequence, differing only in which adapter class to instantiate and a handful
|
|
6
|
+
of env var names. This collapses that into one implementation driven by a
|
|
7
|
+
``RuntimeCLIPlugin`` (see ``runtime_plugin.py``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from typing import TYPE_CHECKING, Any, TypedDict
|
|
18
|
+
|
|
19
|
+
from memgraph_context_graph.events import ToolStartEvent
|
|
20
|
+
from memgraph_context_graph.link import AgentLink
|
|
21
|
+
from memgraph_context_graph.protocols import GraphConnector
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Sequence
|
|
25
|
+
|
|
26
|
+
from memgraph_context_graph.events import Event
|
|
27
|
+
from memgraph_context_graph.hooks.runtime_plugin import RuntimeCLIPlugin
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_payload(stream: Any | None = None) -> dict[str, Any]:
|
|
31
|
+
"""Read one JSON hook payload from a text stream (stdin by default)."""
|
|
32
|
+
if stream is None:
|
|
33
|
+
stream = sys.stdin
|
|
34
|
+
raw = stream.read()
|
|
35
|
+
if not raw.strip():
|
|
36
|
+
return {}
|
|
37
|
+
payload = json.loads(raw)
|
|
38
|
+
if not isinstance(payload, dict):
|
|
39
|
+
msg = "Hook payload must be a JSON object"
|
|
40
|
+
raise TypeError(msg)
|
|
41
|
+
return payload
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_link(connector_names: Iterable[str] = (), *, memgraph_env: dict[str, str] | None = None) -> AgentLink:
|
|
45
|
+
"""Create an AgentLink with optional connectors named by CLI/config.
|
|
46
|
+
|
|
47
|
+
Runtime-agnostic: connectors (skills-graph, actions-graph, sessions-graph, resources-graph)
|
|
48
|
+
are graph components, not tied to any particular runtime adapter.
|
|
49
|
+
"""
|
|
50
|
+
link = AgentLink()
|
|
51
|
+
for connector_name in connector_names:
|
|
52
|
+
normalized = connector_name.strip().replace("-", "_")
|
|
53
|
+
if not normalized:
|
|
54
|
+
continue
|
|
55
|
+
if normalized == "skills_graph":
|
|
56
|
+
_add_skills_graph_connector(link, memgraph_env)
|
|
57
|
+
elif normalized == "actions_graph":
|
|
58
|
+
_add_actions_graph_connector(link, memgraph_env)
|
|
59
|
+
elif normalized == "sessions_graph":
|
|
60
|
+
_add_sessions_graph_connector(link, memgraph_env)
|
|
61
|
+
elif normalized == "resources_graph":
|
|
62
|
+
_add_resources_graph_connector(link, memgraph_env)
|
|
63
|
+
else:
|
|
64
|
+
msg = f"Unsupported connector: {connector_name}"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
return link
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_SCHEMA_COMPONENTS = {
|
|
70
|
+
"skills_graph": ("skills_graph", "SkillGraph"),
|
|
71
|
+
"actions_graph": ("actions_graph", "ActionsGraph"),
|
|
72
|
+
"sessions_graph": ("sessions_graph", "SessionsGraph"),
|
|
73
|
+
"resources_graph": ("resources_graph", "ResourcesGraph"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def setup_connector_schemas(connector_names: Iterable[str], *, memgraph_env: dict[str, str] | None = None) -> None:
|
|
78
|
+
"""Create the Memgraph schema (indexes, constraints) for each named connector.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: if a connector name is not supported.
|
|
82
|
+
"""
|
|
83
|
+
kwargs = _memgraph_kwargs(memgraph_env)
|
|
84
|
+
for connector_name in connector_names:
|
|
85
|
+
normalized = connector_name.strip().replace("-", "_")
|
|
86
|
+
if not normalized:
|
|
87
|
+
continue
|
|
88
|
+
if normalized not in _SCHEMA_COMPONENTS:
|
|
89
|
+
raise ValueError(f"Unsupported connector: {connector_name}")
|
|
90
|
+
module_name, class_name = _SCHEMA_COMPONENTS[normalized]
|
|
91
|
+
component_class = getattr(importlib.import_module(module_name), class_name)
|
|
92
|
+
component_class(**kwargs).setup()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _env_prefix(runtime_name: str) -> str:
|
|
96
|
+
return "MEMGRAPH_CONTEXT_GRAPH_" + runtime_name.strip().replace("-", "_").upper()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _connectors_from_env(runtime_name: str) -> list[str]:
|
|
100
|
+
value = os.environ.get(f"{_env_prefix(runtime_name)}_CONNECTORS", "")
|
|
101
|
+
return [part.strip() for part in value.split(",") if part.strip()]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _debug_log(runtime_name: str, message: str) -> None:
|
|
105
|
+
if os.environ.get(f"{_env_prefix(runtime_name)}_DEBUG") == "1":
|
|
106
|
+
print(message, file=sys.stderr)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int:
|
|
110
|
+
"""Read one hook payload from stdin, run it through *plugin*, print a response.
|
|
111
|
+
|
|
112
|
+
This is what ``memgraph-context-graph hook run <runtime>`` invokes: parse
|
|
113
|
+
the shared CLI flags, resolve Memgraph config, translate the payload via
|
|
114
|
+
``plugin.adapter_class``, and write back whatever ``plugin.response_for_payload``
|
|
115
|
+
says the runtime expects -- identical flow for every registered runtime.
|
|
116
|
+
"""
|
|
117
|
+
parser = argparse.ArgumentParser(description=f"Bridge {plugin.name} hooks to memgraph-context-graph.")
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--connector",
|
|
120
|
+
action="append",
|
|
121
|
+
default=None,
|
|
122
|
+
help="Graph connector to enable. Currently supported: skills-graph, actions-graph, sessions-graph, resources-graph.",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--session-id",
|
|
126
|
+
default=None,
|
|
127
|
+
help="Override the session id from the hook payload.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--event-name",
|
|
131
|
+
default=None,
|
|
132
|
+
help="Supply the event name when the runtime payload omits it.",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument("--memgraph-url", default=None, help="Memgraph Bolt URL. Overrides config file value.")
|
|
135
|
+
parser.add_argument("--memgraph-user", default=None, help="Memgraph username. Overrides config file value.")
|
|
136
|
+
parser.add_argument("--memgraph-password", default=None, help="Memgraph password. Overrides config file value.")
|
|
137
|
+
parser.add_argument("--memgraph-database", default=None, help="Memgraph database. Overrides config file value.")
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--strict",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Return a non-zero status if the hook payload cannot be recorded.",
|
|
142
|
+
)
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
|
|
145
|
+
connector_names = args.connector
|
|
146
|
+
if connector_names is None:
|
|
147
|
+
connector_names = _connectors_from_env(plugin.name)
|
|
148
|
+
|
|
149
|
+
from memgraph_context_graph.adapters._identity import resolve_memgraph_env
|
|
150
|
+
|
|
151
|
+
payload: dict[str, Any] = {}
|
|
152
|
+
try:
|
|
153
|
+
# Inside the guard: an edition the config can't resolve skips the hook like any other failure.
|
|
154
|
+
memgraph_env = resolve_memgraph_env(
|
|
155
|
+
url=args.memgraph_url,
|
|
156
|
+
user=args.memgraph_user,
|
|
157
|
+
password=args.memgraph_password,
|
|
158
|
+
database=args.memgraph_database,
|
|
159
|
+
)
|
|
160
|
+
payload = load_payload()
|
|
161
|
+
if args.event_name is not None:
|
|
162
|
+
payload.setdefault("hook_event_name", args.event_name)
|
|
163
|
+
link = create_link(connector_names, memgraph_env=memgraph_env)
|
|
164
|
+
tool_starts = _ToolStarts()
|
|
165
|
+
link.add_connector(tool_starts)
|
|
166
|
+
adapter = plugin.adapter_class(link, session_id=args.session_id)
|
|
167
|
+
adapter.handle_payload(payload)
|
|
168
|
+
_print_response(plugin, payload, connector_names, before_tool=_context_before_tool(link, tool_starts.events))
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
strict_env = os.environ.get(f"{_env_prefix(plugin.name)}_STRICT") == "1"
|
|
171
|
+
if args.strict or strict_env:
|
|
172
|
+
raise
|
|
173
|
+
_print_response(plugin, payload, connector_names)
|
|
174
|
+
_debug_log(plugin.name, f"memgraph-context-graph {plugin.name} hook skipped: {exc}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _print_response(
|
|
179
|
+
plugin: RuntimeCLIPlugin, payload: dict[str, Any], connector_names: list[str], *, before_tool: Sequence[str] = ()
|
|
180
|
+
) -> None:
|
|
181
|
+
response = plugin.response_for_payload(payload) or {}
|
|
182
|
+
if payload.get("hook_event_name") == "SessionStart":
|
|
183
|
+
response.update(session_start_context(connector_names, payload))
|
|
184
|
+
render = getattr(plugin, "context_before_tool_response", None)
|
|
185
|
+
if before_tool and render is not None:
|
|
186
|
+
response.update(render("\n".join(before_tool)) or {})
|
|
187
|
+
if response:
|
|
188
|
+
print(json.dumps(response))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _ToolStarts(GraphConnector):
|
|
192
|
+
"""Keeps the tool-start events one payload produced, to ask the connectors about afterwards."""
|
|
193
|
+
|
|
194
|
+
def __init__(self) -> None:
|
|
195
|
+
self.events: list[ToolStartEvent] = []
|
|
196
|
+
|
|
197
|
+
def supports(self, event: Event) -> bool:
|
|
198
|
+
return isinstance(event, ToolStartEvent)
|
|
199
|
+
|
|
200
|
+
def on_event(self, event: Event) -> None:
|
|
201
|
+
if isinstance(event, ToolStartEvent):
|
|
202
|
+
self.events.append(event)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _context_before_tool(link: AgentLink, events: list[ToolStartEvent]) -> list[str]:
|
|
206
|
+
"""Every connector's line for the tools about to run; a connector that fails adds nothing."""
|
|
207
|
+
lines: list[str] = []
|
|
208
|
+
for event in events:
|
|
209
|
+
for connector in link.connectors:
|
|
210
|
+
try:
|
|
211
|
+
line = connector.context_before_tool(event)
|
|
212
|
+
except Exception:
|
|
213
|
+
line = None
|
|
214
|
+
if line:
|
|
215
|
+
lines.append(line)
|
|
216
|
+
return lines
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def session_start_context(connector_names: list[str], payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
220
|
+
"""The SessionStart output telling the model which memory tools it has; empty when none.
|
|
221
|
+
|
|
222
|
+
Recall is announced in one line and never pushes retrieved content: the
|
|
223
|
+
model calls it when a question needs memory (#394). The ``memory`` tool
|
|
224
|
+
adds its index of the user's memory files instead, standing in for the
|
|
225
|
+
harness's own memory it replaces (#484). Claude Code and Codex read the
|
|
226
|
+
same shape.
|
|
227
|
+
"""
|
|
228
|
+
from memgraph_context_graph.tools import session_hints
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
hints = session_hints(connector_names, payload=payload)
|
|
232
|
+
except Exception: # A tool that fails to load must not fail the hook.
|
|
233
|
+
return {}
|
|
234
|
+
if not hints:
|
|
235
|
+
return {}
|
|
236
|
+
return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "\n".join(hints)}}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _add_skills_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
|
|
240
|
+
try:
|
|
241
|
+
from skills_graph import SkillGraph
|
|
242
|
+
from skills_graph.connector import SkillGraphConnector
|
|
243
|
+
except ImportError as exc:
|
|
244
|
+
msg = "skills-graph is required for the skills-graph connector"
|
|
245
|
+
raise ImportError(msg) from exc
|
|
246
|
+
|
|
247
|
+
kwargs = _memgraph_kwargs(memgraph_env)
|
|
248
|
+
graph = SkillGraph(**kwargs)
|
|
249
|
+
link.add_connector(SkillGraphConnector(graph))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _add_sessions_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
|
|
253
|
+
try:
|
|
254
|
+
from sessions_graph import SessionsGraph
|
|
255
|
+
from sessions_graph.connector import SessionsGraphConnector
|
|
256
|
+
except ImportError as exc:
|
|
257
|
+
msg = "sessions-graph is required for the sessions-graph connector"
|
|
258
|
+
raise ImportError(msg) from exc
|
|
259
|
+
|
|
260
|
+
from memgraph_context_graph.adapters._identity import resolve_auto_reconcile
|
|
261
|
+
|
|
262
|
+
kwargs = _memgraph_kwargs(memgraph_env)
|
|
263
|
+
graph = SessionsGraph(**kwargs)
|
|
264
|
+
link.add_connector(SessionsGraphConnector(graph, auto_reconcile=resolve_auto_reconcile()))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _add_actions_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
|
|
268
|
+
try:
|
|
269
|
+
from actions_graph import ActionsGraph
|
|
270
|
+
from actions_graph.connector import ActionsGraphConnector
|
|
271
|
+
except ImportError as exc:
|
|
272
|
+
msg = "actions-graph is required for the actions-graph connector"
|
|
273
|
+
raise ImportError(msg) from exc
|
|
274
|
+
|
|
275
|
+
kwargs = _memgraph_kwargs(memgraph_env)
|
|
276
|
+
graph = ActionsGraph(**kwargs)
|
|
277
|
+
link.add_connector(ActionsGraphConnector(graph))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _add_resources_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
|
|
281
|
+
try:
|
|
282
|
+
from resources_graph import ResourcesGraph
|
|
283
|
+
from resources_graph.connector import ResourcesGraphConnector
|
|
284
|
+
except ImportError as exc:
|
|
285
|
+
msg = "resources-graph is required for the resources-graph connector"
|
|
286
|
+
raise ImportError(msg) from exc
|
|
287
|
+
|
|
288
|
+
kwargs = _memgraph_kwargs(memgraph_env)
|
|
289
|
+
link.add_connector(ResourcesGraphConnector(ResourcesGraph(**kwargs)))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# A closed key set (never `memgraph`) so `Component(**kwargs)` below can't be
|
|
293
|
+
# read as colliding with that parameter's `Memgraph | None` type, unlike a
|
|
294
|
+
# plain `dict[str, str]`.
|
|
295
|
+
class _MemgraphKwargs(TypedDict, total=False):
|
|
296
|
+
url: str
|
|
297
|
+
username: str
|
|
298
|
+
password: str
|
|
299
|
+
database: str
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _memgraph_kwargs(memgraph_env: dict[str, str] | None) -> _MemgraphKwargs:
|
|
303
|
+
"""Convert resolved memgraph env dict to kwargs for graph component constructors."""
|
|
304
|
+
if memgraph_env is None:
|
|
305
|
+
return {}
|
|
306
|
+
return {
|
|
307
|
+
"url": memgraph_env["MEMGRAPH_URL"],
|
|
308
|
+
"username": memgraph_env["MEMGRAPH_USER"],
|
|
309
|
+
"password": memgraph_env["MEMGRAPH_PASSWORD"],
|
|
310
|
+
"database": memgraph_env["MEMGRAPH_DATABASE"],
|
|
311
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Registry for command-hook runtime plugins.
|
|
2
|
+
|
|
3
|
+
A command-hook runtime (Codex, Claude Code, ...) registers itself by exposing
|
|
4
|
+
an object with this shape under the ``memgraph_context_graph.runtimes`` entry
|
|
5
|
+
point group, in its own package's ``pyproject.toml``:
|
|
6
|
+
|
|
7
|
+
[project.entry-points."memgraph_context_graph.runtimes"]
|
|
8
|
+
codex = "memgraph_context_graph.adapters.codex:PLUGIN"
|
|
9
|
+
|
|
10
|
+
No changes to memgraph-context-graph itself are needed to add a new runtime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
_ENTRY_POINT_GROUP = "memgraph_context_graph.runtimes"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class RuntimeCLIPlugin(Protocol):
|
|
26
|
+
"""What a runtime must supply to be usable by memgraph-context-graph's CLI.
|
|
27
|
+
|
|
28
|
+
``init`` is optional -- a runtime that has no project-local hook-config
|
|
29
|
+
file to generate (or hasn't implemented one yet) may omit it; callers
|
|
30
|
+
should use ``getattr(plugin, "init", None)`` rather than assume it exists.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Both read-only: a plain mutable attribute here would require every
|
|
34
|
+
# plugin's own fields to be writable with these exact types (invariance),
|
|
35
|
+
# rejecting real plugins that are frozen dataclasses (name) or declare a
|
|
36
|
+
# narrower adapter_class (type[RuntimeAdapter] instead of type[Any]).
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str: ...
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def adapter_class(self) -> type[Any]: ...
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def probe_payload(self) -> Mapping[str, Any]: ...
|
|
45
|
+
|
|
46
|
+
def response_for_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: ...
|
|
47
|
+
|
|
48
|
+
def build_hooks_config(self, command: str, *, timeout: int = 30) -> dict[str, Any]: ...
|
|
49
|
+
|
|
50
|
+
# Deliberately not a protocol member beyond this point: `init` is optional
|
|
51
|
+
# (see class docstring) and accessed via `getattr(plugin, "init", None)`,
|
|
52
|
+
# never through a RuntimeCLIPlugin-typed reference.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UnknownRuntimeError(KeyError):
|
|
56
|
+
"""Raised when no plugin is registered for a requested runtime name."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _normalize(name: str) -> str:
|
|
60
|
+
return name.strip().replace("_", "-")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_runtime_plugins() -> dict[str, RuntimeCLIPlugin]:
|
|
64
|
+
"""Discover every registered runtime plugin, keyed by its normalized ``name``."""
|
|
65
|
+
plugins: dict[str, RuntimeCLIPlugin] = {}
|
|
66
|
+
for entry_point in metadata.entry_points(group=_ENTRY_POINT_GROUP):
|
|
67
|
+
plugin = entry_point.load()
|
|
68
|
+
plugins[_normalize(plugin.name)] = plugin
|
|
69
|
+
return plugins
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_runtime_plugin(name: str) -> RuntimeCLIPlugin:
|
|
73
|
+
"""Look up a registered runtime plugin by name.
|
|
74
|
+
|
|
75
|
+
Raises ``UnknownRuntimeError`` (listing what *is* registered) if none
|
|
76
|
+
matches -- the CLI catches this to print a clear error instead of a
|
|
77
|
+
traceback.
|
|
78
|
+
"""
|
|
79
|
+
plugins = load_runtime_plugins()
|
|
80
|
+
normalized = _normalize(name)
|
|
81
|
+
if normalized not in plugins:
|
|
82
|
+
available = ", ".join(sorted(plugins)) or "(none registered)"
|
|
83
|
+
msg = f"Unknown runtime: {name!r}. Available: {available}"
|
|
84
|
+
raise UnknownRuntimeError(msg)
|
|
85
|
+
return plugins[normalized]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""AgentLink — the central hub that routes events to graph connectors.
|
|
2
|
+
|
|
3
|
+
An ``AgentLink`` instance holds a list of :class:`GraphConnector` instances.
|
|
4
|
+
Runtime adapters call :meth:`emit` to broadcast events to every registered
|
|
5
|
+
connector.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .events import Event
|
|
14
|
+
from .protocols import GraphConnector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentLink:
|
|
18
|
+
"""Routes :class:`Event` objects from runtime adapters to graph connectors.
|
|
19
|
+
|
|
20
|
+
Usage::
|
|
21
|
+
|
|
22
|
+
link = AgentLink()
|
|
23
|
+
link.add_connector(SkillGraphConnector(skill_graph))
|
|
24
|
+
link.add_connector(MyGraphConnector(graph))
|
|
25
|
+
|
|
26
|
+
adapter = ClaudeAdapter(link, session_id="s-1")
|
|
27
|
+
hooks = adapter.get_runtime_hooks()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._connectors: list[GraphConnector] = []
|
|
32
|
+
|
|
33
|
+
def add_connector(self, connector: GraphConnector) -> None:
|
|
34
|
+
"""Register a graph connector."""
|
|
35
|
+
self._connectors.append(connector)
|
|
36
|
+
|
|
37
|
+
def remove_connector(self, connector: GraphConnector) -> None:
|
|
38
|
+
"""Unregister a graph connector."""
|
|
39
|
+
self._connectors.remove(connector)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def connectors(self) -> list[GraphConnector]:
|
|
43
|
+
"""Return all registered connectors (read-only snapshot)."""
|
|
44
|
+
return list(self._connectors)
|
|
45
|
+
|
|
46
|
+
def emit(self, event: Event) -> None:
|
|
47
|
+
"""Broadcast an event to all connectors that support it."""
|
|
48
|
+
for connector in self._connectors:
|
|
49
|
+
if connector.supports(event):
|
|
50
|
+
connector.on_event(event)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""``memgraph-context-graph mcp``: a stdio MCP server for every registered tool.
|
|
2
|
+
|
|
3
|
+
The harness starts it (the Claude Code and Codex plugins bundle it), so like
|
|
4
|
+
the hooks it reads configuration from the config file alone. Needs the
|
|
5
|
+
``mcp`` extra: ``memgraph-context-graph[mcp]``.
|
|
6
|
+
|
|
7
|
+
A result carries the tool's text only, never ``structuredContent``: Claude
|
|
8
|
+
Code and Codex both show the model the structured JSON *instead of* the text
|
|
9
|
+
when a result has both, and the text is the form recall was benchmarked in.
|
|
10
|
+
Programs get the JSON from the CLI (``memgraph-context-graph recall --json``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from memgraph_context_graph.tools import Tool, ToolError, is_available, load_tools
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from mcp.server.lowlevel import Server
|
|
21
|
+
|
|
22
|
+
SERVER_NAME = "context-graph"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_server(tools: dict[str, Tool] | None = None) -> Server:
|
|
26
|
+
"""An MCP server exposing ``tools``, all registered tools by default.
|
|
27
|
+
|
|
28
|
+
Each listing and call re-checks the config file, so a tool whose
|
|
29
|
+
``available`` check fails (e.g. ``memory`` before the user opts in) is
|
|
30
|
+
hidden and refused. Harnesses list tools once per session, so turning a
|
|
31
|
+
tool on shows up in the next session.
|
|
32
|
+
|
|
33
|
+
Supports mcp 1.x, which registers handlers through decorators, and 2.x,
|
|
34
|
+
which takes them as constructor arguments; the protocol is the same.
|
|
35
|
+
"""
|
|
36
|
+
import mcp.types as types
|
|
37
|
+
from anyio import to_thread
|
|
38
|
+
from mcp.server.lowlevel import Server
|
|
39
|
+
|
|
40
|
+
from memgraph_context_graph.adapters._identity import reload_config
|
|
41
|
+
|
|
42
|
+
tools = load_tools() if tools is None else tools
|
|
43
|
+
|
|
44
|
+
def current_config() -> Any:
|
|
45
|
+
# The server lives as long as the harness session; re-read so
|
|
46
|
+
# `setup --memory-backend` or `config set` take effect mid-session.
|
|
47
|
+
return reload_config()
|
|
48
|
+
|
|
49
|
+
async def list_tools() -> list[types.Tool]:
|
|
50
|
+
config = current_config()
|
|
51
|
+
return [
|
|
52
|
+
types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema)
|
|
53
|
+
for tool in tools.values()
|
|
54
|
+
if is_available(tool, config)
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
async def call_tool(name: str, arguments: dict[str, Any] | None) -> types.CallToolResult:
|
|
58
|
+
config = current_config()
|
|
59
|
+
tool = tools.get(name)
|
|
60
|
+
if tool is None or not is_available(tool, config):
|
|
61
|
+
return _error(f"Unknown tool: {name}")
|
|
62
|
+
try:
|
|
63
|
+
# Tools query Memgraph synchronously; a thread keeps the server responsive.
|
|
64
|
+
result = await to_thread.run_sync(tool.call, arguments or {}, config)
|
|
65
|
+
except ToolError as exc:
|
|
66
|
+
return _error(str(exc))
|
|
67
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=result.text)])
|
|
68
|
+
|
|
69
|
+
if hasattr(Server, "call_tool"): # mcp 1.x
|
|
70
|
+
server: Server = Server(SERVER_NAME)
|
|
71
|
+
server.list_tools()(list_tools)
|
|
72
|
+
server.call_tool()(call_tool)
|
|
73
|
+
return server
|
|
74
|
+
|
|
75
|
+
async def on_list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
|
|
76
|
+
return types.ListToolsResult(tools=await list_tools())
|
|
77
|
+
|
|
78
|
+
async def on_call_tool(ctx: Any, params: Any) -> types.CallToolResult:
|
|
79
|
+
return await call_tool(params.name, params.arguments)
|
|
80
|
+
|
|
81
|
+
return Server(SERVER_NAME, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def serve() -> int:
|
|
85
|
+
"""Serve over stdio until the harness closes the stream."""
|
|
86
|
+
import anyio
|
|
87
|
+
from mcp.server.stdio import stdio_server
|
|
88
|
+
|
|
89
|
+
server = build_server()
|
|
90
|
+
|
|
91
|
+
async def run() -> None:
|
|
92
|
+
async with stdio_server() as (read, write):
|
|
93
|
+
await server.run(read, write, server.create_initialization_options())
|
|
94
|
+
|
|
95
|
+
anyio.run(run)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _error(message: str) -> Any:
|
|
100
|
+
import mcp.types as types
|
|
101
|
+
|
|
102
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=message)], isError=True)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Switching a user's harness memory between the harness's own and Context Graph.
|
|
2
|
+
|
|
3
|
+
Neither Claude Code nor Codex lets another store stand in for its built-in
|
|
4
|
+
memory, so "Context Graph as the memory backend" means: turn the harness's
|
|
5
|
+
memory off in its own user-level config, record ``[memory] backend =
|
|
6
|
+
"context-graph"`` in ours (which turns on the ``memory`` tool and the
|
|
7
|
+
session-start index), and bring the harness's existing memories across once,
|
|
8
|
+
so opting in never starts from empty.
|
|
9
|
+
|
|
10
|
+
A runtime plugin takes part by exposing ``native_memory``, an object with the
|
|
11
|
+
:class:`NativeMemory` shape. Runtimes without one can't switch.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
19
|
+
|
|
20
|
+
from memgraph_context_graph.adapters._identity import (
|
|
21
|
+
MEMORY_BACKEND_GRAPH,
|
|
22
|
+
MEMORY_BACKEND_NATIVE,
|
|
23
|
+
load_config,
|
|
24
|
+
write_config,
|
|
25
|
+
)
|
|
26
|
+
from memgraph_context_graph.spaces import spaces_for
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from memgraph_context_graph.hooks.runtime_plugin import RuntimeCLIPlugin
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ImportReport:
|
|
34
|
+
"""What an import brought across, and what it left alone."""
|
|
35
|
+
|
|
36
|
+
imported: list[str] = field(default_factory=list)
|
|
37
|
+
skipped: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MemoryFiles(Protocol):
|
|
41
|
+
"""The part of sessions-graph's ``MemoryStore`` an import writes through."""
|
|
42
|
+
|
|
43
|
+
def view(self, path: str, view_range: tuple[int, int] | None = None) -> str: ...
|
|
44
|
+
|
|
45
|
+
def create(self, path: str, file_text: str) -> str: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NativeMemory(Protocol):
|
|
49
|
+
"""A harness's built-in memory, as far as switching away from it needs."""
|
|
50
|
+
|
|
51
|
+
def enabled(self, home: Path) -> bool:
|
|
52
|
+
"""Whether the harness's own memory is on for this user."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def disable(self, home: Path) -> list[str]:
|
|
56
|
+
"""Turn the harness's memory off in its user-level config; returns what changed."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def enable(self, home: Path) -> list[str]:
|
|
60
|
+
"""Undo :meth:`disable`; returns what changed."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def import_into(self, files: MemoryFiles, home: Path) -> ImportReport:
|
|
64
|
+
"""Copy the harness's existing memories into *files*, never overwriting a path."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MemoryBackendError(Exception):
|
|
69
|
+
"""The switch can't be made; the message says why and what to do."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def native_memory_of(plugin: RuntimeCLIPlugin) -> NativeMemory | None:
|
|
73
|
+
"""The runtime's built-in memory, or None for a runtime that can't switch."""
|
|
74
|
+
return getattr(plugin, "native_memory", None)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def use_graph_memory(plugin: RuntimeCLIPlugin, *, home: Path | None = None) -> list[str]:
|
|
78
|
+
"""Make Context Graph *plugin*'s memory for this user: harness memory off, ours on, old memories imported.
|
|
79
|
+
|
|
80
|
+
The import runs first, so a failure (Memgraph down, sessions-graph
|
|
81
|
+
missing) leaves the user on their harness memory rather than on an
|
|
82
|
+
empty one. Re-running is safe: imported paths are skipped.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
MemoryBackendError: the runtime can't switch, no user is configured,
|
|
86
|
+
sessions-graph isn't installed, or Memgraph can't be reached.
|
|
87
|
+
"""
|
|
88
|
+
native = _require_native(plugin)
|
|
89
|
+
home = home or Path.home()
|
|
90
|
+
config = load_config()
|
|
91
|
+
user_id = spaces_for(config).user_id()
|
|
92
|
+
if not user_id:
|
|
93
|
+
raise MemoryBackendError(
|
|
94
|
+
"No user is configured, so there is no memory to move into. "
|
|
95
|
+
"Set one first: memgraph-context-graph config set identity.user_id <name>"
|
|
96
|
+
)
|
|
97
|
+
report = native.import_into(_memory_files(config, user_id), home)
|
|
98
|
+
lines = [f"Imported {path}" for path in report.imported]
|
|
99
|
+
lines += [f"Kept existing {path}" for path in report.skipped]
|
|
100
|
+
lines += native.disable(home)
|
|
101
|
+
write_config(memory_backend=MEMORY_BACKEND_GRAPH)
|
|
102
|
+
lines.append(
|
|
103
|
+
f"Set memory.backend = {MEMORY_BACKEND_GRAPH}; the memory tool is on from the next {plugin.name} session."
|
|
104
|
+
)
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def use_native_memory(plugin: RuntimeCLIPlugin, *, home: Path | None = None) -> list[str]:
|
|
109
|
+
"""Hand memory back to *plugin*'s own: harness memory on, ours off. Graph memories stay in Memgraph.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
MemoryBackendError: the runtime can't switch.
|
|
113
|
+
"""
|
|
114
|
+
native = _require_native(plugin)
|
|
115
|
+
lines = native.enable(home or Path.home())
|
|
116
|
+
write_config(memory_backend=MEMORY_BACKEND_NATIVE)
|
|
117
|
+
lines.append(
|
|
118
|
+
f"Set memory.backend = {MEMORY_BACKEND_NATIVE}; {plugin.name}'s own memory is back from its next session."
|
|
119
|
+
)
|
|
120
|
+
return lines
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _require_native(plugin: RuntimeCLIPlugin) -> NativeMemory:
|
|
124
|
+
native = native_memory_of(plugin)
|
|
125
|
+
if native is None:
|
|
126
|
+
raise MemoryBackendError(f"{plugin.name} has no memory Context Graph can replace.")
|
|
127
|
+
return native
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _memory_files(config: Any, user_id: str) -> MemoryFiles:
|
|
131
|
+
try:
|
|
132
|
+
from sessions_graph import SessionsGraph
|
|
133
|
+
except ImportError as exc:
|
|
134
|
+
raise MemoryBackendError(
|
|
135
|
+
"Context Graph memory needs sessions-graph: "
|
|
136
|
+
"uv tool install 'memgraph-context-graph[mcp]' --with 'sessions-graph[memgraph-context-graph]'"
|
|
137
|
+
) from exc
|
|
138
|
+
target = spaces_for(config).personal().target
|
|
139
|
+
try:
|
|
140
|
+
graph = SessionsGraph(**target.client_kwargs())
|
|
141
|
+
graph.setup()
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
raise MemoryBackendError(f"Can't reach Memgraph at {target.url}: {exc}") from exc
|
|
144
|
+
return graph.memory_store(user_id)
|