Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions context-graph/agent-context-graph/docs/command-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,37 @@ The generated hook command does not embed any Memgraph connection values. At run

If Memgraph requires a password, provide `MEMGRAPH_PASSWORD` to the Codex process environment. `.codex/hooks.json` should not contain Memgraph credentials.

For the Claude Code plugin, connection settings instead come from
`~/.config/context-graph/config.toml` (see "Persistent hook configuration"
below) rather than from the hook process's environment.

### Persistent hook configuration

`agent-context-graph config set/get/show` read and write
`~/.config/context-graph/config.toml`, which hook subprocesses consult
directly (CLI flag > config file > default; see ADR 0002). Supported keys:

```text
identity.user_id
memgraph.url
memgraph.user
memgraph.password
memgraph.database
llm.openai_api_key
llm.anthropic_api_key
```

The `llm.*` keys are only needed if you enable the `sessions-graph` connector
with `auto_reconcile` (`SESSIONS_GRAPH_AUTO_RECONCILE=1` at connector
construction time): reconciliation shells out to a detached
`sessions-graph reconcile` subprocess that does LLM-backed entity extraction
via LightRAG, and needs an `OPENAI_API_KEY` (or `ANTHROPIC_API_KEY`) the same
way it needs Memgraph credentials — resolved from this config file and
injected into that subprocess's environment explicitly, not inherited from
ambient shell env (see ADR 0003). `agent-context-graph bootstrap` captures
`OPENAI_API_KEY`/`ANTHROPIC_API_KEY` from its own environment into the config
file automatically, the same way it already does for `MEMGRAPH_*`.

To smoke test the generated command, copy the `"command"` value from `.codex/hooks.json` and run:

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

Agent runtimes (Claude Code, Codex) spawn hook commands as non-interactive
subprocesses that do not inherit shell profile environment variables. This
module provides a config-file-only resolution path so hook subprocesses can
reliably access identity and Memgraph connection settings.
module provides a config-file-only resolution path so hook subprocesses (and
subprocesses they in turn spawn, e.g. sessions-graph's detached
reconciliation process) can reliably access identity, Memgraph connection,
and LLM API key settings.

Resolution order (per ADR 0002):
CLI flag > config file > hardcoded default
Expand Down Expand Up @@ -32,6 +34,11 @@
"database": "memgraph",
}

_LLM_DEFAULTS = {
"openai_api_key": "",
"anthropic_api_key": "",
}

_sentinel = object()
_cached_config: object = _sentinel

Expand All @@ -45,6 +52,8 @@ class HookConfig:
memgraph_user: str = _MEMGRAPH_DEFAULTS["user"]
memgraph_password: str = _MEMGRAPH_DEFAULTS["password"]
memgraph_database: str = _MEMGRAPH_DEFAULTS["database"]
openai_api_key: str = _LLM_DEFAULTS["openai_api_key"]
anthropic_api_key: str = _LLM_DEFAULTS["anthropic_api_key"]


def load_config() -> HookConfig:
Expand Down Expand Up @@ -95,13 +104,31 @@ def resolve_memgraph_env(
}


def resolve_llm_env() -> dict[str, str]:
"""Resolve LLM API key settings for hook subprocesses.

Config file only — mirrors :func:`resolve_memgraph_env`, but there is no
CLI-flag override path for these since nothing resolves them from argparse.
Values are empty strings when not configured; callers should treat an
empty value as "not configured" rather than overlaying it onto a child
process's environment.
"""
config = load_config()
return {
"OPENAI_API_KEY": config.openai_api_key,
"ANTHROPIC_API_KEY": config.anthropic_api_key,
}


def write_config(
*,
user_id: str | None = None,
memgraph_url: str | None = None,
memgraph_user: str | None = None,
memgraph_password: str | None = None,
memgraph_database: str | None = None,
openai_api_key: str | None = None,
anthropic_api_key: str | None = None,
) -> Path:
"""Write or update the config file. Returns the path written to.

Expand All @@ -118,13 +145,17 @@ def write_config(
final_user = memgraph_user if memgraph_user is not None else existing.memgraph_user
final_password = memgraph_password if memgraph_password is not None else existing.memgraph_password
final_database = memgraph_database if memgraph_database is not None else existing.memgraph_database
final_openai_api_key = openai_api_key if openai_api_key is not None else existing.openai_api_key
final_anthropic_api_key = anthropic_api_key if anthropic_api_key is not None else existing.anthropic_api_key

content = _render_config(
user_id=final_user_id or "",
url=final_url,
user=final_user,
password=final_password,
database=final_database,
openai_api_key=final_openai_api_key,
anthropic_api_key=final_anthropic_api_key,
)

_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
Expand All @@ -143,6 +174,8 @@ def write_full_config(
memgraph_user: str = _MEMGRAPH_DEFAULTS["user"],
memgraph_password: str = _MEMGRAPH_DEFAULTS["password"],
memgraph_database: str = _MEMGRAPH_DEFAULTS["database"],
openai_api_key: str = _LLM_DEFAULTS["openai_api_key"],
anthropic_api_key: str = _LLM_DEFAULTS["anthropic_api_key"],
) -> Path:
"""Write a complete config file with all sections (used by bootstrap).

