diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/_vendored.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/_vendored.py new file mode 100644 index 00000000..653bd79d --- /dev/null +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/_vendored.py @@ -0,0 +1,119 @@ +"""Vendored symbols from ``context_intelligence`` for standalone installability. + +The hook module's runtime needs four symbols from the parent bundle's +``context_intelligence`` package. Copying them here lets the hook install +and mount standalone — without requiring the parent bundle on PYTHONPATH +or in the install tree. + +Provenance (last synced from upstream): + - AMPLIFIER_DIR, SETTINGS_PATH, _parse_settings_yaml + → context_intelligence/config.py + - workspace_slug + → context_intelligence/reconstruct/discover.py + +If you change behaviour in either of those upstream files, mirror the change +here. The two copies are aligned by convention, not by shared import — the +hook MUST NOT import from ``context_intelligence`` at runtime, or it stops +being standalone-installable in environments that strip ``[tool.uv.sources]`` +(notably amplifier-agent's ``--no-sources`` activator policy). + +This module is internal to the hook package. External callers should not +import from here; if you need the same logic in your own code, copy it. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +log = logging.getLogger("amplifier_module_hook_context_intelligence._vendored") + +# --------------------------------------------------------------------------- +# From context_intelligence/config.py +# --------------------------------------------------------------------------- + +AMPLIFIER_DIR = Path.home() / ".amplifier" +SETTINGS_PATH = AMPLIFIER_DIR / "settings.yaml" + + +def _parse_settings_yaml(path: Path) -> dict: + """Minimal YAML parser for settings.yaml -- good enough for the flat keys + we need without requiring PyYAML. + + Returns a dict with CI server config keys (``server_url``, ``api_key``) if found. + """ + result: dict[str, str] = {} + if not path.is_file(): + return result + + try: + # Try PyYAML first + import yaml + + with open(path) as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + ci_cfg = ( + data.get("overrides", {}).get("hook-context-intelligence", {}).get("config", {}) + ) + if isinstance(ci_cfg, dict): + if "context_intelligence_server_url" in ci_cfg: + result["server_url"] = ci_cfg["context_intelligence_server_url"] + if "context_intelligence_api_key" in ci_cfg: + result["api_key"] = ci_cfg["context_intelligence_api_key"] + except ImportError: + # Fallback: crude line-based extraction + try: + text = path.read_text() + in_ci_section = False + for line in text.splitlines(): + stripped = line.strip() + if "hook-context-intelligence" in stripped: + in_ci_section = True + continue + if in_ci_section: + if stripped.startswith("context_intelligence_server_url:"): + val = stripped.split(":", 1)[1].strip().strip("'\"") + result["server_url"] = val + elif stripped.startswith("context_intelligence_api_key:"): + val = stripped.split(":", 1)[1].strip().strip("'\"") + result["api_key"] = val + # If we hit a non-indented line, we've left the section + if not line.startswith(" ") and not line.startswith("\t") and stripped: + if "context_intelligence" not in stripped: + in_ci_section = False + except OSError: + pass + except Exception as exc: + log.debug("Could not parse %s: %s", path, exc) + return result + + +# --------------------------------------------------------------------------- +# From context_intelligence/reconstruct/discover.py +# --------------------------------------------------------------------------- + + +def workspace_slug(project_dir: str) -> str: + """Derive the workspace slug from an absolute project directory path. + + Converts the absolute path to a slug by replacing every ``/`` with ``-``. + + Examples:: + + workspace_slug("/home/bkrabach/dev/attractor-dev-machine") + # -> "-home-bkrabach-dev-attractor-dev-machine" + + Parameters + ---------- + project_dir: + Absolute path to the project directory. + + Returns + ------- + str + Slug derived from the absolute path. + """ + import os + + return os.path.abspath(project_dir).replace("/", "-") diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index aa2b6f2a..3ff76488 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -6,8 +6,9 @@ from pathlib import Path from typing import Any -from context_intelligence.config import SETTINGS_PATH, _parse_settings_yaml -from context_intelligence.reconstruct.discover import workspace_slug +from ._vendored import SETTINGS_PATH +from ._vendored import _parse_settings_yaml +from ._vendored import workspace_slug _DEFAULT_BASE_PATH = "~/.amplifier/projects" _DEFAULT_PROJECT_SLUG = "default" diff --git a/modules/hook-context-intelligence/pyproject.toml b/modules/hook-context-intelligence/pyproject.toml index 375e53be..970d78b1 100644 --- a/modules/hook-context-intelligence/pyproject.toml +++ b/modules/hook-context-intelligence/pyproject.toml @@ -8,7 +8,6 @@ license = "MIT" dependencies = [ "httpx>=0.28.1", "idna>=3.15", - "amplifier-bundle-context-intelligence", ] [project.entry-points."amplifier.modules"] @@ -36,7 +35,6 @@ dev = [ [tool.uv.sources] amplifier-core = { git = "https://github.com/microsoft/amplifier-core", rev = "v1.4.1" } -amplifier-bundle-context-intelligence = { path = "../.." } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/modules/hook-context-intelligence/tests/test_config_resolver.py b/modules/hook-context-intelligence/tests/test_config_resolver.py index ee9c47cf..90645fdd 100644 --- a/modules/hook-context-intelligence/tests/test_config_resolver.py +++ b/modules/hook-context-intelligence/tests/test_config_resolver.py @@ -750,7 +750,7 @@ def test_unix_path_produces_expected_slug(self) -> None: assert _slugify_path("/home/user/project") == "-home-user-project" def test_unix_path_matches_workspace_slug(self) -> None: - from context_intelligence.reconstruct.discover import workspace_slug + from amplifier_module_hook_context_intelligence._vendored import workspace_slug path = "/home/user/project" assert _slugify_path(path) == workspace_slug(path) diff --git a/modules/hook-context-intelligence/tests/test_hook_dependencies.py b/modules/hook-context-intelligence/tests/test_hook_dependencies.py index 3a945690..b7586e84 100644 --- a/modules/hook-context-intelligence/tests/test_hook_dependencies.py +++ b/modules/hook-context-intelligence/tests/test_hook_dependencies.py @@ -1,6 +1,9 @@ -"""Test that the hook module pyproject.toml declares the context_intelligence dependency. +"""Test that the hook module pyproject.toml does NOT depend on its parent bundle. -TDD: This test is written FIRST and will FAIL until pyproject.toml is updated. +Design: The hook module should be installable independently of its parent bundle +so it can be used in environments that compose modules individually. + +TDD: These tests are written to enforce decoupling from the parent bundle. """ from __future__ import annotations @@ -17,36 +20,41 @@ def _load_pyproject() -> dict: class TestHookDependencies: - """Verify hook module's pyproject.toml declares amplifier-bundle-context-intelligence.""" - - def test_amplifier_bundle_context_intelligence_in_dependencies(self) -> None: - """amplifier-bundle-context-intelligence must appear in the dependencies list.""" + """Verify hook module's pyproject.toml does NOT depend on its parent bundle. + + Per the design proposal: the hook module should be installable independently + of its parent bundle so it can be used in environments that compose modules + individually (e.g. amplifier-agent's --no-sources install policy). + """ + + def test_amplifier_bundle_context_intelligence_not_in_dependencies(self) -> None: + """amplifier-bundle-context-intelligence must NOT appear in dependencies.""" data = _load_pyproject() deps: list[str] = data["project"]["dependencies"] dep_names = [d.split(">=")[0].split("==")[0].strip() for d in deps] - assert "amplifier-bundle-context-intelligence" in dep_names, ( - f"Expected 'amplifier-bundle-context-intelligence' in dependencies, got: {deps}" + assert "amplifier-bundle-context-intelligence" not in dep_names, ( + "The hook module must not declare its parent bundle as a runtime " + "dependency. Doing so makes the module uninstallable in environments " + "that strip [tool.uv.sources] at install time." ) - def test_uv_sources_has_path_entry_for_bundle(self) -> None: - """[tool.uv.sources] must have path = '../..' for amplifier-bundle-context-intelligence.""" + def test_uv_sources_does_not_reference_bundle(self) -> None: + """[tool.uv.sources] must NOT have a path entry for the parent bundle.""" data = _load_pyproject() sources: dict = data.get("tool", {}).get("uv", {}).get("sources", {}) - assert "amplifier-bundle-context-intelligence" in sources, ( - f"Expected 'amplifier-bundle-context-intelligence' in [tool.uv.sources], got: {sources}" + assert "amplifier-bundle-context-intelligence" not in sources, ( + "The hook module must not pin its parent bundle via [tool.uv.sources]. " + "This entry is invisible under --no-sources install and produces an " + "unresolvable dependency." ) - entry = sources["amplifier-bundle-context-intelligence"] - assert entry.get("path") == "../..", f"Expected path = '../..', got: {entry}" - def test_dependencies_list_has_httpx_and_bundle(self) -> None: - """The production dependencies must include httpx and amplifier-bundle-context-intelligence. - amplifier-core is NOT a production dep — it is runtime-provided by the Amplifier CLI. - """ + def test_dependencies_list_has_httpx_only(self) -> None: + """Production dependencies must be httpx (and nothing else from this project).""" data = _load_pyproject() deps: list[str] = data["project"]["dependencies"] - assert any("httpx" in d for d in deps), f"httpx not found in {deps}" - assert any("amplifier-bundle-context-intelligence" in d for d in deps), ( - f"amplifier-bundle-context-intelligence not found in {deps}" + assert any("httpx" in d for d in deps), "httpx is required for HTTP dispatch" + assert not any("amplifier-bundle-context-intelligence" in d for d in deps), ( + "Parent bundle must not be a runtime dependency of the hook." ) assert not any("amplifier-core" in d for d in deps), ( f"amplifier-core must not be a production dep (runtime-provided): {deps}"