diff --git a/agent/context_file_sources.py b/agent/context_file_sources.py new file mode 100644 index 0000000000..0b2ed4766e --- /dev/null +++ b/agent/context_file_sources.py @@ -0,0 +1,115 @@ +"""Per-file manifest of the context/instruction files behind the ``/context`` "Rules" figure. + +Read-only: enumerates the same candidates ``build_context_files_prompt`` loads (through +``agent.prompt_builder.discover_context_files`` — one discovery walk, so the listing cannot drift from the +prompt) and reports, per file, its size and whether it was loaded, truncated over the context-file cap, +shadowed by a higher-priority context type, empty/unreadable, or suppressed by the install-tree guard. +Nothing here builds a prompt or touches the truncation-warning ContextVar, so it is free of cache impact. + +Inspired by Copilot CLI 1.0.81's per-file ``/instructions`` view. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from agent import prompt_builder as _pb +from agent.model_metadata import estimate_tokens_rough + +# status -> (glyph, note shown after the token count; "" = none) +_STATUS_DISPLAY = { + "loaded": ("✓", ""), + "truncated": ("◐", "truncated — over context_file_max_chars"), + "shadowed": ("○", "not loaded — higher-priority context type wins"), + "empty": ("○", "not loaded — empty file"), + "unreadable": ("✗", "not loaded — could not be read"), + "suppressed": ("○", "not loaded — cwd fell back to the Hermes install tree"), +} + + +def _entry(label: str, path: Path, content: str, status: str) -> Dict[str, Any]: + return { + "label": label, "path": str(path), "chars": len(content), "est_tokens": estimate_tokens_rough(content), + "loaded": status in ("loaded", "truncated"), "status": status, + } + + +def _empty_status(path: Path) -> str: + """A file the builder read as "" is either genuinely empty or unreadable (permissions, timeout).""" + try: + return "unreadable" if path.stat().st_size > 0 else "empty" + except OSError: + return "unreadable" + + +def _loaded_status(rendered_len: int, max_chars: int) -> str: + return "truncated" if rendered_len > max_chars else "loaded" + + +def list_context_file_sources( + cwd: Optional[str] = None, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False, + home_override: "Path | None" = None, skip_soul: bool = False, +) -> List[Dict[str, Any]]: + """One dict per context file Hermes considered, in the builder's priority order. + + Same signature semantics as ``build_context_files_prompt`` (``cwd=None`` → launch dir, install-tree guard + unless *allow_install_tree_fallback*). Keys: ``label``, ``path``, ``chars``, ``est_tokens``, ``loaded`` + and ``status`` ∈ loaded / truncated / shadowed / empty / unreadable / suppressed. + """ + cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve() + max_chars = _pb._get_context_file_max_chars(context_length) + suppressed = _pb._project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback) + sources: List[Dict[str, Any]] = [] + winner: Optional[str] = None + for kind, label, path, content in _pb.discover_context_files(cwd_path): + if not content: + status = _empty_status(path) + elif suppressed: + status = "suppressed" + elif winner in (None, kind): + winner = kind + # The builder caps the rendered ``## label`` section, not the raw file. + status = _loaded_status(len(f"## {label}\n\n{content}"), max_chars) + else: + status = "shadowed" + sources.append(_entry(label, path, content, status)) + + if not skip_soul: + home = Path(home_override) if home_override is not None else _pb.get_hermes_home() + soul_path = home / "SOUL.md" + if _pb._exists_or_denied(soul_path): + content = _pb._read_context_file(soul_path) + status = _loaded_status(len(content), max_chars) if content else _empty_status(soul_path) + sources.append(_entry("SOUL.md", soul_path, content, status)) + return sources + + +def context_file_sources_for_agent(agent: Any) -> List[Dict[str, Any]]: + """The manifest for a live agent, resolved exactly like ``agent.system_prompt._context_files_part`` + (session cwd, install-tree policy per platform, the agent's own profile home).""" + if getattr(agent, "skip_context_files", False): + return [] + from agent.runtime_cwd import resolve_context_cwd + from agent.system_prompt import _agent_home + launch_artifact = getattr(agent, "_context_cwd_is_launch_artifact", False) + cwd = None if launch_artifact else resolve_context_cwd() + ctx_len = getattr(getattr(agent, "context_compressor", None), "context_length", None) + return list_context_file_sources( + cwd=str(cwd) if cwd is not None else None, context_length=ctx_len if isinstance(ctx_len, int) else None, + allow_install_tree_fallback=getattr(agent, "platform", None) in ("cli", "tui"), home_override=_agent_home(agent), + ) + + +def render_context_file_lines(sources: List[Dict[str, Any]]) -> List[str]: + """Plain-text ``Context files`` block for ``/context``; [] when nothing was found.""" + if not sources: + return [] + width = max(len(str(src.get("label") or "")) for src in sources) + lines = ["Context files"] + for src in sources: + glyph, note = _STATUS_DISPLAY.get(str(src.get("status") or ""), ("•", "")) + suffix = f" ({note})" if note else "" + lines.append(f"{glyph} {str(src.get('label') or ''):<{width}} ~{int(src.get('est_tokens') or 0):>9,} tokens{suffix}") + return lines diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 8b62174310..1f9b392a69 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1501,14 +1501,13 @@ def _context_section(content: str, label: str, warn_name: str, path: Path, conte return _truncate_content(body, warn_name, context_length=context_length, read_path=str(path)) -def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str: +def _hermes_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]: """.hermes.md / HERMES.md — nearest match walking up to the git root.""" - hermes_md_path = _find_hermes_md(cwd_path) - content = _read_context_file(hermes_md_path) if hermes_md_path else "" - if not content: - return "" - label = str(hermes_md_path.relative_to(cwd_path)) if hermes_md_path.is_relative_to(cwd_path) else hermes_md_path.name - return _context_section(_strip_yaml_frontmatter(content), label, ".hermes.md", hermes_md_path, context_length) + path = _find_hermes_md(cwd_path) + if path is None: + return [] + label = str(path.relative_to(cwd_path)) if path.is_relative_to(cwd_path) else path.name + return [(label, path, _read_context_file(path))] def _agents_md_directory_chain(cwd_path: Path) -> list[Path]: @@ -1521,59 +1520,120 @@ def _agents_md_directory_chain(cwd_path: Path) -> list[Path]: return [root] + [root.joinpath(*parts[: i + 1]) for i in range(len(parts))] +def _agents_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]: + """AGENTS.md chain from git root down to cwd; per directory the first NON-EMPTY of ``AGENTS.override.md`` / + ``AGENTS.md`` / ``agents.md`` wins (empty or unreadable files are listed but fall through).""" + cwd_resolved = cwd_path.resolve() + found: list[tuple[str, Path, str]] = [] + for directory in _agents_md_directory_chain(cwd_resolved): + for name in ("AGENTS.override.md", "AGENTS.md", "agents.md"): + candidate = directory / name + if not _exists_or_denied(candidate): + continue + content = _read_context_file(candidate) + label = name if directory == cwd_resolved else os.path.relpath(candidate, cwd_resolved) + found.append((label, candidate, content)) + if content: + break # first name match wins per directory + return found + + +def _claude_md_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]: + """CLAUDE.md / claude.md — cwd only, first non-empty wins.""" + found: list[tuple[str, Path, str]] = [] + for name in ("CLAUDE.md", "claude.md"): + candidate = cwd_path / name + if not _exists_or_denied(candidate): + continue + content = _read_context_file(candidate) + found.append((name, candidate, content)) + if content: + break + return found + + +def _cursorrules_candidates(cwd_path: Path) -> list[tuple[str, Path, str]]: + """.cursorrules + .cursor/rules/*.mdc — cwd only; every non-empty file is concatenated.""" + candidates: list[tuple[str, Path]] = [(".cursorrules", cwd_path / ".cursorrules")] + cursor_rules_dir = cwd_path / ".cursor" / "rules" + if cursor_rules_dir.is_dir(): + candidates += [(f".cursor/rules/{f.name}", f) for f in sorted(cursor_rules_dir.glob("*.mdc"))] + return [(label, path, _read_context_file(path)) for label, path in candidates if _exists_or_denied(path)] + + +# Project-context types in priority order: the first type with any non-empty file wins, later types are +# shadowed. Both the prompt build (loaders below) and the /context manifest +# (``agent/context_file_sources.py``) enumerate files through these finders, so the two cannot drift. +_CONTEXT_FILE_CANDIDATES = { + "hermes_md": _hermes_md_candidates, + "agents_md": _agents_md_candidates, + "claude_md": _claude_md_candidates, + "cursorrules": _cursorrules_candidates, +} + + +def discover_context_files(cwd_path: Path) -> list[tuple[str, str, Path, str]]: + """Every project-context file on disk as ``(kind, label, path, content)`` in priority order. + ``content == ""`` means empty or unreadable — such a file is never loaded.""" + return [(kind, label, path, content) + for kind, finder in _CONTEXT_FILE_CANDIDATES.items() for label, path, content in finder(cwd_path)] + + +def _project_context_suppressed(cwd: Optional[str], cwd_path: Path, allow_install_tree_fallback: bool) -> bool: + """A FALLBACK-picked cwd inside the Hermes install tree must not gain system-prompt authority (the desktop + default would load this repo's contributor AGENTS.md). An explicitly configured cwd is honored verbatim — + the Hermes tree is a legitimate workspace when the user deliberately points a session at it — and + CLI-style surfaces pass allow_install_tree_fallback=True because their launch dir IS the user's shell cwd + (developing Hermes in-tree). See #64590.""" + from agent.runtime_cwd import _is_install_tree + return cwd is None and not allow_install_tree_fallback and _is_install_tree(cwd_path) + + +def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str: + """.hermes.md / HERMES.md — nearest match walking up to the git root.""" + for label, path, content in _hermes_md_candidates(cwd_path): + if content: + return _context_section(_strip_yaml_frontmatter(content), label, ".hermes.md", path, context_length) + return "" + + def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str: """AGENTS.md — merged directory chain from git root down to cwd. - Per directory the first of ``AGENTS.override.md`` / ``AGENTS.md`` / ``agents.md`` wins (a gitignored - personal override shadows the committed file); identical content seen again down the chain is skipped. - - Each directory on the chain (see ``_agents_md_directory_chain``) contributes its ``AGENTS.override.md`` - / ``AGENTS.md`` / ``agents.md`` (first name wins per directory) as its own provenance-labelled section. + Each directory on the chain (see ``_agents_md_candidates``) contributes its ``AGENTS.override.md`` / + ``AGENTS.md`` / ``agents.md`` (first name wins per directory) as its own provenance-labelled section. ``AGENTS.override.md`` wins over ``AGENTS.md`` so a developer can keep a personal, typically-gitignored override next to the committed project instructions without editing the tracked file (same convention as earendil-works/pi#7681). Identical content encountered again further down the chain (copied or symlinked files) is deduplicated. With a single match — the common case, and always the case outside a git repo — output is identical to the historical single-file behavior. """ - cwd_resolved = cwd_path.resolve() sections: list[str] = [] seen_content: set = set() - for directory in _agents_md_directory_chain(cwd_resolved): - for name in ("AGENTS.override.md", "AGENTS.md", "agents.md"): - candidate = directory / name - content = _read_context_file(candidate) - if not content: - continue - if content not in seen_content: # else: identical copy along the chain - seen_content.add(content) - label = name if directory == cwd_resolved else os.path.relpath(candidate, cwd_resolved) - sections.append(_context_section(content, label, label, candidate, context_length)) - break # first name match wins per directory + for label, candidate, content in _agents_md_candidates(cwd_path): + if content and content not in seen_content: # else: empty, or an identical copy along the chain + seen_content.add(content) + sections.append(_context_section(content, label, label, candidate, context_length)) if len(sections) <= 1: return sections[0] if sections else "" # Per-file budgets applied above; also cap the merged chain so a deep monorepo can't multiply the budget. return _truncate_content("\n\n".join(sections), "AGENTS.md (directory chain)", context_length=context_length, - read_path=str(cwd_resolved / "AGENTS.md")) + read_path=str(cwd_path.resolve() / "AGENTS.md")) def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str: """CLAUDE.md / claude.md — cwd only.""" - for name in ("CLAUDE.md", "claude.md"): - content = _read_context_file(cwd_path / name) + for name, path, content in _claude_md_candidates(cwd_path): if content: - return _context_section(content, name, "CLAUDE.md", cwd_path / name, context_length) + return _context_section(content, name, "CLAUDE.md", path, context_length) return "" def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str: """.cursorrules + .cursor/rules/*.mdc — cwd only, concatenated.""" - candidates: list[tuple[Path, str]] = [(cwd_path / ".cursorrules", ".cursorrules")] - cursor_rules_dir = cwd_path / ".cursor" / "rules" - if cursor_rules_dir.is_dir(): - candidates += [(f, f".cursor/rules/{f.name}") for f in sorted(cursor_rules_dir.glob("*.mdc"))] cursorrules_content = "".join( f"## {label}\n\n{_scan_context_content(content, label)}\n\n" - for path, label in candidates if (content := _read_context_file(path)) + for label, _path, content in _cursorrules_candidates(cwd_path) if content ) if not cursorrules_content: return "" @@ -1592,14 +1652,7 @@ def build_context_files_prompt( from HERMES_HOME is independent and always included unless *skip_soul* (already the identity slot). """ cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve() - # A FALLBACK-picked cwd inside the Hermes install tree must not gain system-prompt authority (the desktop - # default would load this repo's contributor AGENTS.md). An explicit cwd is honored verbatim. - # An explicitly configured cwd is honored verbatim — the Hermes tree is a legitimate workspace when the - # user deliberately points a session at it — and CLI-style surfaces pass - # allow_install_tree_fallback=True because their launch dir IS the user's shell cwd (developing Hermes - # in-tree). See #64590. - from agent.runtime_cwd import _is_install_tree - if cwd is None and not allow_install_tree_fallback and _is_install_tree(cwd_path): + if _project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback): logger.warning( "skipping project-context discovery: working-directory resolution fell back to the Hermes " "install tree (%s) — set terminal.cwd to your project directory", cwd_path, diff --git a/gateway/slash_commands_status.py b/gateway/slash_commands_status.py index a80df42f9f..5fe68be0a0 100644 --- a/gateway/slash_commands_status.py +++ b/gateway/slash_commands_status.py @@ -488,7 +488,9 @@ class GatewayStatusCommandsMixin: if not (payload.get("categories") or []): return [] details = _quiet_sync(lambda: compute_context_details(agent), {"skills": [], "toolsets": []}) if expanded else None - return render_context_breakdown_lines(payload, details=details, grid=False) + from agent.context_file_sources import context_file_sources_for_agent, render_context_file_lines + file_lines = _quiet_sync(lambda: render_context_file_lines(context_file_sources_for_agent(agent)), []) + return render_context_breakdown_lines(payload, details=details, grid=False) + ([""] + file_lines if file_lines else []) except Exception: return [] diff --git a/hermes_cli/cli_info_mixin.py b/hermes_cli/cli_info_mixin.py index 3f5c253b02..936bbcb6b9 100644 --- a/hermes_cli/cli_info_mixin.py +++ b/hermes_cli/cli_info_mixin.py @@ -654,10 +654,16 @@ class CLIInfoMixin: except Exception: details = {"skills": [], "toolsets": []} + from agent.context_file_sources import context_file_sources_for_agent, render_context_file_lines + try: + file_lines = render_context_file_lines(context_file_sources_for_agent(self.agent)) + except Exception: + file_lines = [] + print() print(f" 🧠 Context Usage — {payload.get('model') or self.model}") print() - for line in render_context_breakdown_lines(payload, details=details, grid=True): + for line in render_context_breakdown_lines(payload, details=details, grid=True) + ([""] + file_lines if file_lines else []): print(f" {line}") print() diff --git a/tests/agent/test_context_file_sources.py b/tests/agent/test_context_file_sources.py index aa2b9e06fe..ec761a803e 100644 --- a/tests/agent/test_context_file_sources.py +++ b/tests/agent/test_context_file_sources.py @@ -1,17 +1,15 @@ -#!/usr/bin/env python3 -"""Per-file context source manifest (`list_context_file_sources`). +"""Per-file context manifest (``agent/context_file_sources.py``) behind the ``/context`` Rules figure. -Read-only mirror of ``build_context_files_prompt`` discovery: one entry per -candidate context/instruction file with size, token estimate, and whether it -was loaded, truncated, or shadowed by a higher-priority context type. - -Inspired by: GitHub Copilot CLI 1.0.81 — "Show each user instruction file -separately in /instructions". +The manifest and ``build_context_files_prompt`` share one discovery walk, so the invariant under test is +parity: a file is reported ``loaded`` iff its content appears in the built prompt. """ +from pathlib import Path + import pytest -from agent.prompt_builder import list_context_file_sources +from agent.context_file_sources import list_context_file_sources, render_context_file_lines +from agent.prompt_builder import build_context_files_prompt @pytest.fixture() @@ -24,89 +22,62 @@ def _by_label(sources): return {s["label"]: s for s in sources} -class TestDiscovery: - def test_agents_md_loaded(self, project): - (project / "AGENTS.md").write_text("# rules\n" * 10) - sources = list_context_file_sources(cwd=str(project)) - entry = _by_label(sources)["AGENTS.md"] - assert entry["loaded"] is True - assert entry["status"] == "loaded" - assert entry["chars"] > 0 - assert entry["est_tokens"] == (entry["chars"] + 3) // 4 +def test_manifest_matches_what_the_prompt_actually_loads(project, tmp_path_factory): + """Every context type present at once; the ladder picks .hermes.md, the chain lists both AGENTS files, + CLAUDE.md/.cursorrules/.cursor/rules/*.mdc are shadowed, an empty file never wins, SOUL.md rides along.""" + (project / ".hermes.md").write_text("hermes rules") + (project / "AGENTS.md").write_text("root agents rules") + sub = project / "pkg" + sub.mkdir() + (sub / "AGENTS.override.md").write_text("") # empty: falls through to AGENTS.md in the same directory + (sub / "AGENTS.md").write_text("pkg agents rules") + (sub / "CLAUDE.md").write_text("claude rules") + (sub / ".cursorrules").write_text("cursor rules") + (sub / ".cursor" / "rules").mkdir(parents=True) + (sub / ".cursor" / "rules" / "a.mdc").write_text("mdc rule a") + home = tmp_path_factory.mktemp("home") + (home / "SOUL.md").write_text("identity text") - def test_empty_project_returns_empty(self, tmp_path, tmp_path_factory): - (tmp_path / ".git").mkdir() - empty_home = tmp_path_factory.mktemp("empty_home") - assert ( - list_context_file_sources(cwd=str(tmp_path), home_override=empty_home) - == [] - ) + sources = list_context_file_sources(cwd=str(sub), home_override=home) + prompt = build_context_files_prompt(cwd=str(sub), home_override=home) - def test_priority_shadowing_hermes_md_over_agents_md(self, project): - (project / ".hermes.md").write_text("hermes rules") - (project / "AGENTS.md").write_text("agents rules") - entries = _by_label(list_context_file_sources(cwd=str(project))) - assert entries[".hermes.md"]["status"] == "loaded" - assert entries["AGENTS.md"]["status"] == "shadowed" - assert entries["AGENTS.md"]["loaded"] is False + statuses = {s["label"]: s["status"] for s in sources} + assert statuses == { + ".hermes.md": "loaded", "../AGENTS.md": "shadowed", "AGENTS.override.md": "empty", "AGENTS.md": "shadowed", + "CLAUDE.md": "shadowed", ".cursorrules": "shadowed", ".cursor/rules/a.mdc": "shadowed", "SOUL.md": "loaded", + } + for src in sources: + body = Path(src["path"]).read_text().strip() if src["chars"] else "" + assert src["loaded"] == (bool(body) and body in prompt), src + assert all(s["est_tokens"] > 0 for s in sources if s["chars"]) - def test_claude_md_shadowed_by_agents_md(self, project): - (project / "AGENTS.md").write_text("agents rules") - (project / "CLAUDE.md").write_text("claude rules") - entries = _by_label(list_context_file_sources(cwd=str(project))) - assert entries["AGENTS.md"]["status"] == "loaded" - assert entries["CLAUDE.md"]["status"] == "shadowed" + # Same walk, other winner: drop .hermes.md and the whole AGENTS chain loads while the rest stays shadowed. + (project / ".hermes.md").unlink() + statuses = {s["label"]: s["status"] for s in list_context_file_sources(cwd=str(sub), home_override=home)} + prompt = build_context_files_prompt(cwd=str(sub), home_override=home) + assert statuses["../AGENTS.md"] == statuses["AGENTS.md"] == "loaded" and "root agents rules" in prompt + assert statuses["CLAUDE.md"] == "shadowed" and "claude rules" not in prompt - def test_cursorrules_listed(self, project): - (project / ".cursorrules").write_text("cursor rules") - rules_dir = project / ".cursor" / "rules" - rules_dir.mkdir(parents=True) - (rules_dir / "a.mdc").write_text("rule a") - entries = _by_label(list_context_file_sources(cwd=str(project))) - assert entries[".cursorrules"]["status"] == "loaded" - assert entries[".cursor/rules/a.mdc"]["status"] == "loaded" - def test_agents_override_wins_per_directory(self, project): - (project / "AGENTS.md").write_text("committed") - (project / "AGENTS.override.md").write_text("personal override") - labels = [s["label"] for s in list_context_file_sources(cwd=str(project))] - assert "AGENTS.override.md" in labels - assert "AGENTS.md" not in labels # first name wins per directory +def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypatch, tmp_path_factory): + import agent.prompt_builder as pb - def test_directory_chain_lists_both_agents_files(self, project): - (project / "AGENTS.md").write_text("root rules") - sub = project / "pkg" - sub.mkdir() - (sub / "AGENTS.md").write_text("pkg rules") - sources = list_context_file_sources(cwd=str(sub)) - agents_entries = [s for s in sources if "AGENTS.md" in s["label"]] - assert len(agents_entries) == 2 - assert all(s["loaded"] for s in agents_entries) + monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 40) + (project / "AGENTS.md").write_text("x" * 100) + home = tmp_path_factory.mktemp("home") + entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"] + assert entry["status"] == "truncated" and entry["loaded"] is True + assert "[...truncated AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home) - def test_truncated_status_when_over_cap(self, project, monkeypatch): - import agent.prompt_builder as pb + # Install-tree guard: a fallback cwd (cwd=None) inside the Hermes tree lists the file but never loads it. + monkeypatch.setattr("agent.runtime_cwd._is_install_tree", lambda _p: True) + monkeypatch.chdir(project) + entry = _by_label(list_context_file_sources(cwd=None, home_override=home))["AGENTS.md"] + assert entry["status"] == "suppressed" and entry["loaded"] is False + assert build_context_files_prompt(cwd=None, skip_soul=True) == "" + assert _by_label(list_context_file_sources(cwd=None, allow_install_tree_fallback=True, home_override=home))[ + "AGENTS.md"]["status"] == "truncated" - monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 10) - (project / "AGENTS.md").write_text("x" * 100) - entries = _by_label(list_context_file_sources(cwd=str(project))) - assert entries["AGENTS.md"]["status"] == "truncated" - assert entries["AGENTS.md"]["loaded"] is True - - def test_soul_md_from_home_override(self, project, tmp_path_factory): - home = tmp_path_factory.mktemp("hermes_home") - (home / "SOUL.md").write_text("identity") - entries = _by_label( - list_context_file_sources(cwd=str(project), home_override=home) - ) - assert entries["SOUL.md"]["status"] == "loaded" - - def test_read_only_no_side_effects(self, project): - (project / "AGENTS.md").write_text("rules") - before = (project / "AGENTS.md").read_text() - list_context_file_sources(cwd=str(project)) - assert (project / "AGENTS.md").read_text() == before - - def test_accepts_path_object(self, project): - (project / "AGENTS.md").write_text("rules") - sources = list_context_file_sources(cwd=project) - assert _by_label(sources)["AGENTS.md"]["loaded"] is True + lines = render_context_file_lines(list_context_file_sources(cwd=None, home_override=home)) + assert lines[0] == "Context files" and "AGENTS.md" in lines[1] and "install tree" in lines[1] + assert render_context_file_lines([]) == [] diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 5459da6f3c..2569f5992c 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -63,7 +63,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. | | `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) | | `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. | -| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space, followed by a per-file **Context files** listing (AGENTS.md chain, .hermes.md, CLAUDE.md, .cursorrules, SOUL.md) with each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, or shadowed by a higher-priority context type. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. | +| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. Both end with a per-file **Context files** listing (.hermes.md, AGENTS.md chain, CLAUDE.md, .cursorrules + .cursor/rules/*.mdc, SOUL.md) showing each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, shadowed by a higher-priority context type, empty/unreadable, or suppressed by the install-tree guard — the answer to "why is my CLAUDE.md ignored?". `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | | `/bg ` | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/user-guide/cli#background-sessions). | | `/btw ` | Ask a quick side question **about the current conversation** without interrupting it. A one-shot auxiliary LLM call answers from a read-only snapshot of the transcript — the live session's history and prompt cache are untouched, and the current turn keeps running. For independent work with a fresh context, use `/bg`. |