Expand All @@ -156,6 +189,8 @@ def write_full_config(
user=memgraph_user,
password=memgraph_password,
database=memgraph_database,
openai_api_key=openai_api_key,
anthropic_api_key=anthropic_api_key,
)

_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -191,13 +226,16 @@ def _read_config_file() -> HookConfig:

identity = sections.get("identity", {})
memgraph = sections.get("memgraph", {})
llm = sections.get("llm", {})

return HookConfig(
user_id=identity.get("user_id") or None,
memgraph_url=memgraph.get("url") or _MEMGRAPH_DEFAULTS["url"],
memgraph_user=memgraph.get("user", _MEMGRAPH_DEFAULTS["user"]),
memgraph_password=memgraph.get("password", _MEMGRAPH_DEFAULTS["password"]),
memgraph_database=memgraph.get("database") or _MEMGRAPH_DEFAULTS["database"],
openai_api_key=llm.get("openai_api_key", _LLM_DEFAULTS["openai_api_key"]),
anthropic_api_key=llm.get("anthropic_api_key", _LLM_DEFAULTS["anthropic_api_key"]),
)


Expand Down Expand Up @@ -233,6 +271,8 @@ def _render_config(
user: str,
password: str,
database: str,
openai_api_key: str,
anthropic_api_key: str,
) -> str:
"""Render the full config file content."""
lines = [
Expand All @@ -249,6 +289,10 @@ def _render_config(
f'password = "{password}"',
f'database = "{database}"',
"",
"[llm]",
f'openai_api_key = "{openai_api_key}"',
f'anthropic_api_key = "{anthropic_api_key}"',
"",
]
return "\n".join(lines)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ def _config(argv: list[str]) -> int:
"memgraph.user": "memgraph_user",
"memgraph.password": "memgraph_password",
"memgraph.database": "memgraph_database",
"llm.openai_api_key": "openai_api_key",
"llm.anthropic_api_key": "anthropic_api_key",
}
_SECRET_KEYS = {"memgraph.password", "llm.openai_api_key", "llm.anthropic_api_key"}

if not argv or argv[0] in {"-h", "--help"}:
print("usage: agent-context-graph config set <key> <value>")
Expand All @@ -103,6 +106,8 @@ def _config(argv: list[str]) -> int:
print(f"memgraph.user = {config.memgraph_user!r}")
print(f"memgraph.password = {'***' if config.memgraph_password else repr('')}")
print(f"memgraph.database = {config.memgraph_database!r}")
print(f"llm.openai_api_key = {'***' if config.openai_api_key else repr('')}")
print(f"llm.anthropic_api_key = {'***' if config.anthropic_api_key else repr('')}")
return 0

if action == "set":
Expand All @@ -115,20 +120,20 @@ def _config(argv: list[str]) -> int:
print(f"Supported keys: {', '.join(_CONFIG_KEYS)}", file=sys.stderr)
return 2

# Password: read from stdin if value not provided.
# Secrets: read from stdin if value not provided.
if len(argv) < 3:
if key == "memgraph.password":
if key in _SECRET_KEYS:
import getpass

value = getpass.getpass("Enter memgraph password: ")
value = getpass.getpass(f"Enter {key}: ")
else:
print(f"usage: agent-context-graph config set {key} <value>", file=sys.stderr)
return 2
else:
value = argv[2]

write_config(**{_CONFIG_KEYS[key]: value})
display_value = "***" if key == "memgraph.password" else repr(value)
display_value = "***" if key in _SECRET_KEYS else repr(value)
print(f"Wrote {key} = {display_value} to {config_path}")
return 0

Expand All @@ -147,8 +152,8 @@ def _config(argv: list[str]) -> int:
if value is None:
print(f"{key} is not set in {config_path}", file=sys.stderr)
return 1
# Don't print password to stdout unless explicitly requested.
if key == "memgraph.password":
# Don't print secrets to stdout unless explicitly requested.
if key in _SECRET_KEYS:
print("***")
else:
print(value)
Expand Down Expand Up @@ -265,20 +270,36 @@ def _bootstrap(argv: list[str]) -> int:
memgraph_user = os.environ.get("MEMGRAPH_USER", "")
memgraph_password = os.environ.get("MEMGRAPH_PASSWORD", "")
memgraph_database = os.environ.get("MEMGRAPH_DATABASE", "memgraph")
openai_api_key = os.environ.get("OPENAI_API_KEY", "")
anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")

