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.
Files changed (33) hide show
  1. memgraph_context_graph/__init__.py +71 -0
  2. memgraph_context_graph/adapters/__init__.py +0 -0
  3. memgraph_context_graph/adapters/_identity.py +520 -0
  4. memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
  5. memgraph_context_graph/adapters/_spec.py +441 -0
  6. memgraph_context_graph/adapters/antigravity_cli.py +152 -0
  7. memgraph_context_graph/adapters/claude.py +297 -0
  8. memgraph_context_graph/adapters/claude_code.py +216 -0
  9. memgraph_context_graph/adapters/claude_code_memory.py +181 -0
  10. memgraph_context_graph/adapters/codex.py +118 -0
  11. memgraph_context_graph/adapters/codex_memory.py +185 -0
  12. memgraph_context_graph/adapters/copilot_cli.py +236 -0
  13. memgraph_context_graph/adapters/cursor.py +189 -0
  14. memgraph_context_graph/adapters/grok.py +187 -0
  15. memgraph_context_graph/adapters/openai.py +240 -0
  16. memgraph_context_graph/adapters/opencode.py +168 -0
  17. memgraph_context_graph/cli.py +845 -0
  18. memgraph_context_graph/events.py +210 -0
  19. memgraph_context_graph/hooks/__init__.py +1 -0
  20. memgraph_context_graph/hooks/cli.py +184 -0
  21. memgraph_context_graph/hooks/runner.py +311 -0
  22. memgraph_context_graph/hooks/runtime_plugin.py +85 -0
  23. memgraph_context_graph/link.py +50 -0
  24. memgraph_context_graph/mcp_server.py +102 -0
  25. memgraph_context_graph/memory_backend.py +144 -0
  26. memgraph_context_graph/protocols.py +67 -0
  27. memgraph_context_graph/spaces.py +214 -0
  28. memgraph_context_graph/tools.py +130 -0
  29. memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
  30. memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
  31. memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
  32. memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
  33. 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