diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a54c7ff..1806d2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12"] + python-version: ["3.12", "3.14"] runs-on: ${{ matrix.os }} steps: diff --git a/docs/hooks/hermes.md b/docs/hooks/hermes.md index f8bd0c4..bc9acd2 100644 --- a/docs/hooks/hermes.md +++ b/docs/hooks/hermes.md @@ -3,3 +3,5 @@ `keepygaga install --yes --host hermes` projects one Context Bootstrap command at `pre_llm_call` within `~/.hermes/config.yaml`. The first turn receives the live Profile and Preferences pages, scope-routing guidance, and the routing/completion reminder in one `context` payload. Because Hermes replays persisted injected context, a later turn (`extra.is_first_turn` is `false`) whose reported user history still carries that complete block receives only the reminder. A later turn whose history no longer holds it — a session path that does not persist injected context — or whose home pages changed since receives the full bootstrap again, because the check matches the whole block rather than any fragment of Keepygaga markup. A payload without `extra.is_first_turn` set to `false` receives the full bootstrap. Hermes `subagent_start` is an observer event whose return value is ignored; it is not a context-injection surface. Keepygaga does not project a separate closeout event. Round-trip YAML merging removes obsolete Keepygaga-owned closeout and standalone route projections while preserving unrelated keys, comments, ordering, and Hook entries. + +Hermes hooks inherit its `PYTHONPATH`, which points at Hermes dependencies built for its own Python. The Keepygaga CLI moves those entries behind its own on `sys.path` before loading any dependency, so a hook runs on the packages installed with Keepygaga. diff --git a/keepygaga/cli.py b/keepygaga/cli.py index 25de050..247dafb 100644 --- a/keepygaga/cli.py +++ b/keepygaga/cli.py @@ -5,6 +5,7 @@ import binascii import importlib import json +import os import sys from collections.abc import Mapping from contextlib import suppress @@ -374,7 +375,25 @@ def _run_host_command(args: argparse.Namespace, config_path: Path) -> int: return 0 +def _demote_host_pythonpath() -> None: + """Hooks inherit the host's PYTHONPATH; its packages must not replace this tool's own. + + Moved behind them rather than dropped, since a host path may also be this tool's own. + """ + + def normal(path: str) -> str: + return os.path.normcase(os.path.abspath(path)) + + host_paths = { + normal(path) + for path in os.environ.get("PYTHONPATH", "").split(os.pathsep) + if path + } + sys.path.sort(key=lambda path: normal(path) in host_paths) + + def main(argv: list[str] | None = None) -> int: + _demote_host_pythonpath() parser = _parser() args = parser.parse_args(argv) if args.command is None: diff --git a/pyproject.toml b/pyproject.toml index 151cedb..f7430ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.14", ] dependencies = [ "filelock>=3.16,<4", diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 546c987..4f403de 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -6,6 +6,7 @@ import shutil import subprocess import sys +import sysconfig from pathlib import Path import pytest @@ -518,6 +519,47 @@ def test_route_cli_needs_no_memory_config_or_session_state( } +@pytest.mark.parametrize("own_packages", [False, True]) +def test_context_cli_prefers_own_packages_over_host_pythonpath( + tmp_path: Path, own_packages: bool +) -> None: + memory_root = tmp_path / "agents-memory" + initialize_memory_tree(memory_root, MemoryFilesConfig(root=str(memory_root))) + config = tmp_path / "keepygaga.toml" + config.write_text(f"[memory]\nroot = '{memory_root}'\n", encoding="utf-8") + host_packages = tmp_path / "host" + (host_packages / "pydantic").mkdir(parents=True) + (host_packages / "pydantic" / "__init__.py").write_text( + "raise ImportError('host pydantic')", encoding="utf-8" + ) + pythonpath = ( + sysconfig.get_path("purelib") if own_packages else f"{host_packages}{os.sep}" + ) + completed = subprocess.run( + [ + sys.executable, + "-m", + "keepygaga.cli", + "--config", + str(config), + "hook", + "run", + "context", + "--host", + "hermes", + "--event", + "pre_llm_call", + ], + input="{}", + capture_output=True, + encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONPATH": pythonpath}, + check=True, + ) + assert "