config_path = write_full_config(
user_id=user_id,
memgraph_url=memgraph_url,
memgraph_user=memgraph_user,
memgraph_password=memgraph_password,
memgraph_database=memgraph_database,
openai_api_key=openai_api_key,
anthropic_api_key=anthropic_api_key,
)
print(f"OK config: wrote {config_path}")
if user_id:
print(f" identity.user_id = {user_id!r}")
else:
print(" identity.user_id is empty — set it with: agent-context-graph config set identity.user_id <your-name>")
print(f" memgraph.url = {memgraph_url!r}")
if openai_api_key or anthropic_api_key:
found = ", ".join(
name
for name, value in (("llm.openai_api_key", openai_api_key), ("llm.anthropic_api_key", anthropic_api_key))
if value
)
print(f" {found} captured from environment")
else:
print(
" no LLM API key found in environment — sessions-graph reconciliation needs one; "
"set with: agent-context-graph config set llm.openai_api_key"
)

doctor_args = ["--runtime", args.runtime]
for connector in connectors:
Expand Down Expand Up @@ -344,6 +365,10 @@ def _check_config() -> dict[str, object]:
else:
parts.append("user_id=NOT SET")
parts.append(f"memgraph.url={config.memgraph_url!r}")
llm_key_set = bool(config.openai_api_key or config.anthropic_api_key)
parts.append(
"llm_key=SET" if llm_key_set else "llm_key=NOT SET (needed only for sessions-graph auto-reconciliation)"
)
return {"name": "config", "ok": bool(config.user_id), "detail": f"{path} — {'; '.join(parts)}"}


Expand Down
27 changes: 27 additions & 0 deletions context-graph/agent-context-graph/tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,33 @@ def test_cli_flag_overrides_config(config_dir):
assert env["MEMGRAPH_URL"] == "bolt://flag:9999"


# --- resolve_llm_env ---


def test_llm_defaults_when_no_config(config_dir):
env = _identity.resolve_llm_env()
assert env["OPENAI_API_KEY"] == ""
assert env["ANTHROPIC_API_KEY"] == ""


def test_llm_from_config_file(config_dir):
_identity.write_full_config(openai_api_key="sk-openai-secret", anthropic_api_key="sk-anthropic-secret")
_identity._reset_cache()
env = _identity.resolve_llm_env()
assert env["OPENAI_API_KEY"] == "sk-openai-secret"
assert env["ANTHROPIC_API_KEY"] == "sk-anthropic-secret"


def test_write_config_preserves_llm_keys(config_dir):
_identity.write_full_config(openai_api_key="sk-openai-secret")
_identity._reset_cache()
_identity.write_config(user_id="someone")
_identity._reset_cache()
config = _identity.load_config()
assert config.openai_api_key == "sk-openai-secret"
assert config.user_id == "someone"


# --- write_config ---


Expand Down
22 changes: 22 additions & 0 deletions context-graph/sessions-graph/src/sessions_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import argparse
import asyncio
import os
import sys
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -77,7 +78,28 @@ def _reconcile(argv: list[str]) -> int:
return asyncio.run(_run_reconcile(parsed))


def _fill_env_from_context_graph_config() -> None:
"""Best-effort fallback for standalone (manual/cron) ``reconcile`` runs.

When spawned by the SESSION_END hook, the parent process already overlays
context-graph's config.toml onto this subprocess's environment (see
sessions_graph.connector._reconciliation_env). Run standalone, there's no
such parent, so fill in the same values here -- only for keys not already
set, so explicit ambient env always wins. agent-context-graph is an
optional extra; silently skip if it isn't installed.
"""
try:
from agent_context_graph.adapters._identity import resolve_llm_env, resolve_memgraph_env
except ImportError:
return
for key, value in {**resolve_memgraph_env(), **resolve_llm_env()}.items():
if value:
os.environ.setdefault(key, value)


async def _run_reconcile(parsed: argparse.Namespace) -> int:
_fill_env_from_context_graph_config()

from lightrag_memgraph import MemgraphLightRAGWrapper
from sessions_graph import SessionsGraph

Expand Down
22 changes: 22 additions & 0 deletions context-graph/sessions-graph/src/sessions_graph/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ def _spawn_reconciliation(session_id: str) -> None:
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=_reconciliation_env(),
)
except OSError as e:
logger.warning(f"Could not spawn detached reconciliation process for session {session_id}: {e}")


def _reconciliation_env() -> dict[str, str]:
"""Build the environment for the detached ``sessions-graph reconcile`` subprocess.

This hook process resolves Memgraph connection settings from
``~/.config/context-graph/config.toml`` (per ADR 0002) purely as constructor
kwargs -- never writing them into ``os.environ``. A plain ``Popen`` without an
explicit ``env=`` would therefore leave the detached reconciliation subprocess
with ambient ``os.environ`` only, missing both the configured Memgraph
connection and any LLM API key LightRAG needs. Overlay the same config-file
resolution onto a copy of the ambient environment so the child gets what this
process would have used, without discarding real ambient values (e.g. an
OPENAI_API_KEY already exported) when config-file values are unset.
"""
from agent_context_graph.adapters._identity import resolve_llm_env, resolve_memgraph_env

env = dict(os.environ)
env.update({k: v for k, v in resolve_memgraph_env().items() if v})
env.update({k: v for k, v in resolve_llm_env().items() if v})
return env
Loading
Loading