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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/hooks/hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions keepygaga/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import binascii
import importlib
import json
import os
import sys
from collections.abc import Mapping
from contextlib import suppress
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import shutil
import subprocess
import sys
import sysconfig
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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 "<profile version=" in completed.stdout
assert "keepygaga-bootstrap-error" not in completed.stdout


@pytest.mark.parametrize("host", ["codex", "claude", "workbuddy"])
def test_fragment_removes_owned_post_tool_use_and_preserves_other_hooks(
tmp_path: Path, host: str
Expand Down
Loading