{"repo":"NousResearch/hermes-agent","public_id":"682","display_label":"@ context reference expansion and bug corrections","summary":"These items cover the addition of @ context references (for files, folders, diffs, URLs, and more) in messages. A recurring bug caused reference expansion to silently stop due to an AttributeError on a nonexistent self._model attribute in the gateway.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2343},"resolution":{"state":"resolved","by":"pr-2343,pr-62783"},"freshness":{"state":"verified","last_verified_at":"2026-07-11T18:16:14+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":682,"pr_number":2343,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62695,"pr_number":62783,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":682,"title":"Feature: @ Context References — Scoped File, Folder, Diff & URL Injection in Messages","url":null,"state":"closed"},{"kind":"issue","number":62695,"title":"Gateway @ context-reference expansion never runs: AttributeError on nonexistent self._model, silently swallowed","url":null,"state":"closed"},{"kind":"pr","number":2090,"title":"Add @ context references for files, folders, diffs, git, and URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2343","number":2343}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2343,"title":"feat: @ context references + Honcho config fixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_references.py","cli.py","gateway/run.py","hermes_cli/doctor.py","honcho_integration/cli.py","honcho_integration/client.py","run_agent.py","tests/honcho_integration/test_client.py","tests/test_context_references.py"],"fix_hunks":[{"file":"agent/context_references.py","hunks":["@@ -0,0 +1,440 @@\n+from __future__ import annotations\n+\n+import asyncio\n+import inspect\n+import json\n+import mimetypes\n+import os\n+import re\n+import subprocess\n+from dataclasses import dataclass, field\n+from pathlib import Path\n+from typing import Awaitable, Callable\n+\n+from agent.model_metadata import estimate_tokens_rough\n+\n+REFERENCE_PATTERN = re.compile(\n+    r\"(?<![\\w/])@(?:(?P<simple>diff|staged)\\b|(?P<kind>file|folder|git|url):(?P<value>\\S+))\"\n+)\n+TRAILING_PUNCTUATION = \",.;!?\"\n+\n+\n+@dataclass(frozen=True)\n+class ContextReference:\n+    raw: str\n+    kind: str\n+    target: str\n+    start: int\n+    end: int\n+    line_start: int | None = None\n+    line_end: int | None = None\n+\n+\n+@dataclass\n+class ContextReferenceResult:\n+    message: str\n+    original_message: str\n+    references: list[ContextReference] = field(default_factory=list)\n+    warnings: list[str] = field(default_factory=list)\n+    injected_tokens: int = 0\n+    expanded: bool = False\n+    blocked: bool = False\n+\n+\n+def parse_context_references(message: str) -> list[ContextReference]:\n+    refs: list[ContextReference] = []\n+    if not message:\n+        return refs\n+\n+    for match in REFERENCE_PATTERN.finditer(message):\n+        simple = match.group(\"simple\")\n+        if simple:\n+            refs.append(\n+                ContextReference(\n+                    raw=match.group(0),\n+                    kind=simple,\n+                    target=\"\",\n+                    start=match.start(),\n+                    end=match.end(),\n+                )\n+            )\n+            continue\n+\n+        kind = match.group(\"kind\")\n+        value = _strip_trailing_punctuation(match.group(\"value\") or \"\")\n+        line_start = None\n+        line_end = None\n+        target = value\n+\n+        if kind == \"file\":\n+            range_match = re.match(r\"^(?P<path>.+?):(?P<start>\\d+)(?:-(?P<end>\\d+))?$\", value)\n+            if range_match:\n+                target = range_match.group(\"path\")\n+                line_start = int(range_match.group(\"start\"))\n+                line_end = int(range_match.group(\"end\") or range_match.group(\"start\"))\n+\n+        refs.append(\n+            ContextReference(\n+                raw=match.group(0),\n+                kind=kind,\n+                target=target,\n+                start=match.start(),\n+                end=match.end(),\n+                line_start=line_start,\n+                line_end=line_end,\n+            )\n+        )\n+\n+    return refs\n+\n+\n+def preprocess_context_references(\n+    message: str,\n+    *,\n+    cwd: str | Path,\n+    context_length: int,\n+    url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,\n+    allowed_root: str | Path | None = None,\n+) -> ContextReferenceResult:\n+    coro = preprocess_context_references_async(\n+        message,\n+        cwd=cwd,\n+        context_length=context_length,\n+        url_fetcher=url_fetcher,\n+        allowed_root=allowed_root,\n+    )\n+    # Safe for both CLI (no loop) and gateway (loop already running).\n+    try:\n+        loop = asyncio.get_running_loop()\n+    except RuntimeError:\n+        loop = None\n+    if loop and loop.is_running():\n+        import concurrent.futures\n+        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:\n+            return pool.submit(asyncio.run, coro).result()\n+    return asyncio.run(coro)\n+\n+\n+async def preprocess_context_references_async(\n+    message: str,\n+    *,\n+    cwd: str | Path,\n+    context_length: int,\n+    url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,\n+    allowed_root: str | Path | None = None,\n+) -> ContextReferenceResult:\n+    refs = parse_context_references(message)\n+    if not refs:\n+        return ContextReferenceResult(message=message, original_message=message)\n+\n+    cwd_path = Path(cwd).expanduser().resolve()\n+    allowed_root_path = Path(allowed_root).expanduser().resolve() if allowed_root is not None else None\n+    warnings: list[str] = []\n+    blocks: list[str] = []\n+    injected_tokens = 0\n+\n+    for ref in refs:\n+        warning, block = await _expand_reference(\n+            ref,\n+            cwd_path,\n+            url_fetcher=url_fetcher,\n+            allowed_root=allowed_root_path,\n+        )\n+        if warning:\n+            warnings.append(warning)\n+        if block:\n+            blocks.append(block)\n+            injected_tokens += estimate_tokens_rough(block)\n+\n+    hard_limit = max(1, int(context_length * 0.50))\n+    soft_limit = max(1, int(context_length * 0.25))\n+    if injected_tokens > hard_limit:\n+        warnings.append(\n+            f\"@ context injection refused: {injected_tokens} tokens exceeds the 50% hard limit ({hard_limit}).\"\n+        )\n+        return ContextReferenceResult(\n+            message=message,\n+            original_message=message,\n+            references=refs,\n+            warnings=warnings,\n+            injected_tokens=[redacted:secret-assignment]\n+            expanded=False,\n+            blocked=True,\n+        )\n+\n+    if injected_tokens > soft_limit:\n+        warnings.append(\n+            f\"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit}).\"\n+        )\n+\n+    stripped = _remove_reference_tokens(message, refs)\n+    final = stripped\n+    if warnings:\n+        final = f\"{final}\\n\\n--- Context Warnings ---\\n\" + \"\\n\".join(f\"- {warning}\" for warning in warnings)\n+    if blocks:\n+        final = f\"{final}\\n\\n--- Attached Context ---\\n\\n\" + \"\\n\\n\".join(blocks)\n+\n+    return ContextReferenceResult(\n+        message=final.strip(),\n+        original_message=message,\n+        references=refs,\n+        warnings=warnings,\n+        injected_tokens=[redacted:secret-assignment]\n+        expanded=bool(blocks or warnings),\n+        blocked=False,\n+    )\n+\n+\n+async def _expand_reference(\n+    ref: ContextReference,\n+    cwd: Path,\n+    *,\n+    url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,\n+    allowed_root: Path | None = None,\n+) -> tuple[str | None, str | None]:\n+    try:\n+        if ref.kind == \"file\":\n+            return _expand_file_reference(ref, cwd, allowed_root=allowed_root)\n+        if ref.kind == \"folder\":\n+            return _expand_folder_reference(ref, cwd, allowed_root=allowed_root)\n+        if ref.kind == \"diff\":\n+            return _expand_git_reference(ref, cwd, [\"diff\"], \"git diff\")\n+        if ref.kind == \"staged\":\n+            return _expand_git_reference(ref, cwd, [\"diff\", \"--staged\"], \"git diff --staged\")\n+        if ref.kind == \"git\":\n+            count = max(1, min(int(ref.target or \"1\"), 10))\n+            return _expand_git_reference(ref, cwd, [\"log\", f\"-{count}\", \"-p\"], f\"git log -{count} -p\")\n+        if ref.kind == \"url\":\n+            content = await _fetch_url_content(ref.target, url_fetcher=url_fetcher)\n+            if not content:\n+                return f\"{ref.raw}: no content extracted\", None\n+            return None, f\"🌐 {ref.raw} ({estimate_tokens_rough(content)} tokens)\\n{content}\"\n+    except Exception as exc:\n+        return f\"{ref.raw}: {exc}\", None\n+\n+    return f\"{ref.raw}: unsupported reference type\", None\n+\n+\n+def _expand_file_reference(\n+    ref: ContextReference,\n+    cwd: Path,\n+    *,\n+    allowed_root: Path | None = None,\n+) -> tuple[str | None, str | None]:\n+    path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)\n+    if not path.exists():\n+        return f\"{ref.raw}: file not found\", None\n+    if not path.is_file():\n+        return f\"{ref.raw}: path is not a file\", None\n+    if _is_binary_file(path):\n+        return f\"{ref.raw}: binary files are not supported\", None\n+\n+    text = path.read_text(encoding=\"utf-8\")\n+    if ref.line_start is not None:\n+        lines = text.splitlines()\n+        start_idx = max(ref.line_start - 1, 0)\n+        end_idx = min(ref.line_end or ref.line_start, len(lines))\n+        text = \"\\n\".join(lines[start_idx:end_idx])\n+\n+    lang = _code_fence_language(path)\n+    label = ref.raw\n+    return None, f\"📄 {label} ({estimate_tokens_rough(text)} tokens)\\n```{lang}\\n{text}\\n```\"\n+\n+\n+def _expand_folder_reference(\n+    ref: ContextReference,\n+    cwd: Path,\n+    *,\n+    allowed_root: Path | None = None,\n+) -> tuple[str | None, str | None]:\n+    path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)\n+    if not path.exists():\n+        return f\"{ref.raw}: folder not found\", None\n+    if not path.is_dir():\n+        return f\"{ref.raw}: path is not a folder\", None\n+\n+    listing = _build_folder_listing(path, cwd)\n+    return None, f\"📁 {ref.raw} ({estimate_tokens_rough(listing)} tokens)\\n{listing}\"\n+\n+\n+def _expand_git_reference(\n+    ref: ContextReference,\n+    cwd: Path,\n+    args: list[str],\n+    label: str,\n+) -> tuple[str | None, str | None]:\n+    result = subprocess.run(\n+        [\"git\", *args],\n+        cwd=cwd,\n+        capture_output=True,\n+        text=True,\n+    )\n+    if result.returncode != 0:\n+        stderr = (result.stderr or \"\").strip() or \"git command failed\"\n+        return f\"{ref.raw}: {stderr}\", None\n+    content = result.stdout.strip()\n+    if not content:\n+        content = \"(no output)\"\n+    return None, f\"🧾 {label} ({estimate_tokens_rough(content)} tokens)\\n```diff\\n{content}\\n```\"\n+\n+\n+async def _fetch_url_content(\n+    url: str,\n+    *,\n+    url_fetcher: Callable[[str], str | Awaitable[str]] | None = None,\n+) -> str:\n+    fetcher = url_fetcher or _default_url_fetcher\n+    content = fetcher(url)\n+    if inspect.isawaitable(content):\n+        content = await content\n+    return str(content or \"\").strip()\n+\n+\n+async def _default_url_fetcher(url: str) -> str:\n+    from tools.web_tools import web_extract_tool\n+\n+    raw = await web_extract_tool([url], format=\"markdown\", use_llm_processing=True)\n+    payload = json.loads(raw)\n+    docs = payload.get(\"data\", {}).get(\"documents\", [])\n+    if not docs:\n+        return \"\"\n+    doc = docs[0]\n+    return str(doc.get(\"content\") or doc.get(\"raw_content\") or \"\").strip()\n+\n+\n+def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -> Path:\n+    path = Path(os.path.expanduser(target))\n+    if not path.is_absolute():\n+        path = cwd / path\n+    resolved = path.resolve()\n+    if allowed_root is not None:\n+        try:\n+            resolved.relative_to(allowed_root)\n+        except ValueError as exc:\n+            raise ValueError(\"path is outside the allowed workspace\") from exc\n+    return resolved\n+\n+\n+def _strip_trailing_punctuation(value: str) -> str:\n+    stripped = value.rstrip(TRAILING_PUNCTUATION)\n+    while stripped.endswith((\")\", \"]\", \"}\")):\n+        closer = stripped[-1]\n+        opener = {\")\": \"(\", \"]\": \"[\", \"}\": \"{\"}[closer]\n+        if stripped.count(closer) > stripped.count(opener):\n+            stripped = stripped[:-1]\n+            continue\n+        break\n+    return stripped\n+\n+\n+def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:\n+    pieces: list[str] = []\n+    cursor = 0\n+    for ref in refs:\n+        pieces.append(message[cursor:ref.start])\n+        cursor = ref.end\n+    pieces.append(message[cursor:])\n+    text = \"\".join(pieces)\n+    text = re.sub(r\"\\s{2,}\", \" \", text)\n+    text = re.sub(r\"\\s+([,.;:!?])\", r\"\\1\", text)\n+    return text.strip()\n+\n+\n+def _is_binary_file(path: Path) -> bool:\n+    mime, _ = mimetypes.guess_type(path.name)\n+    if mime and not mime.startswith(\"text/\") and not any(\n+        path.name.endswith(ext) for ext in (\".py\", \".md\", \".txt\", \".json\", \".yaml\", \".yml\", \".toml\", \".js\", \".ts\")\n+    ):\n+        return True\n+    chunk = path.read_bytes()[:4096]\n+    return b\"\\x00\" in chunk\n+\n+\n+def _build_folder_listing(path: Path, cwd: Path, limit: int = 200) -> str:\n+    lines = [f\"{path.relative_to(cwd)}/\"]\n+    entries = _iter_visible_entries(path, cwd, limit=limit)\n+    for entry in entries:\n+        rel = entry.relative_to(cwd)\n+        indent = \"  \" * max(len(rel.parts) - len(path.relative_to(cwd).parts) - 1, 0)\n+        if entry.is_dir():\n+            lines.append(f\"{indent}- {entry.name}/\")\n+        else:\n+            meta = _file_metadata(entry)\n+            lines.append(f\"{indent}- {entry.name} ({meta})\")\n+    if len(entries) >= limit:\n+        lines.append(\"- ...\")\n+    return \"\\n\".join(lines)\n+\n+\n+def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:\n+    rg_entries = _rg_files(path, cwd, limit=limit)\n+    if rg_entries is not None:\n+        output: list[Path] = []\n+        seen_dirs: set[Path] = set()\n+        for rel in rg_entries:\n+            full = cwd / rel\n+            for parent in full.parents:\n+                if parent == cwd or parent in seen_dirs or path not in {parent, *parent.parents}:\n+                    continue\n+                seen_dirs.add(parent)\n+                output.append(parent)\n+            output.append(full)\n+        return sorted({p for p in output if p.exists()}, key=lambda p: (not p.is_dir(), str(p)))\n+\n+    output = []\n+    for root, dirs, files in os.walk(path):\n+        dirs[:] = sorted(d for d in dirs if not d.startswith(\".\") and d != \"__pycache__\")\n+        files = sorted(f for f in files if not f.startswith(\".\"))\n+        root_path = Path(root)\n+        for d in dirs:\n+            output.append(root_path / d)\n+            if len(output) >= limit:\n+                return output\n+        for f in files:\n+            output.append(root_path / f)\n+            if len(output) >= limit:\n+                return output\n+    return output\n+\n+\n+def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:\n+    try:\n+        result = subprocess.run(\n+            [\"rg\", \"--files\", str(path.relative_to(cwd))],\n+            cwd=cwd,\n+            capture_output=True,\n+            text=True,\n+        )\n+    except FileNotFoundError:\n+        return None\n+    if result.returncode != 0:\n+        return None\n+    files = [Path(line.strip()) for line in result.stdout.splitlines() if line.strip()]\n+    return files[:limit]\n+\n+\n+def _file_metadata(path: Path) -> str:\n+    if _is_binary_file(path):\n+        return f\"{path.stat().st_size} bytes\"\n+    try:\n+        line_count = path.read_text(encoding=\"utf-8\").count(\"\\n\") + 1\n+    except Exception:\n+        return f\"{path.stat().st_size} bytes\"\n+    return f\"{line_count} lines\"\n+\n+\n+def _code_fence_language(path: Path) -> str:\n+    mapping = {\n+        \".py\": \"python\",\n+        \".js\": \"javascript\",\n+        \".ts\": \"typescript\",\n+        \".tsx\": \"tsx\",\n+        \".jsx\": \"jsx\",\n+        \".json\": \"json\",\n+        \".md\": \"markdown\",\n+        \".sh\": \"bash\",\n+        \".yml\": \"yaml\",\n+        \".yaml\": \"yaml\",\n+        \".toml\": \"toml\",\n+    }\n+    return mapping.get(path.suffix.lower(), \"\")"]},{"file":"cli.py","hunks":["@@ -165,10 +165,10 @@ def load_cli_config() -> Dict[str, Any]:\n             \"cwd\": \".\",  # \".\" is resolved to os.getcwd() at runtime\n             \"timeout\": 60,\n             \"lifetime_seconds\": 300,\n-            \"docker_image\": \"python:3.11\",\n+            \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n             \"docker_forward_env\": [],\n-            \"singularity_image\": \"docker://python:3.11\",\n-            \"modal_image\": \"python:3.11\",\n+            \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n+            \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n             \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n             \"docker_volumes\": [],  # host:container volume mounts for Docker backend\n             \"docker_mount_cwd_to_workspace\": False,  # explicit opt-in only; default off for sandbox isolation","@@ -5320,6 +5320,28 @@ def chat(self, message, images: list = None) -> Optional[str]:\n                 message if isinstance(message, str) else \"\", images\n             )\n \n+        # Expand @ context references (e.g. @file:main.py, @diff, @folder:src/)\n+        if isinstance(message, str) and \"@\" in message:\n+            try:\n+                from agent.context_references import preprocess_context_references\n+                from agent.model_metadata import get_model_context_length\n+                _ctx_len = get_model_context_length(\n+                    self.model, base_url=self.base_url or \"\", api_key=[redacted:secret-assignment] or \"\")\n+                _ctx_result = preprocess_context_references(\n+                    message, cwd=os.getcwd(), context_length=_ctx_len)\n+                if _ctx_result.expanded or _ctx_result.blocked:\n+                    if _ctx_result.references:\n+                        _cprint(\n+                            f\"  {_DIM}[@ context: {len(_ctx_result.references)} ref(s), \"\n+                            f\"{_ctx_result.injected_tokens} tokens]{_RST}\")\n+                    for w in _ctx_result.warnings:\n+                        _cprint(f\"  {_DIM}⚠ {w}{_RST}\")\n+                    if _ctx_result.blocked:\n+                        return \"\\n\".join(_ctx_result.warnings) or \"Context injection refused.\"\n+                    message = _ctx_result.message\n+            except Exception as e:\n+                logging.debug(\"@ context reference expansion failed: %s\", e)\n+\n         # Add user message to history\n         self.conversation_history.append({\"role\": \"user\", \"content\": message})\n ","@@ -5751,12 +5773,14 @@ def run(self):\n         \"\"\"Run the interactive CLI loop with persistent input at bottom.\"\"\"\n         self.show_banner()\n \n-        # One-line Honcho session indicator (TTY-only, not captured by agent)\n+        # One-line Honcho session indicator (TTY-only, not captured by agent).\n+        # Only show when the user explicitly configured Honcho for Hermes\n+        # (not auto-enabled from a stray HONCHO_API_KEY env var).\n         try:\n             from honcho_integration.client import HonchoClientConfig\n             from agent.display import honcho_session_line, write_tty\n             hcfg = HonchoClientConfig.from_global_config()\n-            if hcfg.enabled and hcfg.api_key:\n+            if hcfg.enabled and hcfg.api_key and hcfg.explicitly_configured:\n                 sname = hcfg.resolve_session_name(session_id=self.session_id)\n                 if sname:\n                     write_tty(honcho_session_line(hcfg.workspace_id, sname) + \"\\n\")"]},{"file":"gateway/run.py","hunks":["@@ -2058,7 +2058,31 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                 \"message\": message_text[:500],\n             }\n             await self.hooks.emit(\"agent:start\", hook_ctx)\n-            \n+\n+            # Expand @ context references (@file:, @folder:, @diff, etc.)\n+            if \"@\" in message_text:\n+                try:\n+                    from agent.context_references import preprocess_context_references_async\n+                    from agent.model_metadata import get_model_context_length\n+                    _msg_cwd = os.environ.get(\"MESSAGING_CWD\", os.path.expanduser(\"~\"))\n+                    _msg_ctx_len = get_model_context_length(\n+                        self._model, base_url=self._base_url or \"\")\n+                    _ctx_result = await preprocess_context_references_async(\n+                        message_text, cwd=_msg_cwd,\n+                        context_length=_msg_ctx_len, allowed_root=_msg_cwd)\n+                    if _ctx_result.blocked:\n+                        _adapter = self.adapters.get(source.platform)\n+                        if _adapter:\n+                            await _adapter.send(\n+                                source.chat_id,\n+                                \"\\n\".join(_ctx_result.warnings) or \"Context injection refused.\",\n+                            )\n+                        return\n+                    if _ctx_result.expanded:\n+                        message_text = _ctx_result.message\n+                except Exception as exc:\n+                    logger.debug(\"@ context reference expansion failed: %s\", exc)\n+\n             # Run the agent\n             agent_result = await self._run_agent(\n                 message=message_text,"]},{"file":"hermes_cli/doctor.py","hunks":["@@ -717,13 +717,14 @@ def run_doctor(args):\n     print(color(\"◆ Honcho Memory\", Colors.CYAN, Colors.BOLD))\n \n     try:\n-        from honcho_integration.client import HonchoClientConfig, GLOBAL_CONFIG_PATH\n+        from honcho_integration.client import HonchoClientConfig, resolve_config_path\n         hcfg = HonchoClientConfig.from_global_config()\n+        _honcho_cfg_path = resolve_config_path()\n \n-        if not GLOBAL_CONFIG_PATH.exists():\n+        if not _honcho_cfg_path.exists():\n             check_warn(\"Honcho config not found\", f\"run: hermes honcho setup\")\n         elif not hcfg.enabled:\n-            check_info(\"Honcho disabled (set enabled: true in ~/.honcho/config.json to activate)\")\n+            check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n         elif not hcfg.api_key:\n             [redacted:secret-assignment]\"Honcho API key not set\", \"run: hermes honcho setup\")\n             issues.append(\"No Honcho API key — run 'hermes honcho setup'\")"]},{"file":"honcho_integration/cli.py","hunks":["@@ -10,22 +10,30 @@\n import sys\n from pathlib import Path\n \n-GLOBAL_CONFIG_PATH = Path.home() / \".honcho\" / \"config.json\"\n+from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH\n+\n HOST = \"hermes\"\n \n \n+def _config_path() -> Path:\n+    \"\"\"Return the active Honcho config path (instance-local or global).\"\"\"\n+    return resolve_config_path()\n+\n+\n def _read_config() -> dict:\n-    if GLOBAL_CONFIG_PATH.exists():\n+    path = _config_path()\n+    if path.exists():\n         try:\n-            return json.loads(GLOBAL_CONFIG_PATH.read_text(encoding=\"utf-8\"))\n+            return json.loads(path.read_text(encoding=\"utf-8\"))\n         except Exception:\n             pass\n     return {}\n \n \n-def _write_config(cfg: dict) -> None:\n-    GLOBAL_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)\n-    GLOBAL_CONFIG_PATH.write_text(\n+def _write_config(cfg: dict, path: Path | None = None) -> None:\n+    path = path or _config_path()\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    path.write_text(\n         json.dumps(cfg, indent=2, ensure_ascii=False) + \"\\n\",\n         encoding=\"utf-8\",\n     )","@@ -87,9 +95,14 @@ def cmd_setup(args) -> None:\n     \"\"\"Interactive Honcho setup wizard.\"\"\"\n     cfg = _read_config()\n \n+    active_path = _config_path()\n     print(\"\\nHoncho memory setup\\n\" + \"─\" * 40)\n     print(\"  Honcho gives Hermes persistent cross-session memory.\")\n-    print(\"  Config is shared with other hosts at ~/.honcho/config.json\\n\")\n+    if active_path != GLOBAL_CONFIG_PATH:\n+        print(f\"  Instance config: {active_path}\")\n+    else:\n+        print(\"  Config is shared with other hosts at ~/.honcho/config.json\")\n+    print()\n \n     if not _ensure_sdk_installed():\n         return","@@ -162,10 +175,10 @@ def cmd_setup(args) -> None:\n         hermes_host[\"recallMode\"] = new_recall\n \n     # Session strategy\n-    current_strat = hermes_host.get(\"sessionStrategy\") or cfg.get(\"sessionStrategy\", \"per-session\")\n+    current_strat = hermes_host.get(\"sessionStrategy\") or cfg.get(\"sessionStrategy\", \"per-directory\")\n     print(f\"\\n  Session strategy options:\")\n-    print(\"    per-session   — new Honcho session each run, named by Hermes session ID (default)\")\n-    print(\"    per-directory — one session per working directory\")\n+    print(\"    per-directory — one session per working directory (default)\")\n+    print(\"    per-session   — new Honcho session each run, named by Hermes session ID\")\n     print(\"    per-repo      — one session per git repository (uses repo root name)\")\n     print(\"    global        — single session across all directories\")\n     new_strat = _prompt(\"Session strategy\", default=current_strat)","@@ -176,7 +189,7 @@ def cmd_setup(args) -> None:\n     hermes_host.setdefault(\"saveMessages\", True)\n \n     _write_config(cfg)\n-    print(f\"\\n  Config written to {GLOBAL_CONFIG_PATH}\")\n+    print(f\"\\n  Config written to {active_path}\")\n \n     # Test connection\n     print(\"  Testing connection... \", end=\"\", flush=True)","@@ -223,8 +236,10 @@ def cmd_status(args) -> None:\n \n     cfg = _read_config()\n \n+    active_path = _config_path()\n+\n     if not cfg:\n-        print(\"  No Honcho config found at ~/.honcho/config.json\")\n+        print(f\"  No Honcho config found at {active_path}\")\n         print(\"  Run 'hermes honcho setup' to configure.\\n\")\n         return\n ","@@ -243,7 +258,7 @@ def cmd_status(args) -> None:\n     print(f\"  API key:        {masked}\")\n     print(f\"  Workspace:      {hcfg.workspace_id}\")\n     print(f\"  Host:           {hcfg.host}\")\n-    print(f\"  Config path:    {GLOBAL_CONFIG_PATH}\")\n+    print(f\"  Config path:    {active_path}\")\n     print(f\"  AI peer:        {hcfg.ai_peer}\")\n     print(f\"  User peer:      {hcfg.peer_name or 'not set'}\")\n     print(f\"  Session key:    {hcfg.resolve_session_name()}\")","@@ -275,7 +290,7 @@ def cmd_sessions(args) -> None:\n     if not sessions:\n         print(\"  No session mappings configured.\\n\")\n         print(\"  Add one with: hermes honcho map <session-name>\")\n-        print(\"  Or edit ~/.honcho/config.json directly.\\n\")\n+        print(f\"  Or edit {_config_path()} directly.\\n\")\n         return\n \n     cwd = os.getcwd()","@@ -361,7 +376,7 @@ def cmd_peer(args) -> None:\n \n     if changed:\n         _write_config(cfg)\n-        print(f\"  Saved to {GLOBAL_CONFIG_PATH}\\n\")\n+        print(f\"  Saved to {_config_path()}\\n\")\n \n \n def cmd_mode(args) -> None:","@@ -434,7 +449,7 @@ def cmd_tokens(args) -> None:\n \n     if changed:\n         _write_config(cfg)\n-        print(f\"  Saved to {GLOBAL_CONFIG_PATH}\\n\")\n+        print(f\"  Saved to {_config_path()}\\n\")\n \n \n def cmd_identity(args) -> None:"]},{"file":"honcho_integration/client.py","hunks":["@@ -1,7 +1,9 @@\n \"\"\"Honcho client initialization and configuration.\n \n-Reads the global ~/.honcho/config.json when available, falling back\n-to environment variables.\n+Resolution order for config file:\n+  1. $HERMES_HOME/honcho.json  (instance-local, enables isolated Hermes instances)\n+  2. ~/.honcho/config.json     (global, shared across all Honcho-enabled apps)\n+  3. Environment variables     (HONCHO_API_KEY, HONCHO_ENVIRONMENT)\n \n Resolution order for host-specific settings:\n   1. Explicit host block fields (always win)","@@ -27,6 +29,24 @@\n HOST = \"hermes\"\n \n \n+def _get_hermes_home() -> Path:\n+    \"\"\"Get HERMES_HOME without importing hermes_cli (avoids circular deps).\"\"\"\n+    return Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+\n+\n+def resolve_config_path() -> Path:\n+    \"\"\"Return the active Honcho config path.\n+\n+    Checks $HERMES_HOME/honcho.json first (instance-local), then falls back\n+    to ~/.honcho/config.json (global).  Returns the global path if neither\n+    exists (for first-time setup writes).\n+    \"\"\"\n+    local_path = _get_hermes_home() / \"honcho.json\"\n+    if local_path.exists():\n+        return local_path\n+    return GLOBAL_CONFIG_PATH\n+\n+\n _RECALL_MODE_ALIASES = {\"auto\": \"hybrid\"}\n _VALID_RECALL_MODES = {\"hybrid\", \"context\", \"tools\"}\n ","@@ -107,11 +127,15 @@ def peer_memory_mode(self, peer_name: str) -> str:\n     # \"tools\"   — Honcho tools only, no auto-injected context\n     recall_mode: str = \"hybrid\"\n     # Session resolution\n-    session_strategy: str = \"per-session\"\n+    session_strategy: str = \"per-directory\"\n     session_peer_prefix: bool = False\n     sessions: dict[str, str] = field(default_factory=dict)\n     # Raw global config for anything else consumers need\n     raw: dict[str, Any] = field(default_factory=dict)\n+    # True when Honcho was explicitly configured for this host (hosts.hermes\n+    # block exists or enabled was set explicitly), vs auto-enabled from a\n+    # stray HONCHO_API_KEY env var.\n+    explicitly_configured: bool = False\n \n     @classmethod\n     def from_env(cls, workspace_id: str = \"hermes\") -> HonchoClientConfig:","@@ -132,11 +156,11 @@ def from_global_config(\n         host: str = HOST,\n         config_path: Path | None = None,\n     ) -> HonchoClientConfig:\n-        \"\"\"Create config from ~/.honcho/config.json.\n+        \"\"\"Create config from the resolved Honcho config path.\n \n-        Falls back to environment variables if the file doesn't exist.\n+        Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.\n         \"\"\"\n-        path = config_path or GLOBAL_CONFIG_PATH\n+        path = config_path or resolve_config_path()\n         if not path.exists():\n             logger.debug(\"No global Honcho config at %s, falling back to env\", path)\n             return cls.from_env()","@@ -148,6 +172,9 @@ def from_global_config(\n             return cls.from_env()\n \n         host_block = (raw.get(\"hosts\") or {}).get(host, {})\n+        # A hosts.hermes block or explicit enabled flag means the user\n+        # intentionally configured Honcho for this host.\n+        _explicitly_configured = bool(host_block) or raw.get(\"enabled\") is True\n \n         # Explicit host block fields win, then flat/global, then defaults\n         workspace = (","@@ -209,7 +236,7 @@ def from_global_config(\n         # sessionStrategy / sessionPeerPrefix: host first, root fallback\n         session_strategy = (\n             host_block.get(\"sessionStrategy\")\n-            or raw.get(\"sessionStrategy\", \"per-session\")\n+            or raw.get(\"sessionStrategy\", \"per-directory\")\n         )\n         host_prefix = host_block.get(\"sessionPeerPrefix\")\n         session_peer_prefix = (","@@ -253,6 +280,7 @@ def from_global_config(\n             session_peer_prefix=session_peer_prefix,\n             sessions=raw.get(\"sessions\", {}),\n             raw=raw,\n+            explicitly_configured=_explicitly_configured,\n         )\n \n     @staticmethod","@@ -318,7 +346,7 @@ def resolve_session_name(\n                 return f\"{self.peer_name}-{base}\"\n             return base\n \n-        # per-directory: one Honcho session per working directory\n+        # per-directory: one Honcho session per working directory (default)\n         if self.session_strategy in (\"per-directory\", \"per-session\"):\n             base = Path(cwd).name\n             if self.session_peer_prefix and self.peer_name:"]},{"file":"run_agent.py","hunks":["@@ -901,7 +901,7 @@ def __init__(\n                 pass  # Memory is optional -- don't break agent init\n         \n         # Honcho AI-native memory (cross-session user modeling)\n-        # Reads ~/.honcho/config.json as the single source of truth.\n+        # Reads $HERMES_HOME/honcho.json (instance) or ~/.honcho/config.json (global).\n         self._honcho = None  # HonchoSessionManager | None\n         self._honcho_session_key = honcho_session_key\n         self._honcho_config = None  # HonchoClientConfig | None"]},{"file":"tests/honcho_integration/test_client.py","hunks":["@@ -11,6 +11,7 @@\n     HonchoClientConfig,\n     get_honcho_client,\n     reset_honcho_client,\n+    resolve_config_path,\n     GLOBAL_CONFIG_PATH,\n     HOST,\n )","@@ -25,7 +26,7 @@ def test_default_values(self):\n         assert config.environment == \"production\"\n         assert config.enabled is False\n         assert config.save_messages is True\n-        assert config.session_strategy == \"per-session\"\n+        assert config.session_strategy == \"per-directory\"\n         assert config.recall_mode == \"hybrid\"\n         assert config.session_peer_prefix is False\n         assert config.linked_hosts == []","@@ -157,7 +158,7 @@ def test_session_strategy_default_from_global_config(self, tmp_path):\n         config_file = tmp_path / \"config.json\"\n         config_file.write_text(json.dumps({\"apiKey\": \"key\"}))\n         config = HonchoClientConfig.from_global_config(config_path=config_file)\n-        assert config.session_strategy == \"per-session\"\n+        assert config.session_strategy == \"per-directory\"\n \n     def test_context_tokens_host_block_wins(self, tmp_path):\n         \"\"\"Host block contextTokens should override root.\"\"\"","@@ -330,6 +331,47 @@ def test_uses_host_key_as_fallback(self):\n         assert \"cursor\" in workspaces\n \n \n+class TestResolveConfigPath:\n+    def test_prefers_hermes_home_when_exists(self, tmp_path):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        local_cfg = hermes_home / \"honcho.json\"\n+        local_cfg.write_text('{\"apiKey\": \"local\"}')\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(hermes_home)}):\n+            result = resolve_config_path()\n+        assert result == local_cfg\n+\n+    def test_falls_back_to_global_when_no_local(self, tmp_path):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        # No honcho.json in HERMES_HOME\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(hermes_home)}):\n+            result = resolve_config_path()\n+        assert result == GLOBAL_CONFIG_PATH\n+\n+    def test_falls_back_to_global_without_hermes_home_env(self):\n+        with patch.dict(os.environ, {}, clear=False):\n+            os.environ.pop(\"HERMES_HOME\", None)\n+            result = resolve_config_path()\n+        assert result == GLOBAL_CONFIG_PATH\n+\n+    def test_from_global_config_uses_local_path(self, tmp_path):\n+        hermes_home = tmp_path / \"hermes\"\n+        hermes_home.mkdir()\n+        local_cfg = hermes_home / \"honcho.json\"\n+        local_cfg.write_text(json.dumps({\n+            \"apiKey\": \"local-key\",\n+            \"workspace\": \"local-ws\",\n+        }))\n+\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(hermes_home)}):\n+            config = HonchoClientConfig.from_global_config()\n+        assert config.api_key == \"local-key\"\n+        assert config.workspace_id == \"local-ws\"\n+\n+\n class TestResetHonchoClient:\n     def test_reset_clears_singleton(self):\n         import honcho_integration.client as mod"]},{"file":"tests/test_context_references.py","hunks":["@@ -0,0 +1,221 @@\n+from __future__ import annotations\n+\n+import asyncio\n+import subprocess\n+from pathlib import Path\n+\n+import pytest\n+\n+\n+def _git(cwd: Path, *args: str) -> str:\n+    result = subprocess.run(\n+        [\"git\", *args],\n+        cwd=cwd,\n+        check=True,\n+        capture_output=True,\n+        text=True,\n+    )\n+    return result.stdout.strip()\n+\n+\n[redacted:email]\n+def sample_repo(tmp_path: Path) -> Path:\n+    repo = tmp_path / \"repo\"\n+    repo.mkdir()\n+    _git(repo, \"init\")\n+    _git(repo, \"config\", \"user.name\", \"Hermes Tests\")\n+    _git(repo, \"config\", \"user.email\", \"[redacted:email]\")\n+\n+    (repo / \"src\").mkdir()\n+    (repo / \"src\" / \"main.py\").write_text(\n+        \"def alpha():\\n\"\n+        \"    return 'a'\\n\\n\"\n+        \"def beta():\\n\"\n+        \"    return 'b'\\n\",\n+        encoding=\"utf-8\",\n+    )\n+    (repo / \"src\" / \"helper.py\").write_text(\"VALUE = 1\\n\", encoding=\"utf-8\")\n+    (repo / \"README.md\").write_text(\"# Demo\\n\", encoding=\"utf-8\")\n+    (repo / \"blob.bin\").write_bytes(b\"\\x00\\x01\\x02binary\")\n+\n+    _git(repo, \"add\", \".\")\n+    _git(repo, \"commit\", \"-m\", \"initial\")\n+\n+    (repo / \"src\" / \"main.py\").write_text(\n+        \"def alpha():\\n\"\n+        \"    return 'changed'\\n\\n\"\n+        \"def beta():\\n\"\n+        \"    return 'b'\\n\",\n+        encoding=\"utf-8\",\n+    )\n+    (repo / \"src\" / \"helper.py\").write_text(\"VALUE = 2\\n\", encoding=\"utf-8\")\n+    _git(repo, \"add\", \"src/helper.py\")\n+    return repo\n+\n+\n+def test_parse_typed_references_ignores_emails_and_handles():\n+    from agent.context_references import parse_context_references\n+\n+    message = (\n+        \"email me at [redacted:email] and ping @teammate \"\n+        \"but include @file:src/main.py:1-2 plus @diff and @git:2 \"\n+        \"and @url:https://example.com/docs\"\n+    )\n+\n+    refs = parse_context_references(message)\n+\n+    assert [ref.kind for ref in refs] == [\"file\", \"diff\", \"git\", \"url\"]\n+    assert refs[0].target == \"src/main.py\"\n+    assert refs[0].line_start == 1\n+    assert refs[0].line_end == 2\n+    assert refs[2].target == \"2\"\n+\n+\n+def test_parse_references_strips_trailing_punctuation():\n+    from agent.context_references import parse_context_references\n+\n+    refs = parse_context_references(\n+        \"review @file:README.md, then see (@url:https://example.com/docs).\"\n+    )\n+\n+    assert [ref.kind for ref in refs] == [\"file\", \"url\"]\n+    assert refs[0].target == \"README.md\"\n+    assert refs[1].target == \"https://example.com/docs\"\n+\n+\n+def test_expand_file_range_and_folder_listing(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    result = preprocess_context_references(\n+        \"Review @file:src/main.py:1-2 and @folder:src/\",\n+        cwd=sample_repo,\n+        context_length=100_000,\n+    )\n+\n+    assert result.expanded\n+    assert \"Review and\" in result.message\n+    assert \"Review @file:src/main.py:1-2\" not in result.message\n+    assert \"--- Attached Context ---\" in result.message\n+    assert \"def alpha():\" in result.message\n+    assert \"return 'changed'\" in result.message\n+    assert \"def beta():\" not in result.message\n+    assert \"src/\" in result.message\n+    assert \"main.py\" in result.message\n+    assert \"helper.py\" in result.message\n+    assert result.injected_tokens > 0\n+    assert not result.warnings\n+\n+\n+def test_expand_git_diff_staged_and_log(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    result = preprocess_context_references(\n+        \"Inspect @diff and @staged and @git:1\",\n+        cwd=sample_repo,\n+        context_length=100_000,\n+    )\n+\n+    assert result.expanded\n+    assert \"git diff\" in result.message\n+    assert \"git diff --staged\" in result.message\n+    assert \"git log -1 -p\" in result.message\n+    assert \"initial\" in result.message\n+    assert \"return 'changed'\" in result.message\n+    assert \"VALUE = 2\" in result.message\n+\n+\n+def test_binary_and_missing_files_become_warnings(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    result = preprocess_context_references(\n+        \"Check @file:blob.bin and @file:nope.txt\",\n+        cwd=sample_repo,\n+        context_length=100_000,\n+    )\n+\n+    assert result.expanded\n+    assert len(result.warnings) == 2\n+    assert \"binary\" in result.message.lower()\n+    assert \"not found\" in result.message.lower()\n+\n+\n+def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    soft = preprocess_context_references(\n+        \"Check @file:src/main.py\",\n+        cwd=sample_repo,\n+        context_length=100,\n+    )\n+    assert soft.expanded\n+    assert any(\"25%\" in warning for warning in soft.warnings)\n+\n+    hard = preprocess_context_references(\n+        \"Check @file:src/main.py and @file:README.md\",\n+        cwd=sample_repo,\n+        context_length=20,\n+    )\n+    assert not hard.expanded\n+    assert hard.blocked\n+    assert \"@file:src/main.py\" in hard.message\n+    assert any(\"50%\" in warning for warning in hard.warnings)\n+\n+\n[redacted:email]\n+async def test_async_url_expansion_uses_fetcher(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references_async\n+\n+    async def fake_fetch(url: str) -> str:\n+        assert url == \"https://example.com/spec\"\n+        return \"# Spec\\n\\nImportant details.\"\n+\n+    result = await preprocess_context_references_async(\n+        \"Use @url:https://example.com/spec\",\n+        cwd=sample_repo,\n+        context_length=100_000,\n+        url_fetcher=fake_fetch,\n+    )\n+\n+    assert result.expanded\n+    assert \"Important details.\" in result.message\n+    assert result.injected_tokens > 0\n+\n+\n+def test_sync_url_expansion_uses_async_fetcher(sample_repo: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    async def fake_fetch(url: str) -> str:\n+        await asyncio.sleep(0)\n+        return f\"Content for {url}\"\n+\n+    result = preprocess_context_references(\n+        \"Use @url:https://example.com/spec\",\n+        cwd=sample_repo,\n+        context_length=100_000,\n+        url_fetcher=fake_fetch,\n+    )\n+\n+    assert result.expanded\n+    assert \"Content for https://example.com/spec\" in result.message\n+\n+\n+def test_restricts_paths_to_allowed_root(tmp_path: Path):\n+    from agent.context_references import preprocess_context_references\n+\n+    workspace = tmp_path / \"workspace\"\n+    workspace.mkdir()\n+    (workspace / \"notes.txt\").write_text(\"inside\\n\", encoding=\"utf-8\")\n+    secret = [redacted:secret-assignment] / \"secret.txt\"\n+    secret.write_text(\"outside\\n\", encoding=\"utf-8\")\n+\n+    result = preprocess_context_references(\n+        \"read @file:../secret.txt and @file:notes.txt\",\n+        cwd=workspace,\n+        context_length=100_000,\n+        allowed_root=workspace,\n+    )\n+\n+    assert result.expanded\n+    assert \"```\\noutside\\n```\" not in result.message\n+    assert \"inside\" in result.message\n+    assert any(\"outside the allowed workspace\" in warning for warning in result.warnings)\n"]}]},{"kind":"pr","number":62696,"title":"fix(gateway): @ context reference expansion never ran (AttributeError on nonexistent self._model)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":62783,"title":"fix(gateway): expand @ references in routed profiles","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_context_ref_expansion_runtime.py"]},{"kind":"pr","number":62788,"title":"fix(gateway): resolve @ references for active runtime","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62783","number":62783},{"kind":"pr","value":"#62696","number":62696},{"kind":"pr","value":"#62783","number":62783}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}