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,112 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
// No @opencode/plugin import: OpenCode resolves imports relative to this file,
|
|
4
|
+
// where that package is not installed, and Plugin.define is only an identity
|
|
5
|
+
// helper, so exporting the plugin object directly keeps this dependency-free.
|
|
6
|
+
|
|
7
|
+
// argv, spawned directly: no shell, so no login profile or ambient env is
|
|
8
|
+
// sourced between OpenCode and the hook (ADR 0002).
|
|
9
|
+
const command = __MEMGRAPH_CONTEXT_GRAPH_COMMAND__
|
|
10
|
+
|
|
11
|
+
const CAPTURED_EVENTS = new Set([
|
|
12
|
+
"session.deleted",
|
|
13
|
+
"session.text.ended",
|
|
14
|
+
"session.execution.succeeded",
|
|
15
|
+
"session.execution.failed",
|
|
16
|
+
"permission.asked",
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
function capture(payload) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const child = spawn(command[0], command.slice(1), { stdio: ["pipe", "ignore", "inherit"] })
|
|
22
|
+
child.on("error", resolve)
|
|
23
|
+
child.on("close", resolve)
|
|
24
|
+
child.stdin.end(JSON.stringify(payload))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
id: "memgraph.memgraph-context-graph",
|
|
30
|
+
async setup(ctx) {
|
|
31
|
+
const registrations = []
|
|
32
|
+
// Sessions with a turn in flight. `opencode run` can exit before the bus
|
|
33
|
+
// delivers session.execution.succeeded, so shutdown closes these turns.
|
|
34
|
+
const openTurns = new Set()
|
|
35
|
+
// Sessions already recorded as started. The bus subscription below can
|
|
36
|
+
// start after `opencode run` creates its session, so session.created may
|
|
37
|
+
// never arrive; the first prompt then records the start, with the
|
|
38
|
+
// plugin's own directory as the session's.
|
|
39
|
+
const started = new Set()
|
|
40
|
+
const start = (sessionID, cwd, model) => {
|
|
41
|
+
if (started.has(sessionID)) return
|
|
42
|
+
started.add(sessionID)
|
|
43
|
+
return capture({ hook_event_name: "session.created", session_id: sessionID, cwd, model })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
registrations.push(await ctx.session.hook("prompt", async (event) => {
|
|
47
|
+
openTurns.add(event.sessionID)
|
|
48
|
+
await start(event.sessionID, ctx.location?.directory)
|
|
49
|
+
await capture({
|
|
50
|
+
hook_event_name: "session.prompt",
|
|
51
|
+
session_id: event.sessionID,
|
|
52
|
+
prompt: event.prompt.text,
|
|
53
|
+
metadata: event.metadata,
|
|
54
|
+
})
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
registrations.push(await ctx.tool.hook("execute.before", async (event) => {
|
|
58
|
+
await capture({
|
|
59
|
+
hook_event_name: "tool.execute.before",
|
|
60
|
+
session_id: event.sessionID,
|
|
61
|
+
tool_name: event.tool,
|
|
62
|
+
tool_input: event.input,
|
|
63
|
+
tool_use_id: event.id,
|
|
64
|
+
})
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
registrations.push(await ctx.tool.hook("execute.after", async (event) => {
|
|
68
|
+
await capture({
|
|
69
|
+
hook_event_name: "tool.execute.after",
|
|
70
|
+
session_id: event.sessionID,
|
|
71
|
+
tool_name: event.tool,
|
|
72
|
+
tool_input: event.input,
|
|
73
|
+
tool_use_id: event.id,
|
|
74
|
+
tool_result: event.status === "completed" ? event.result : undefined,
|
|
75
|
+
is_error: event.status === "error",
|
|
76
|
+
error: event.status === "error" ? event.error : undefined,
|
|
77
|
+
})
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
const controller = new AbortController()
|
|
81
|
+
void (async () => {
|
|
82
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
83
|
+
const data = event.data ?? {}
|
|
84
|
+
if (event.type === "session.created") {
|
|
85
|
+
await start(data.sessionID, data.location?.directory ?? ctx.location?.directory, data.model?.id)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (!CAPTURED_EVENTS.has(event.type)) continue
|
|
89
|
+
if (event.type.startsWith("session.execution.")) openTurns.delete(data.sessionID)
|
|
90
|
+
await capture({
|
|
91
|
+
hook_event_name: event.type,
|
|
92
|
+
session_id: data.sessionID,
|
|
93
|
+
cwd: data.location?.directory,
|
|
94
|
+
model: data.model?.id,
|
|
95
|
+
text: data.text,
|
|
96
|
+
error: data.error,
|
|
97
|
+
permission: data.action,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
})()
|
|
101
|
+
|
|
102
|
+
return async () => {
|
|
103
|
+
controller.abort()
|
|
104
|
+
await Promise.all(
|
|
105
|
+
[...openTurns].map((sessionID) =>
|
|
106
|
+
capture({ hook_event_name: "session.execution.succeeded", session_id: sessionID }),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
await Promise.all(registrations.map((registration) => registration.dispose()))
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Declarative support for command-hook runtime adapters.
|
|
2
|
+
|
|
3
|
+
Runtime modules describe field aliases, event rules, hook responses, and hook
|
|
4
|
+
configuration in a :class:`RuntimeSpec`. This module owns the shared payload
|
|
5
|
+
dispatch, command-config rendering, and runtime registration so a new runtime
|
|
6
|
+
only declares what differs about it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
18
|
+
|
|
19
|
+
from memgraph_context_graph.adapters._identity import resolve_user_id
|
|
20
|
+
from memgraph_context_graph.events import MessageEvent, SessionStartEvent, ToolEndEvent, ToolStartEvent, TurnEndEvent
|
|
21
|
+
from memgraph_context_graph.protocols import RuntimeAdapter
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from memgraph_context_graph.events import Event
|
|
27
|
+
from memgraph_context_graph.link import AgentLink
|
|
28
|
+
|
|
29
|
+
EventRule = Callable[["EventContext"], "Event | list[Event] | None"]
|
|
30
|
+
Installer = Callable[..., None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class FieldMap:
|
|
35
|
+
"""Aliases for common fields in command-hook payloads."""
|
|
36
|
+
|
|
37
|
+
session_id: tuple[str, ...] = ("session_id", "sessionId")
|
|
38
|
+
cwd: tuple[str, ...] = ("cwd",)
|
|
39
|
+
model: tuple[str, ...] = ("model",)
|
|
40
|
+
tool_name: tuple[str, ...] = ("tool_name", "toolName")
|
|
41
|
+
tool_input: tuple[str, ...] = ("tool_input", "toolArgs", "tool_args")
|
|
42
|
+
tool_result: tuple[str, ...] = (
|
|
43
|
+
"tool_response",
|
|
44
|
+
"tool_result",
|
|
45
|
+
"toolResult",
|
|
46
|
+
"tool_output",
|
|
47
|
+
"toolOutput",
|
|
48
|
+
)
|
|
49
|
+
tool_use_id: tuple[str, ...] = ("tool_use_id", "toolUseId", "toolCallId")
|
|
50
|
+
prompt: tuple[str, ...] = ("prompt",)
|
|
51
|
+
agent_id: tuple[str, ...] = ("agent_id", "agentId", "subagent_id", "subagentId")
|
|
52
|
+
agent_type: tuple[str, ...] = (
|
|
53
|
+
"agent_type",
|
|
54
|
+
"agentType",
|
|
55
|
+
"agent_name",
|
|
56
|
+
"agentName",
|
|
57
|
+
"subagent_type",
|
|
58
|
+
"subagentType",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class HookConfig:
|
|
64
|
+
"""Describe how a runtime represents and stores command hooks.
|
|
65
|
+
|
|
66
|
+
``path`` is ``None`` for a runtime whose hooks are installed by something
|
|
67
|
+
other than a project-local JSON file (e.g. Claude Code's plugin).
|
|
68
|
+
``flat_hooks`` names events written with the flat layout even when
|
|
69
|
+
``layout`` is nested, for runtimes that only accept matcher groups on some
|
|
70
|
+
events.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
layout: Literal["nested", "flat"]
|
|
74
|
+
path: str | None = None
|
|
75
|
+
root_key: str = "hooks"
|
|
76
|
+
version: int | None = None
|
|
77
|
+
merge: bool = False
|
|
78
|
+
timeout_key: str | None = "timeout"
|
|
79
|
+
timeout_multiplier: int = 1
|
|
80
|
+
command_key: str = "command"
|
|
81
|
+
include_type: bool = True
|
|
82
|
+
inject_event_name: bool = False
|
|
83
|
+
matchers: Mapping[str, str] = field(default_factory=dict)
|
|
84
|
+
flat_hooks: frozenset[str] = frozenset()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RuntimeSpec:
|
|
89
|
+
"""Declarative contract for one command-hook runtime.
|
|
90
|
+
|
|
91
|
+
``responses`` maps an event name to the JSON the runtime expects on stdout
|
|
92
|
+
for it; events not listed get no output. ``event_aliases`` maps alternate
|
|
93
|
+
spellings of an event name onto the canonical name used by ``rules`` and
|
|
94
|
+
``responses``. ``foreign_payload_keys`` are keys this runtime never sends;
|
|
95
|
+
a payload carrying one came from another runtime reading this runtime's
|
|
96
|
+
hook file, and is ignored. ``context_before_tool`` renders the stdout JSON
|
|
97
|
+
that shows the model a line of context before a tool runs, leaving the call
|
|
98
|
+
itself untouched; None where the runtime can't do that without denying or
|
|
99
|
+
modifying the call.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
source_sdk: str
|
|
104
|
+
event_key: str
|
|
105
|
+
hooks: tuple[str, ...]
|
|
106
|
+
rules: Mapping[str, EventRule]
|
|
107
|
+
metadata_keys: tuple[str, ...]
|
|
108
|
+
config: HookConfig
|
|
109
|
+
fields: FieldMap = FieldMap()
|
|
110
|
+
responses: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
|
111
|
+
event_aliases: Mapping[str, str] = field(default_factory=dict)
|
|
112
|
+
foreign_payload_keys: frozenset[str] = frozenset()
|
|
113
|
+
probe_payload: Mapping[str, Any] = field(default_factory=dict)
|
|
114
|
+
context_before_tool: Callable[[str], Mapping[str, Any]] | None = None
|
|
115
|
+
|
|
116
|
+
def event_name(self, payload: Mapping[str, Any]) -> str:
|
|
117
|
+
"""Return *payload*'s canonical event name."""
|
|
118
|
+
name = str(payload.get(self.event_key))
|
|
119
|
+
return self.event_aliases.get(name, name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class EventContext:
|
|
124
|
+
"""Resolved payload values supplied to a runtime's event rule."""
|
|
125
|
+
|
|
126
|
+
spec: RuntimeSpec
|
|
127
|
+
payload: dict[str, Any]
|
|
128
|
+
session_id: str
|
|
129
|
+
metadata: dict[str, Any]
|
|
130
|
+
|
|
131
|
+
def value(self, field_name: str, default: Any = None) -> Any:
|
|
132
|
+
"""Return the first present alias for a common field."""
|
|
133
|
+
aliases = getattr(self.spec.fields, field_name, (field_name,))
|
|
134
|
+
for alias in aliases:
|
|
135
|
+
if alias in self.payload:
|
|
136
|
+
return self.payload[alias]
|
|
137
|
+
return default
|
|
138
|
+
|
|
139
|
+
def text(self, field_name: str, default: str = "") -> str:
|
|
140
|
+
"""Return a common field coerced to text, or *default* when missing or empty."""
|
|
141
|
+
value = self.value(field_name)
|
|
142
|
+
return default if value is None or value == "" else str(value)
|
|
143
|
+
|
|
144
|
+
def optional_text(self, field_name: str) -> str | None:
|
|
145
|
+
"""Return a common field as text when present."""
|
|
146
|
+
value = self.value(field_name)
|
|
147
|
+
return None if value is None else str(value)
|
|
148
|
+
|
|
149
|
+
def base(self) -> dict[str, Any]:
|
|
150
|
+
"""Return keyword arguments shared by every Event Protocol event."""
|
|
151
|
+
return {
|
|
152
|
+
"session_id": self.session_id,
|
|
153
|
+
"source_sdk": self.spec.source_sdk,
|
|
154
|
+
"metadata": self.metadata,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SpecAdapter(RuntimeAdapter):
|
|
159
|
+
"""Translate command-hook payloads using a :class:`RuntimeSpec`."""
|
|
160
|
+
|
|
161
|
+
SPEC: RuntimeSpec
|
|
162
|
+
|
|
163
|
+
def __init__(self, link: AgentLink, session_id: str | None = None) -> None:
|
|
164
|
+
self._link = link
|
|
165
|
+
self._session_id = session_id
|
|
166
|
+
|
|
167
|
+
def get_runtime_hooks(self) -> dict[str, list[dict[str, Any]]]:
|
|
168
|
+
"""Return the runtime's hook configuration with its default command."""
|
|
169
|
+
command = f"memgraph-context-graph hook run {self.SPEC.name}"
|
|
170
|
+
return build_hooks_config(self.SPEC, command)
|
|
171
|
+
|
|
172
|
+
def handle_payload(self, payload: dict[str, Any]) -> list[Event]:
|
|
173
|
+
"""Translate and emit every event described by *payload*."""
|
|
174
|
+
rule = self.SPEC.rules.get(self.SPEC.event_name(payload))
|
|
175
|
+
if rule is None or not self.SPEC.foreign_payload_keys.isdisjoint(payload):
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
session_id = self._session_id or self._resolve_session_id(payload)
|
|
179
|
+
context = EventContext(
|
|
180
|
+
spec=self.SPEC,
|
|
181
|
+
payload=payload,
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
metadata=_metadata_from_payload(payload, self.SPEC.metadata_keys),
|
|
184
|
+
)
|
|
185
|
+
result = rule(context)
|
|
186
|
+
events = result if isinstance(result, list) else ([] if result is None else [result])
|
|
187
|
+
for event in events:
|
|
188
|
+
self._link.emit(event)
|
|
189
|
+
return events
|
|
190
|
+
|
|
191
|
+
def _resolve_session_id(self, payload: dict[str, Any]) -> str:
|
|
192
|
+
for key in self.SPEC.fields.session_id:
|
|
193
|
+
if payload.get(key) is not None:
|
|
194
|
+
return str(payload[key])
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def pre_tool_use_context(text: str) -> dict[str, Any]:
|
|
199
|
+
"""``PreToolUse`` output that adds *text* to the model's context and leaves the call alone.
|
|
200
|
+
|
|
201
|
+
Claude Code and Codex both accept it; it carries no ``permissionDecision``,
|
|
202
|
+
so the tool runs as it would have. (Codex treats ``continue``/``stopReason``
|
|
203
|
+
here as a failed hook run, so neither is added.)
|
|
204
|
+
"""
|
|
205
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class SpecPlugin:
|
|
210
|
+
"""Runtime registration (``RuntimeCLIPlugin``) derived from a :class:`RuntimeSpec`.
|
|
211
|
+
|
|
212
|
+
``init`` is ``None`` for a runtime with no project-local installer, which
|
|
213
|
+
the CLI reports as "not implemented" (see ``RuntimeCLIPlugin``).
|
|
214
|
+
``native_memory`` is the harness's built-in memory, for runtimes whose
|
|
215
|
+
memory Context Graph can replace (see ``memgraph_context_graph.memory_backend``).
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
spec: RuntimeSpec
|
|
219
|
+
adapter_class: type[SpecAdapter]
|
|
220
|
+
init: Installer | None = None
|
|
221
|
+
native_memory: Any = None
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def name(self) -> str:
|
|
225
|
+
"""Return the runtime's registered name."""
|
|
226
|
+
return self.spec.name
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def probe_payload(self) -> Mapping[str, Any]:
|
|
230
|
+
"""Return the payload ``doctor`` replays to smoke-test the runtime."""
|
|
231
|
+
return self.spec.probe_payload
|
|
232
|
+
|
|
233
|
+
def response_for_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
234
|
+
"""Return the JSON the runtime expects on stdout for *payload*, if any."""
|
|
235
|
+
response = self.spec.responses.get(self.spec.event_name(payload))
|
|
236
|
+
return None if response is None else dict(response)
|
|
237
|
+
|
|
238
|
+
def context_before_tool_response(self, text: str) -> dict[str, Any] | None:
|
|
239
|
+
"""The stdout JSON that shows the model *text* before a tool runs; None where unsupported."""
|
|
240
|
+
render = self.spec.context_before_tool
|
|
241
|
+
return None if render is None else dict(render(text))
|
|
242
|
+
|
|
243
|
+
def build_hooks_config(self, command: str, *, timeout: int = 30) -> dict[str, Any]:
|
|
244
|
+
"""Render the runtime's hook map using *command*."""
|
|
245
|
+
return build_hooks_config(self.spec, command, timeout=timeout)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def json_hook_installer(spec: RuntimeSpec) -> Installer:
|
|
249
|
+
"""Return an ``init`` that writes *spec*'s project-local JSON hook file."""
|
|
250
|
+
|
|
251
|
+
def init(
|
|
252
|
+
project_dir: Path,
|
|
253
|
+
connectors: list[str],
|
|
254
|
+
*,
|
|
255
|
+
hook_command: str | None = None,
|
|
256
|
+
timeout: int = 30,
|
|
257
|
+
force: bool = False,
|
|
258
|
+
) -> None:
|
|
259
|
+
path = write_hook_config(spec, project_dir, connectors, hook_command=hook_command, timeout=timeout, force=force)
|
|
260
|
+
print(f"Wrote {path}")
|
|
261
|
+
|
|
262
|
+
return init
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def build_hooks_config(spec: RuntimeSpec, command: str, *, timeout: int = 30) -> dict[str, list[dict[str, Any]]]:
|
|
266
|
+
"""Render the runtime-specific command-hook map for *spec*."""
|
|
267
|
+
config: dict[str, list[dict[str, Any]]] = {}
|
|
268
|
+
for hook_name in spec.hooks:
|
|
269
|
+
hook_command = command
|
|
270
|
+
if spec.config.inject_event_name:
|
|
271
|
+
hook_command = f"{command} --event-name {shlex.quote(hook_name)}"
|
|
272
|
+
command_entry: dict[str, Any] = {spec.config.command_key: hook_command}
|
|
273
|
+
if spec.config.include_type:
|
|
274
|
+
command_entry["type"] = "command"
|
|
275
|
+
if spec.config.timeout_key is not None:
|
|
276
|
+
command_entry[spec.config.timeout_key] = timeout * spec.config.timeout_multiplier
|
|
277
|
+
|
|
278
|
+
if spec.config.layout == "nested" and hook_name not in spec.config.flat_hooks:
|
|
279
|
+
entry: dict[str, Any] = {"hooks": [command_entry]}
|
|
280
|
+
else:
|
|
281
|
+
entry = command_entry
|
|
282
|
+
matcher = spec.config.matchers.get(hook_name)
|
|
283
|
+
if matcher is not None:
|
|
284
|
+
entry["matcher"] = matcher
|
|
285
|
+
config[hook_name] = [entry]
|
|
286
|
+
return config
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def write_hook_config(
|
|
290
|
+
spec: RuntimeSpec,
|
|
291
|
+
project_dir: Path,
|
|
292
|
+
connectors: list[str],
|
|
293
|
+
*,
|
|
294
|
+
hook_command: str | None = None,
|
|
295
|
+
timeout: int = 30,
|
|
296
|
+
force: bool = False,
|
|
297
|
+
) -> Path:
|
|
298
|
+
"""Write a runtime's project-local JSON hook configuration.
|
|
299
|
+
|
|
300
|
+
Existing settings files are merged only when the runtime declares
|
|
301
|
+
``merge=True``. Otherwise callers must pass ``force=True`` to replace one.
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValueError: if *spec* declares no config path, or an existing file to
|
|
305
|
+
merge into is not a JSON object with an object-valued hook map.
|
|
306
|
+
FileExistsError: if the file exists, is not mergeable, and *force* is false.
|
|
307
|
+
"""
|
|
308
|
+
if spec.config.path is None:
|
|
309
|
+
raise ValueError(f"{spec.name} has no project-local hook config file")
|
|
310
|
+
config_path = project_dir / spec.config.path
|
|
311
|
+
if config_path.exists() and not spec.config.merge and not force:
|
|
312
|
+
raise FileExistsError(f"Refusing to overwrite existing {spec.name} config: {config_path} (pass force=True)")
|
|
313
|
+
|
|
314
|
+
command = hook_command or hook_command_for(spec.name, connectors)
|
|
315
|
+
hooks = build_hooks_config(spec, command, timeout=timeout)
|
|
316
|
+
document: dict[str, Any]
|
|
317
|
+
if config_path.exists() and spec.config.merge:
|
|
318
|
+
loaded = json.loads(config_path.read_text(encoding="utf-8"))
|
|
319
|
+
if not isinstance(loaded, dict):
|
|
320
|
+
raise ValueError(f"Expected a JSON object in {config_path}")
|
|
321
|
+
document = loaded
|
|
322
|
+
else:
|
|
323
|
+
document = {}
|
|
324
|
+
if spec.config.version is not None:
|
|
325
|
+
document["version"] = spec.config.version
|
|
326
|
+
existing_hooks = document.get(spec.config.root_key, {})
|
|
327
|
+
if not isinstance(existing_hooks, dict):
|
|
328
|
+
raise ValueError(f"Expected {spec.config.root_key!r} to be an object in {config_path}")
|
|
329
|
+
document[spec.config.root_key] = {**existing_hooks, **hooks}
|
|
330
|
+
|
|
331
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
config_path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
|
333
|
+
return config_path
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def hook_command_argv(runtime: str, connectors: list[str]) -> list[str]:
|
|
337
|
+
"""Build the installed hook command for *runtime* and *connectors* as argv."""
|
|
338
|
+
executable = shutil.which("memgraph-context-graph")
|
|
339
|
+
base = [executable] if executable else [sys.executable, "-m", "memgraph_context_graph.cli"]
|
|
340
|
+
argv = [*base, "hook", "run", runtime]
|
|
341
|
+
for connector in connectors:
|
|
342
|
+
argv.extend(["--connector", connector])
|
|
343
|
+
return argv
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def hook_command_for(runtime: str, connectors: list[str]) -> str:
|
|
347
|
+
"""Build the installed hook command for *runtime* and *connectors* as a shell string."""
|
|
348
|
+
return shlex.join(hook_command_argv(runtime, connectors))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _metadata_from_payload(payload: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
|
352
|
+
return {key: payload[key] for key in keys if key in payload and payload[key] is not None}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def event_user_id(context: EventContext) -> str | None:
|
|
356
|
+
"""Resolve the stable user identity for a session-start event."""
|
|
357
|
+
return resolve_user_id(context.payload)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def string_or_none(value: Any) -> str | None:
|
|
361
|
+
"""Coerce *value* to text while preserving ``None``."""
|
|
362
|
+
return None if value is None else str(value)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def extract_tool_result(tool_response: Any) -> tuple[Any, bool, str | None]:
|
|
366
|
+
"""Normalize common command-hook tool result shapes."""
|
|
367
|
+
if not isinstance(tool_response, dict):
|
|
368
|
+
return tool_response, False, None
|
|
369
|
+
error = tool_response.get("error")
|
|
370
|
+
exit_code = tool_response.get("exit_code", tool_response.get("exitCode"))
|
|
371
|
+
is_error = bool(tool_response.get("is_error", False) or error or exit_code not in (None, 0))
|
|
372
|
+
error_message = error or tool_response.get("stderr")
|
|
373
|
+
result = tool_response.get(
|
|
374
|
+
"content",
|
|
375
|
+
tool_response.get(
|
|
376
|
+
"llmContent", tool_response.get("text_result_for_llm", tool_response.get("textResultForLlm"))
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
if result is None:
|
|
380
|
+
result = tool_response
|
|
381
|
+
return result, is_error, string_or_none(error_message)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def session_start(context: EventContext) -> SessionStartEvent:
|
|
385
|
+
"""Default session-start rule: working directory, model, and user identity."""
|
|
386
|
+
return SessionStartEvent(
|
|
387
|
+
**context.base(),
|
|
388
|
+
model=context.optional_text("model"),
|
|
389
|
+
working_directory=context.optional_text("cwd"),
|
|
390
|
+
user_id=event_user_id(context),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def tool_start(context: EventContext) -> ToolStartEvent:
|
|
395
|
+
"""Default tool-start rule shared by every command-hook runtime."""
|
|
396
|
+
return ToolStartEvent(
|
|
397
|
+
**context.base(),
|
|
398
|
+
tool_name=context.text("tool_name"),
|
|
399
|
+
tool_input=context.value("tool_input"),
|
|
400
|
+
tool_use_id=context.optional_text("tool_use_id"),
|
|
401
|
+
agent_name=context.optional_text("agent_id"),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def tool_end(context: EventContext) -> ToolEndEvent:
|
|
406
|
+
"""Default tool-end rule: normalize the result via :func:`extract_tool_result`."""
|
|
407
|
+
result, is_error, error_message = extract_tool_result(context.value("tool_result"))
|
|
408
|
+
return ToolEndEvent(
|
|
409
|
+
**context.base(),
|
|
410
|
+
tool_name=context.text("tool_name"),
|
|
411
|
+
tool_use_id=context.optional_text("tool_use_id"),
|
|
412
|
+
result=result,
|
|
413
|
+
is_error=is_error,
|
|
414
|
+
error_message=error_message,
|
|
415
|
+
agent_name=context.optional_text("agent_id"),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def turn_end(context: EventContext, *, reply: Any = None, reason: Any = None) -> list[Event]:
|
|
420
|
+
"""Default turn-end rule: the assistant's final reply, if any, then the turn end.
|
|
421
|
+
|
|
422
|
+
Only the turn's final reply reaches a stop hook; text written before a tool
|
|
423
|
+
call in the same turn is narration and is in no payload. A turn end never
|
|
424
|
+
ends the session; runtimes with a session-end hook map that separately.
|
|
425
|
+
"""
|
|
426
|
+
events: list[Event] = []
|
|
427
|
+
text = string_or_none(reply)
|
|
428
|
+
if text is not None and text.strip():
|
|
429
|
+
# The reply is the message's content; a copy in its metadata would only double what is stored.
|
|
430
|
+
metadata = {key: value for key, value in context.metadata.items() if value != reply}
|
|
431
|
+
events.append(
|
|
432
|
+
MessageEvent(
|
|
433
|
+
session_id=context.session_id,
|
|
434
|
+
source_sdk=context.spec.source_sdk,
|
|
435
|
+
metadata=metadata,
|
|
436
|
+
role="assistant",
|
|
437
|
+
content=text,
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
events.append(TurnEndEvent(**context.base(), reason=string_or_none(reason)))
|
|
441
|
+
return events
|