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,71 @@
|
|
|
1
|
+
"""Memgraph Context Graph: Connect agent runtimes to context-graph components.
|
|
2
|
+
|
|
3
|
+
Memgraph Context Graph provides a generic adapter layer that bridges any agent
|
|
4
|
+
development SDK or runtime hook source to any context-graph component
|
|
5
|
+
(actions-graph, skills-graph, etc.).
|
|
6
|
+
|
|
7
|
+
Architecture:
|
|
8
|
+
Runtime Adapter (Claude, OpenAI, Codex, ...) → Event Protocol → Graph Connector(s)
|
|
9
|
+
|
|
10
|
+
Adapters live here. Connectors live in each graph library.
|
|
11
|
+
|
|
12
|
+
Quick Start::
|
|
13
|
+
|
|
14
|
+
from memgraph_context_graph import AgentLink
|
|
15
|
+
from memgraph_context_graph.adapters.claude import ClaudeAdapter
|
|
16
|
+
from skills_graph.connector import SkillGraphConnector
|
|
17
|
+
|
|
18
|
+
link = AgentLink()
|
|
19
|
+
link.add_connector(SkillGraphConnector(skill_graph))
|
|
20
|
+
adapter = ClaudeAdapter(link, session_id="s-1")
|
|
21
|
+
hooks = adapter.get_runtime_hooks()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from importlib import metadata
|
|
25
|
+
|
|
26
|
+
from .events import (
|
|
27
|
+
AgentEndEvent,
|
|
28
|
+
AgentStartEvent,
|
|
29
|
+
ErrorOccurredEvent,
|
|
30
|
+
Event,
|
|
31
|
+
EventType,
|
|
32
|
+
HandoffEvent,
|
|
33
|
+
LLMEndEvent,
|
|
34
|
+
LLMStartEvent,
|
|
35
|
+
MessageEvent,
|
|
36
|
+
SessionEndEvent,
|
|
37
|
+
SessionStartEvent,
|
|
38
|
+
ToolEndEvent,
|
|
39
|
+
ToolStartEvent,
|
|
40
|
+
TurnEndEvent,
|
|
41
|
+
)
|
|
42
|
+
from .link import AgentLink
|
|
43
|
+
from .protocols import GraphConnector, RuntimeAdapter
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
__version__ = metadata.version(__package__ or __name__)
|
|
47
|
+
except metadata.PackageNotFoundError:
|
|
48
|
+
# Case where package metadata is not available.
|
|
49
|
+
__version__ = ""
|
|
50
|
+
del metadata # optional, avoids polluting the results of dir(__package__)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AgentEndEvent",
|
|
54
|
+
"AgentLink",
|
|
55
|
+
"AgentStartEvent",
|
|
56
|
+
"ErrorOccurredEvent",
|
|
57
|
+
"Event",
|
|
58
|
+
"EventType",
|
|
59
|
+
"GraphConnector",
|
|
60
|
+
"HandoffEvent",
|
|
61
|
+
"LLMEndEvent",
|
|
62
|
+
"LLMStartEvent",
|
|
63
|
+
"MessageEvent",
|
|
64
|
+
"RuntimeAdapter",
|
|
65
|
+
"SessionEndEvent",
|
|
66
|
+
"SessionStartEvent",
|
|
67
|
+
"ToolEndEvent",
|
|
68
|
+
"ToolStartEvent",
|
|
69
|
+
"TurnEndEvent",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""Hook Configuration — persistent config for hook subprocesses.
|
|
2
|
+
|
|
3
|
+
Agent runtimes (Claude Code, Codex) spawn hook commands as non-interactive
|
|
4
|
+
subprocesses that do not inherit shell profile environment variables. This
|
|
5
|
+
module provides a config-file-only resolution path so hook subprocesses (and
|
|
6
|
+
subprocesses they in turn spawn, e.g. sessions-graph's detached
|
|
7
|
+
reconciliation process) can reliably access identity, Memgraph connection,
|
|
8
|
+
LLM API key, and reconciliation opt-in settings.
|
|
9
|
+
|
|
10
|
+
Resolution order (per ADR 0002):
|
|
11
|
+
CLI flag > config file > hardcoded default
|
|
12
|
+
|
|
13
|
+
Environment variables are **not** consulted for configuration *values* at hook
|
|
14
|
+
runtime. They are only used as a write-time input during ``bootstrap`` or
|
|
15
|
+
``config set``.
|
|
16
|
+
|
|
17
|
+
Config file location: ``~/.config/context-graph/config.toml``, unless
|
|
18
|
+
``CONTEXT_GRAPH_CONFIG`` names another file. That override selects *which file*
|
|
19
|
+
to read, never what is in it, and exists because the default is a single global:
|
|
20
|
+
pointing one session's hooks at a different Memgraph would otherwise redirect
|
|
21
|
+
every other Claude Code session on the machine. See ADR 0003.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import stat
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
#: Points hooks at a different config *file*. Not a way to supply configuration
|
|
33
|
+
#: values -- ADR 0002 removed those from the environment deliberately, because
|
|
34
|
+
#: hook subprocesses do not source shell profiles and relying on ambient env
|
|
35
|
+
#: caused `doctor` and hooks to disagree about what was configured.
|
|
36
|
+
#:
|
|
37
|
+
#: This is the opposite situation: a parent process that spawns a session hands
|
|
38
|
+
#: the path down explicitly to its own child, so nothing is ambient and nothing
|
|
39
|
+
#: can drift. It exists because the config file is otherwise a single global,
|
|
40
|
+
#: which means pointing ONE session at a different Memgraph silently redirects
|
|
41
|
+
#: every other Claude Code session running on the machine -- observed while
|
|
42
|
+
#: building the eval gold slice, where an unrelated session's activity landed
|
|
43
|
+
#: in the graph under test.
|
|
44
|
+
CONFIG_PATH_ENV = "CONTEXT_GRAPH_CONFIG"
|
|
45
|
+
|
|
46
|
+
_DEFAULT_CONFIG_DIR = Path.home() / ".config" / "context-graph"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def config_file() -> Path:
|
|
50
|
+
"""The config file hooks read, honouring :data:`CONFIG_PATH_ENV`."""
|
|
51
|
+
override = os.environ.get(CONFIG_PATH_ENV)
|
|
52
|
+
return Path(override) if override else _DEFAULT_CONFIG_DIR / "config.toml"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Defaults matching memgraph-toolbox's MEMGRAPH_ENV_DEFAULTS.
|
|
56
|
+
_MEMGRAPH_DEFAULTS = {
|
|
57
|
+
"url": "bolt://localhost:7687",
|
|
58
|
+
"user": "",
|
|
59
|
+
"password": "",
|
|
60
|
+
"database": "memgraph",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_LLM_DEFAULTS = {
|
|
64
|
+
"openai_api_key": "",
|
|
65
|
+
"anthropic_api_key": "",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#: ``[memory] backend`` values: the graph, or the harness's built-in memory.
|
|
69
|
+
MEMORY_BACKEND_GRAPH = "context-graph"
|
|
70
|
+
MEMORY_BACKEND_NATIVE = "native"
|
|
71
|
+
MEMORY_BACKENDS = frozenset({MEMORY_BACKEND_GRAPH, MEMORY_BACKEND_NATIVE})
|
|
72
|
+
|
|
73
|
+
_RECONCILE_DEFAULTS = {
|
|
74
|
+
"auto_reconcile": None,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#: Shared truthy/falsy vocabulary for TOML/CLI boolean flags — kept here so
|
|
78
|
+
#: cli.py can validate `config set` input against the same set parse_bool_flag
|
|
79
|
+
#: accepts, instead of redefining it inline.
|
|
80
|
+
TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
81
|
+
FALSY_VALUES = frozenset({"0", "false", "no", "off"})
|
|
82
|
+
|
|
83
|
+
_cached_config: HookConfig | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class HookConfig:
|
|
88
|
+
"""Parsed hook configuration from the config file."""
|
|
89
|
+
|
|
90
|
+
#: Top-level ``edition``: "community" (or None, the default) or "enterprise"; see memgraph_context_graph.spaces.
|
|
91
|
+
edition: str | None = None
|
|
92
|
+
user_id: str | None = None
|
|
93
|
+
memgraph_url: str = _MEMGRAPH_DEFAULTS["url"]
|
|
94
|
+
memgraph_user: str = _MEMGRAPH_DEFAULTS["user"]
|
|
95
|
+
memgraph_password: str = _MEMGRAPH_DEFAULTS["password"]
|
|
96
|
+
memgraph_database: str = _MEMGRAPH_DEFAULTS["database"]
|
|
97
|
+
openai_api_key: str = _LLM_DEFAULTS["openai_api_key"]
|
|
98
|
+
anthropic_api_key: str = _LLM_DEFAULTS["anthropic_api_key"]
|
|
99
|
+
auto_reconcile: bool | None = _RECONCILE_DEFAULTS["auto_reconcile"]
|
|
100
|
+
#: The model recall embeds with; None means the consumer's own default.
|
|
101
|
+
embedding_model: str | None = None
|
|
102
|
+
#: Every other ``[recall]`` key (lanes and widths), as written: the recall
|
|
103
|
+
#: tool validates them, this module only keeps them across rewrites.
|
|
104
|
+
recall_settings: dict[str, str] = field(default_factory=dict)
|
|
105
|
+
#: ``[ontology] path``: a schema file to extract this user's sessions under.
|
|
106
|
+
ontology_path: str | None = None
|
|
107
|
+
#: ``[ontology] derive``: "extend" (the default) or "off"; see sessions-graph's ontology module.
|
|
108
|
+
ontology_derive: str | None = None
|
|
109
|
+
#: ``[github] token``: overrides the ``gh`` login resources-graph's Sweep otherwise fetches with.
|
|
110
|
+
github_token: str | None = None
|
|
111
|
+
#: ``[memory] backend``: ``"context-graph"`` when the user has handed their
|
|
112
|
+
#: harness memory to the graph (the ``memory`` tool and session-start index
|
|
113
|
+
#: are on); ``"native"`` or None leaves the harness's own memory in charge.
|
|
114
|
+
memory_backend: str | None = None
|
|
115
|
+
#: The ``section.key`` names the file actually sets (top-level keys as ``key``), so a check can tell
|
|
116
|
+
#: "left at the default" from "written": Enterprise refuses an explicit ``identity.user_id`` or
|
|
117
|
+
#: ``memgraph.database``, which would quietly override what the login decides.
|
|
118
|
+
present: frozenset[str] = frozenset()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def graph_memory(self) -> bool:
|
|
122
|
+
"""Whether Context Graph is this user's memory backend."""
|
|
123
|
+
return self.memory_backend == MEMORY_BACKEND_GRAPH
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_config() -> HookConfig:
|
|
127
|
+
"""Load hook configuration from the config file.
|
|
128
|
+
|
|
129
|
+
Returns a HookConfig with defaults for any missing values.
|
|
130
|
+
Caches the result for the lifetime of the process.
|
|
131
|
+
"""
|
|
132
|
+
global _cached_config
|
|
133
|
+
if _cached_config is not None:
|
|
134
|
+
return _cached_config
|
|
135
|
+
|
|
136
|
+
config = _read_config_file()
|
|
137
|
+
_cached_config = config
|
|
138
|
+
return config
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def reload_config() -> HookConfig:
|
|
142
|
+
"""Re-read the config file, for long-lived processes (the MCP server) that must see later edits."""
|
|
143
|
+
global _cached_config
|
|
144
|
+
_cached_config = None
|
|
145
|
+
return load_config()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_user_id(payload: dict[str, Any]) -> str | None:
|
|
149
|
+
"""Resolve user identity for hook subprocesses, as the configured edition's space resolver decides.
|
|
150
|
+
|
|
151
|
+
In Community: the hook payload's ``user_id`` (forward-compat), else ``[identity] user_id``.
|
|
152
|
+
"""
|
|
153
|
+
from memgraph_context_graph.spaces import spaces_for
|
|
154
|
+
|
|
155
|
+
return spaces_for(load_config()).user_id(payload)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_memgraph_env(
|
|
159
|
+
*,
|
|
160
|
+
url: str | None = None,
|
|
161
|
+
user: str | None = None,
|
|
162
|
+
password: str | None = None,
|
|
163
|
+
database: str | None = None,
|
|
164
|
+
) -> dict[str, str]:
|
|
165
|
+
"""Resolve Memgraph connection settings for hook subprocesses.
|
|
166
|
+
|
|
167
|
+
Resolution order per setting: explicit arg (CLI flag) > the user's personal
|
|
168
|
+
space, as the configured edition's space resolver gives it > default.
|
|
169
|
+
Returns a dict with keys matching memgraph-toolbox's env contract.
|
|
170
|
+
"""
|
|
171
|
+
from memgraph_context_graph.spaces import spaces_for
|
|
172
|
+
|
|
173
|
+
env = spaces_for(load_config()).personal().target.env()
|
|
174
|
+
overrides = {
|
|
175
|
+
"MEMGRAPH_URL": url,
|
|
176
|
+
"MEMGRAPH_USER": user,
|
|
177
|
+
"MEMGRAPH_PASSWORD": password,
|
|
178
|
+
"MEMGRAPH_DATABASE": database,
|
|
179
|
+
}
|
|
180
|
+
return {key: value if value is not None else env[key] for key, value in overrides.items()}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def resolve_llm_env() -> dict[str, str]:
|
|
184
|
+
"""Resolve LLM API key settings for hook subprocesses.
|
|
185
|
+
|
|
186
|
+
Config file only — mirrors :func:`resolve_memgraph_env`, but there is no
|
|
187
|
+
CLI-flag override path for these since nothing resolves them from argparse.
|
|
188
|
+
Values are empty strings when not configured. Hook-spawned children must
|
|
189
|
+
overlay these empty values too so ambient credentials cannot supply a key
|
|
190
|
+
absent from the selected config file.
|
|
191
|
+
"""
|
|
192
|
+
config = load_config()
|
|
193
|
+
return {
|
|
194
|
+
"OPENAI_API_KEY": config.openai_api_key,
|
|
195
|
+
"ANTHROPIC_API_KEY": config.anthropic_api_key,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_auto_reconcile() -> bool:
|
|
200
|
+
"""Resolve whether sessions-graph should auto-trigger reconciliation on SESSION_END.
|
|
201
|
+
|
|
202
|
+
Config file only — mirrors :func:`resolve_llm_env`. The config-file value
|
|
203
|
+
is ``None`` when never configured; per ADR 0002 (config-file-only-hook-
|
|
204
|
+
resolution) there is no ambient env var fallback to preserve here, so
|
|
205
|
+
"never configured" collapses to ``False``, matching
|
|
206
|
+
``SessionsGraphConnector``'s own default. Off by default given LightRAG
|
|
207
|
+
entity extraction's LLM cost; opt in with
|
|
208
|
+
``memgraph-context-graph config set reconcile.auto_reconcile true``.
|
|
209
|
+
"""
|
|
210
|
+
return bool(load_config().auto_reconcile)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def resolve_embedding_model() -> str | None:
|
|
214
|
+
"""The configured ``[recall] embedding_model``, or None when unset.
|
|
215
|
+
|
|
216
|
+
Config file only, like :func:`resolve_auto_reconcile`. None leaves the
|
|
217
|
+
choice to sessions-graph's own default rather than duplicating it here.
|
|
218
|
+
"""
|
|
219
|
+
return load_config().embedding_model
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_ontology() -> tuple[str | None, str | None]:
|
|
223
|
+
"""The configured ``[ontology]`` (path, derive), each None when unset.
|
|
224
|
+
|
|
225
|
+
Config file only, like :func:`resolve_auto_reconcile`. sessions-graph owns
|
|
226
|
+
what the values mean and their defaults.
|
|
227
|
+
"""
|
|
228
|
+
config = load_config()
|
|
229
|
+
return config.ontology_path, config.ontology_derive
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_bool_flag(value: str | bool) -> bool:
|
|
233
|
+
"""Parse a TOML/CLI boolean flag string. Truthy: "1"/"true"/"yes"/"on" (case-insensitive).
|
|
234
|
+
|
|
235
|
+
Passing an actual ``bool`` through unchanged guards against a future
|
|
236
|
+
swap to a real TOML parser (which would hand back native booleans).
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(value, bool):
|
|
239
|
+
return value
|
|
240
|
+
return value.strip().lower() in TRUTHY_VALUES
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_config(
|
|
244
|
+
*,
|
|
245
|
+
edition: str | None = None,
|
|
246
|
+
user_id: str | None = None,
|
|
247
|
+
memgraph_url: str | None = None,
|
|
248
|
+
memgraph_user: str | None = None,
|
|
249
|
+
memgraph_password: str | None = None,
|
|
250
|
+
memgraph_database: str | None = None,
|
|
251
|
+
openai_api_key: str | None = None,
|
|
252
|
+
anthropic_api_key: str | None = None,
|
|
253
|
+
auto_reconcile: bool | None = None,
|
|
254
|
+
embedding_model: str | None = None,
|
|
255
|
+
ontology_path: str | None = None,
|
|
256
|
+
ontology_derive: str | None = None,
|
|
257
|
+
github_token: str | None = None,
|
|
258
|
+
memory_backend: str | None = None,
|
|
259
|
+
) -> Path:
|
|
260
|
+
"""Write or update the config file. Returns the path written to.
|
|
261
|
+
|
|
262
|
+
Only updates supplied values; preserves existing values for unspecified keys.
|
|
263
|
+
Creates the file with 0600 permissions if it does not exist.
|
|
264
|
+
"""
|
|
265
|
+
global _cached_config
|
|
266
|
+
|
|
267
|
+
# Read existing config as base.
|
|
268
|
+
existing = _read_config_file()
|
|
269
|
+
|
|
270
|
+
final_edition = edition if edition is not None else existing.edition
|
|
271
|
+
final_user_id = user_id if user_id is not None else existing.user_id
|
|
272
|
+
final_url = memgraph_url if memgraph_url is not None else existing.memgraph_url
|
|
273
|
+
final_user = memgraph_user if memgraph_user is not None else existing.memgraph_user
|
|
274
|
+
final_password = memgraph_password if memgraph_password is not None else existing.memgraph_password
|
|
275
|
+
final_database = memgraph_database if memgraph_database is not None else existing.memgraph_database
|
|
276
|
+
final_openai_api_key = openai_api_key if openai_api_key is not None else existing.openai_api_key
|
|
277
|
+
final_anthropic_api_key = anthropic_api_key if anthropic_api_key is not None else existing.anthropic_api_key
|
|
278
|
+
final_auto_reconcile = auto_reconcile if auto_reconcile is not None else existing.auto_reconcile
|
|
279
|
+
final_embedding_model = embedding_model if embedding_model is not None else existing.embedding_model
|
|
280
|
+
final_ontology_path = ontology_path if ontology_path is not None else existing.ontology_path
|
|
281
|
+
final_ontology_derive = ontology_derive if ontology_derive is not None else existing.ontology_derive
|
|
282
|
+
final_github_token = github_token if github_token is not None else existing.github_token
|
|
283
|
+
final_memory_backend = memory_backend if memory_backend is not None else existing.memory_backend
|
|
284
|
+
|
|
285
|
+
content = _render_config(
|
|
286
|
+
edition=final_edition,
|
|
287
|
+
user_id=final_user_id or "",
|
|
288
|
+
url=final_url,
|
|
289
|
+
user=final_user,
|
|
290
|
+
password=final_password,
|
|
291
|
+
database=final_database,
|
|
292
|
+
openai_api_key=final_openai_api_key,
|
|
293
|
+
anthropic_api_key=final_anthropic_api_key,
|
|
294
|
+
auto_reconcile=final_auto_reconcile,
|
|
295
|
+
embedding_model=final_embedding_model,
|
|
296
|
+
recall_settings=existing.recall_settings,
|
|
297
|
+
ontology_path=final_ontology_path,
|
|
298
|
+
ontology_derive=final_ontology_derive,
|
|
299
|
+
github_token=final_github_token,
|
|
300
|
+
memory_backend=final_memory_backend,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
path = config_file()
|
|
304
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
path.write_text(content, encoding="utf-8")
|
|
306
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
307
|
+
|
|
308
|
+
# Invalidate cache.
|
|
309
|
+
_cached_config = None
|
|
310
|
+
return config_file()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def write_full_config(
|
|
314
|
+
*,
|
|
315
|
+
user_id: str = "",
|
|
316
|
+
memgraph_url: str = _MEMGRAPH_DEFAULTS["url"],
|
|
317
|
+
memgraph_user: str = _MEMGRAPH_DEFAULTS["user"],
|
|
318
|
+
memgraph_password: str = _MEMGRAPH_DEFAULTS["password"],
|
|
319
|
+
memgraph_database: str = _MEMGRAPH_DEFAULTS["database"],
|
|
320
|
+
openai_api_key: str = _LLM_DEFAULTS["openai_api_key"],
|
|
321
|
+
anthropic_api_key: str = _LLM_DEFAULTS["anthropic_api_key"],
|
|
322
|
+
auto_reconcile: bool | None = _RECONCILE_DEFAULTS["auto_reconcile"],
|
|
323
|
+
) -> Path:
|
|
324
|
+
"""Write a complete config file with all sections (used by bootstrap).
|
|
325
|
+
|
|
326
|
+
Overwrites every section except ``[reconcile]``, ``[recall]``, ``[ontology]``, ``[github]`` and ``[memory]``: unlike identity/Memgraph/LLM
|
|
327
|
+
settings, ``auto_reconcile`` has no legitimate ambient-env source for
|
|
328
|
+
``bootstrap`` to capture (nobody has ``SESSIONS_GRAPH_AUTO_RECONCILE``
|
|
329
|
+
exported for an unrelated reason the way they might already have
|
|
330
|
+
``OPENAI_API_KEY``/`MEMGRAPH_PASSWORD` set) — it is only ever set via
|
|
331
|
+
``config set reconcile.auto_reconcile``. Re-running bootstrap must not
|
|
332
|
+
silently revert it to off, so ``auto_reconcile`` is preserved from the
|
|
333
|
+
existing file unless explicitly given here. ``[recall]``, ``[ontology]`` and
|
|
334
|
+
``[github]`` are preserved the same way: they are only ever set via ``config set`` or by
|
|
335
|
+
editing the file. ``[memory]`` is set by ``setup --memory-backend`` and kept likewise.
|
|
336
|
+
"""
|
|
337
|
+
global _cached_config
|
|
338
|
+
|
|
339
|
+
existing = _read_config_file()
|
|
340
|
+
final_auto_reconcile = auto_reconcile if auto_reconcile is not None else existing.auto_reconcile
|
|
341
|
+
|
|
342
|
+
content = _render_config(
|
|
343
|
+
edition=existing.edition,
|
|
344
|
+
user_id=user_id,
|
|
345
|
+
url=memgraph_url,
|
|
346
|
+
user=memgraph_user,
|
|
347
|
+
password=memgraph_password,
|
|
348
|
+
database=memgraph_database,
|
|
349
|
+
openai_api_key=openai_api_key,
|
|
350
|
+
anthropic_api_key=anthropic_api_key,
|
|
351
|
+
auto_reconcile=final_auto_reconcile,
|
|
352
|
+
embedding_model=existing.embedding_model,
|
|
353
|
+
recall_settings=existing.recall_settings,
|
|
354
|
+
ontology_path=existing.ontology_path,
|
|
355
|
+
ontology_derive=existing.ontology_derive,
|
|
356
|
+
github_token=existing.github_token,
|
|
357
|
+
memory_backend=existing.memory_backend,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
path = config_file()
|
|
361
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
path.write_text(content, encoding="utf-8")
|
|
363
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
364
|
+
|
|
365
|
+
_cached_config = None
|
|
366
|
+
return config_file()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def config_file_path() -> Path:
|
|
370
|
+
"""Return the config file path (for display purposes)."""
|
|
371
|
+
return config_file()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def config_dir_path() -> Path:
|
|
375
|
+
"""Return the directory holding the config file actually in use."""
|
|
376
|
+
return config_file().parent
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --- Internal helpers ---
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _read_config_file() -> HookConfig:
|
|
383
|
+
"""Parse the config file. Returns defaults if file is missing or malformed."""
|
|
384
|
+
if not config_file().is_file():
|
|
385
|
+
return HookConfig()
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
sections = _parse_toml(config_file())
|
|
389
|
+
except Exception:
|
|
390
|
+
return HookConfig()
|
|
391
|
+
|
|
392
|
+
identity = sections.get("identity", {})
|
|
393
|
+
memgraph = sections.get("memgraph", {})
|
|
394
|
+
llm = sections.get("llm", {})
|
|
395
|
+
reconcile = sections.get("reconcile", {})
|
|
396
|
+
recall = sections.get("recall", {})
|
|
397
|
+
ontology = sections.get("ontology", {})
|
|
398
|
+
github = sections.get("github", {})
|
|
399
|
+
memory = sections.get("memory", {})
|
|
400
|
+
auto_reconcile_raw = reconcile.get("auto_reconcile")
|
|
401
|
+
|
|
402
|
+
return HookConfig(
|
|
403
|
+
edition=sections.get("", {}).get("edition") or None,
|
|
404
|
+
user_id=identity.get("user_id") or None,
|
|
405
|
+
memgraph_url=memgraph.get("url") or _MEMGRAPH_DEFAULTS["url"],
|
|
406
|
+
memgraph_user=memgraph.get("user", _MEMGRAPH_DEFAULTS["user"]),
|
|
407
|
+
memgraph_password=memgraph.get("password", _MEMGRAPH_DEFAULTS["password"]),
|
|
408
|
+
memgraph_database=memgraph.get("database") or _MEMGRAPH_DEFAULTS["database"],
|
|
409
|
+
openai_api_key=llm.get("openai_api_key", _LLM_DEFAULTS["openai_api_key"]),
|
|
410
|
+
anthropic_api_key=llm.get("anthropic_api_key", _LLM_DEFAULTS["anthropic_api_key"]),
|
|
411
|
+
auto_reconcile=parse_bool_flag(auto_reconcile_raw) if auto_reconcile_raw is not None else None,
|
|
412
|
+
embedding_model=recall.get("embedding_model") or None,
|
|
413
|
+
recall_settings={key: value for key, value in recall.items() if key != "embedding_model"},
|
|
414
|
+
ontology_path=ontology.get("path") or None,
|
|
415
|
+
ontology_derive=ontology.get("derive") or None,
|
|
416
|
+
github_token=github.get("token") or None,
|
|
417
|
+
memory_backend=memory.get("backend") or None,
|
|
418
|
+
present=frozenset(
|
|
419
|
+
f"{section}.{key}" if section else key
|
|
420
|
+
for section, keys in sections.items()
|
|
421
|
+
for key, value in keys.items()
|
|
422
|
+
if value
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _parse_toml(path: Path) -> dict[str, dict[str, str]]:
|
|
428
|
+
"""Minimal TOML parser — handles [section] and key = "value" pairs.
|
|
429
|
+
|
|
430
|
+
Only supports string values (quoted or bare). Sufficient for our config shape.
|
|
431
|
+
Keys before the first section header land in the ``""`` section.
|
|
432
|
+
"""
|
|
433
|
+
sections: dict[str, dict[str, str]] = {"": {}}
|
|
434
|
+
current_section = ""
|
|
435
|
+
|
|
436
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
437
|
+
stripped = line.strip()
|
|
438
|
+
if not stripped or stripped.startswith("#"):
|
|
439
|
+
continue
|
|
440
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
441
|
+
current_section = stripped[1:-1].strip()
|
|
442
|
+
sections.setdefault(current_section, {})
|
|
443
|
+
continue
|
|
444
|
+
if "=" in stripped:
|
|
445
|
+
key, _, value = stripped.partition("=")
|
|
446
|
+
key = key.strip()
|
|
447
|
+
value = value.strip()
|
|
448
|
+
if value[:1] not in {'"', "'"} and "#" in value:
|
|
449
|
+
# Bare (unquoted) value with a trailing comment, e.g. `auto_reconcile = true # off for now`.
|
|
450
|
+
value = value.partition("#")[0].strip()
|
|
451
|
+
value = value.strip('"').strip("'")
|
|
452
|
+
sections[current_section][key] = value
|
|
453
|
+
|
|
454
|
+
return sections
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _render_config(
|
|
458
|
+
*,
|
|
459
|
+
edition: str | None = None,
|
|
460
|
+
user_id: str,
|
|
461
|
+
url: str,
|
|
462
|
+
user: str,
|
|
463
|
+
password: str,
|
|
464
|
+
database: str,
|
|
465
|
+
openai_api_key: str,
|
|
466
|
+
anthropic_api_key: str,
|
|
467
|
+
auto_reconcile: bool | None,
|
|
468
|
+
embedding_model: str | None = None,
|
|
469
|
+
recall_settings: dict[str, str] | None = None,
|
|
470
|
+
ontology_path: str | None = None,
|
|
471
|
+
ontology_derive: str | None = None,
|
|
472
|
+
github_token: str | None = None,
|
|
473
|
+
memory_backend: str | None = None,
|
|
474
|
+
) -> str:
|
|
475
|
+
"""Render the full config file content.
|
|
476
|
+
|
|
477
|
+
The ``[reconcile]`` section is omitted entirely when ``auto_reconcile`` is
|
|
478
|
+
``None`` (never configured), so a fresh read of the file resolves it back
|
|
479
|
+
to ``None`` rather than a concrete ``false`` — see
|
|
480
|
+
:func:`resolve_auto_reconcile` for why that distinction matters.
|
|
481
|
+
``[recall]``, ``[ontology]``, ``[github]`` and ``[memory]`` are likewise omitted while they hold nothing.
|
|
482
|
+
"""
|
|
483
|
+
lines = [
|
|
484
|
+
"# Context Graph hook configuration",
|
|
485
|
+
"# Generated by: memgraph-context-graph config set / bootstrap",
|
|
486
|
+
f"# Location: {config_file()}",
|
|
487
|
+
"",
|
|
488
|
+
*([f'edition = "{edition}"', ""] if edition else []),
|
|
489
|
+
# Enterprise takes the user and their database from the Memgraph login, so it writes neither.
|
|
490
|
+
*(["[identity]", f'user_id = "{user_id}"', ""] if edition != "enterprise" else []),
|
|
491
|
+
"[memgraph]",
|
|
492
|
+
f'url = "{url}"',
|
|
493
|
+
f'user = "{user}"',
|
|
494
|
+
f'password = "{password}"',
|
|
495
|
+
*([f'database = "{database}"'] if edition != "enterprise" else []),
|
|
496
|
+
"",
|
|
497
|
+
"[llm]",
|
|
498
|
+
f'openai_api_key = "{openai_api_key}"',
|
|
499
|
+
f'anthropic_api_key = "{anthropic_api_key}"',
|
|
500
|
+
]
|
|
501
|
+
if auto_reconcile is not None:
|
|
502
|
+
lines += ["", "[reconcile]", f"auto_reconcile = {'true' if auto_reconcile else 'false'}"]
|
|
503
|
+
recall = {"embedding_model": embedding_model, **(recall_settings or {})} if embedding_model else recall_settings
|
|
504
|
+
if recall:
|
|
505
|
+
lines += ["", "[recall]", *(f'{key} = "{value}"' for key, value in recall.items())]
|
|
506
|
+
ontology = {key: value for key, value in (("path", ontology_path), ("derive", ontology_derive)) if value}
|
|
507
|
+
if ontology:
|
|
508
|
+
lines += ["", "[ontology]", *(f'{key} = "{value}"' for key, value in ontology.items())]
|
|
509
|
+
if github_token:
|
|
510
|
+
lines += ["", "[github]", f'token = "{github_token}"']
|
|
511
|
+
if memory_backend:
|
|
512
|
+
lines += ["", "[memory]", f'backend = "{memory_backend}"']
|
|
513
|
+
lines.append("")
|
|
514
|
+
return "\n".join(lines)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _reset_cache() -> None:
|
|
518
|
+
"""Reset the module cache (for testing)."""
|
|
519
|
+
global _cached_config
|
|
520
|
+
_cached_config = None
|