"""
This module contains the functions to generate the initial system prompt.
It is used to instruct the LLM about its role, how to use tools, and provide context for the conversation.

When prompting, it is important to provide clear instructions and avoid any ambiguity.
"""

import logging
import platform
import subprocess
import time
from collections.abc import Generator
from contextvars import ContextVar
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from .__version__ import __version__
from .config import config_path, get_config, get_project_config
from .dirs import get_project_git_dir
from .llm.models import get_model, get_recommended_model
from .message import Message, len_tokens
from .tools import ToolFormat, ToolSpec, get_available_tools
from .util import document_prompt_function
from .util.content import extract_content_summary
from .util.context import md_codeblock
from .util.tree import get_tree_output

if TYPE_CHECKING:
    from .util.uri import FilePath

# Agent instruction files — always loaded (layered: user-level + project-level)
# These are the standard filenames used across different AI coding tools.
AGENT_FILES = [
    "AGENTS.md",
    "CLAUDE.md",  # Claude Code compatibility
    "GEMINI.md",  # Gemini compatibility
]
# Keep old name for backwards compatibility with any external code
ALWAYS_LOAD_FILES = AGENT_FILES

# ContextVar tracking which agent instruction files have been loaded into the session.
# Populated by prompt_workspace() at startup; used by the agents_md_inject hook
# (gptme/hooks/agents_md_inject.py) to avoid re-injecting files on CWD changes.
_loaded_agent_files_var: ContextVar[set[str] | None] = ContextVar(
    "loaded_agent_files", default=None
)

# Default files to include in context when no gptme.toml is present or files list is empty
# These are project-specific files that provide useful context
DEFAULT_CONTEXT_FILES = [
    "README*",
    ".cursor/rules/*.mdc",
    "pyproject.toml",
    "package.json",
    "Cargo.toml",
    "Makefile",
    "docker-compose.y*ml",
]

PromptType = Literal["full", "short"]

logger = logging.getLogger(__name__)


ContextMode = Literal["full", "instructions-only", "selective"]


def get_prompt(
    tools: list[ToolSpec],
    tool_format: ToolFormat = "markdown",
    prompt: PromptType | str = "full",
    interactive: bool = True,
    model: str | None = None,
    workspace: Path | None = None,
    agent_path: Path | None = None,
    context_mode: ContextMode | None = None,
    context_include: list[str] | None = None,
) -> list[Message]:
    """
    Get the initial system prompt.

    The prompt is assembled from several layers:

    1. **Core prompt** (always included):

       - Base gptme identity and instructions
       - User identity/preferences (interactive only, from user config ``[user]``;
         skipped in ``--non-interactive`` since no human is present)
       - Tool descriptions (when tools are loaded, controlled by ``--tools``)

    2. **Context** (controlled by ``--context``, independent of ``--non-interactive``):

       - ``files``: static files from project config (gptme.toml ``[prompt] files``)
         and user config (``~/.config/gptme/config.toml`` ``[prompt] files``).
         Both sources are merged and deduplicated.
       - ``cmd``: dynamic output of ``context_cmd`` in gptme.toml (project-level only,
         no user-level equivalent). Changes most often, least cacheable.

    3. **Agent config** (implicit when ``--agent-path`` is provided):

       - Separate agent identity workspace. If ``agent_path == workspace``,
         workspace is skipped to avoid duplication.

    ``--context`` selects which context components to include.
    Without it, all context is included (full mode).

    Implicit behavior (not controlled by ``--context``):

    - **Tool descriptions** are always included when tools are loaded
    - **Agent config** is always loaded when ``--agent-path`` is specified

    Args:
        tools: List of available tools
        tool_format: Format for tool descriptions
        prompt: Prompt type or custom prompt string
        interactive: Whether in interactive mode
        model: Model to use
        workspace: Project workspace directory
        agent_path: Agent identity workspace (if different from project workspace)
        context_mode: Context mode (full, selective; instructions-only deprecated)
        context_include: Components to include in selective mode

    Returns a list of messages: [core_system_prompt, workspace_prompt, ...].
    """
    agent_config = get_project_config(agent_path)
    agent_name = (
        agent_config.agent.name if agent_config and agent_config.agent else None
    )

    # Default context_mode to "full" if not specified
    effective_mode = context_mode or "full"
    include_set = set(context_include or [])

    # "instructions-only" is a deprecated alias for "selective" with no includes
    if effective_mode == "instructions-only":
        effective_mode = "selective"

    # Determine what to include based on context_mode
    # Expand aliases
    if "all" in include_set:
        include_set.update(("files", "cmd"))
    # Legacy: "agent" in context_include is ignored (agent-path is now always loaded)
    include_set.discard("agent")
    include_set.discard("agent-config")
    is_selective = effective_mode == "selective"
    # Tools are always included when they're loaded — no need to opt-in via --context-include
    include_tools = bool(tools)
    include_workspace = effective_mode == "full" or (
        is_selective and "files" in include_set
    )
    # Agent workspace is always loaded when --agent-path is provided
    include_agent_config = bool(agent_path)
    include_context_cmd = effective_mode == "full" or (
        is_selective and "cmd" in include_set
    )

    # Generate core system messages (without workspace context)
    core_msgs: list[Message]
    if is_selective and not include_tools:
        # Selective mode with no tools loaded: base prompt only
        core_msgs = list(prompt_gptme(interactive, model, agent_name))
    elif prompt == "full":
        if include_tools:
            core_msgs = list(
                prompt_full(
                    interactive, tools, tool_format, model, agent_name=agent_name
                )
            )
        else:
            # Full mode without tools
            # Note: skills summary is intentionally excluded here since skills
            # require tool access (e.g., `cat <path>`) to load on-demand
            core_msgs = list(prompt_gptme(interactive, model, agent_name))
            if interactive:
                core_msgs.extend(prompt_user())
            core_msgs.extend(prompt_project())
            core_msgs.extend(prompt_systeminfo())
            core_msgs.extend(prompt_timeinfo())
    elif prompt == "short":
        if include_tools:
            core_msgs = list(
                prompt_short(interactive, tools, tool_format, agent_name=agent_name)
            )
        else:
            core_msgs = list(prompt_gptme(interactive, model, agent_name))
    else:
        core_msgs = [Message("system", prompt)]
        if tools and include_tools:
            core_msgs.extend(
                prompt_tools(tools=tools, tool_format=tool_format, model=model)
            )

    # TODO: generate context_cmd outputs separately and put them last in a "dynamic context" section
    #       with context known not to cache well across conversation starts, so that cache points can be set before and better utilized/changed less frequently.
    #       probably together with chat history since it's also dynamic/live context.
    #       as opposed to static (core/system prompt) and semi-static (workspace/project prompt, like files).

    # Generate workspace messages separately (if included)
    workspace_msgs = (
        list(prompt_workspace(workspace, include_context_cmd=include_context_cmd))
        if include_workspace and workspace and workspace != agent_path
        else []
    )

    # Agent config workspace (separate from project, only with --agent-path)
    agent_config_msgs = (
        list(
            prompt_workspace(
                agent_path,
                title="Agent Config",
                include_path=True,
                include_context_cmd=include_context_cmd,
            )
        )
        if include_agent_config
        else []
    )

    # Combine core messages into one system prompt
    result = []
    if core_msgs:
        core_prompt = _join_messages(core_msgs)
        result.append(core_prompt)

    # Add agent config messages separately (if included)
    if include_agent_config:
        result.extend(agent_config_msgs)

    # Add workspace messages separately (if included)
    if include_workspace:
        result.extend(workspace_msgs)

    # Generate cross-conversation context if enabled
    # Add chat history context
    result.extend(prompt_chat_history())

    # Set hide=True, pinned=True for all messages
    for i, msg in enumerate(result):
        result[i] = msg.replace(hide=True, pinned=True)

    return result


def _join_messages(msgs: list[Message]) -> Message:
    """Combine several system prompt messages into one."""
    role = msgs[0].role if msgs else "system"
    assert all(m.role == role for m in msgs), "All messages must be of same role"
    return Message(
        role,
        "\n\n".join(m.content for m in msgs),
        hide=any(m.hide for m in msgs),
        pinned=any(m.pinned for m in msgs),
    )


def prompt_full(
    interactive: bool,
    tools: list[ToolSpec],
    tool_format: ToolFormat,
    model: str | None,
    agent_name: str | None = None,
) -> Generator[Message, None, None]:
    """Full prompt to start the conversation."""
    yield from prompt_gptme(interactive, model, agent_name)
    yield from prompt_tools(tools=tools, tool_format=tool_format, model=model)
    if interactive:
        yield from prompt_user()
    yield from prompt_project()
    yield from prompt_systeminfo()
    yield from prompt_timeinfo()
    yield from prompt_skills_summary()


def prompt_short(
    interactive: bool,
    tools: list[ToolSpec],
    tool_format: ToolFormat,
    agent_name: str | None = None,
) -> Generator[Message, None, None]:
    """Short prompt to start the conversation."""
    yield from prompt_gptme(interactive, agent_name)
    yield from prompt_tools(examples=False, tools=tools, tool_format=tool_format)
    if interactive:
        yield from prompt_user()
    yield from prompt_project()


def prompt_gptme(
    interactive: bool, model: str | None = None, agent_name: str | None = None
) -> Generator[Message, None, None]:
    """
    Base system prompt for gptme.

    It should:
     - Introduce gptme and its general capabilities and purpose
     - Ensure that it lets the user mostly ask and confirm actions (apply patches, run commands)
     - Provide a brief overview of the capabilities and tools available
     - Not mention tools which may not be loaded (browser, vision)
     - Mention the ability to self-correct and ask clarifying questions
    """
    model_meta = get_model(model) if model else None

    # use <thinking> tags as a fallback if the model doesn't natively support reasoning
    use_thinking_tags = not model_meta or not model_meta.supports_reasoning

    if agent_name:
        agent_blurb = f"{agent_name}, an agent running in gptme, letting you act as a general-purpose AI assistant powered by LLMs"
    else:
        agent_name = f"gptme v{__version__}"
        agent_blurb = f"{agent_name}, a general-purpose AI assistant powered by LLMs"

    default_base_prompt = f"""
You are {agent_blurb}. {
        ("Currently using model: " + model_meta.full) if model_meta else ""
    }
You are designed to help users with programming tasks, such as writing code, debugging, and learning new concepts.
You can run code, execute terminal commands, and access the filesystem on the local machine.
You will help the user with writing code, either from scratch or in existing projects.
{
        "You will think step by step when solving a problem, in `<thinking>` tags."
        if use_thinking_tags
        else ""
    }
Break down complex tasks into smaller, manageable steps.

You have the ability to self-correct. {
        '''If you receive feedback that your output or actions were incorrect, you should:
- acknowledge the mistake
- analyze what went wrong in `<thinking>` tags
- provide a corrected response'''
        if use_thinking_tags
        else ""
    }

You should learn about the context needed to provide the best help,
such as exploring the current working directory and reading the code using terminal tools.

When suggesting code changes, prefer applying patches over examples. Preserve comments, unless they are no longer relevant.
Use the patch tool to edit existing files, or the save tool to overwrite.
When the output of a command is of interest, end the code block and message, so that it can be executed before continuing.

Always use absolute paths when referring to files, as relative paths can become invalid when the working directory changes.
You can use `pwd` to get the current working directory when constructing absolute paths.

Do not use placeholders like `$REPO` unless they have been set.
Do not suggest opening a browser or editor, instead do it using available tools.

Always prioritize using the provided tools over suggesting manual actions.
Be proactive in using tools to gather information or perform tasks.
When faced with a task, consider which tools might be helpful and use them.
Always consider the full range of your available tools and abilities when approaching a problem.

Maintain a professional and efficient communication style. Be concise but thorough in your explanations.

{"Use `<thinking>` tags to think before you answer." if use_thinking_tags else ""}
""".strip()

    interactive_prompt = """
You are in interactive mode. The user is available to provide feedback.
You should show the user how you can use your tools to write code, interact with the terminal, and access the internet.
The user can execute the suggested commands so that you see their output.
If the user aborted or interrupted an operation don't try it again, ask for clarification instead.
If clarification is needed, ask the user.
""".strip()

    non_interactive_prompt = """
You are in non-interactive mode. The user is not available to provide feedback.
All code blocks you suggest will be automatically executed.
Do not provide examples or ask for permission before running commands.
Proceed directly with the most appropriate actions to complete the task.
""".strip()

    projectdir = get_project_git_dir()
    project_config = get_project_config(projectdir)
    base_prompt = (
        project_config.base_prompt
        if project_config and project_config.base_prompt
        else default_base_prompt
    )

    full_prompt = (
        base_prompt
        + "\n\n"
        + (interactive_prompt if interactive else non_interactive_prompt)
    )
    yield Message("system", full_prompt)


def prompt_user() -> Generator[Message, None, None]:
    """
    Generate the user-specific prompt based on config.

    Only included in interactive mode.
    Reads from ``[user]`` section first, falling back to ``[prompt]`` for backward compat.
    """
    config = get_config()
    user_identity = config.user.user
    config_prompt = config.user.prompt

    # Prefer [user] section, fall back to [prompt] for backward compat
    about_user = (
        user_identity.about
        or config_prompt.about_user
        or "You are interacting with a human programmer."
    )
    response_prefs = (
        user_identity.response_preference
        or config_prompt.response_preference
        or "No specific preferences set."
    ).strip()

    user_name = user_identity.name or "User"

    prompt_content = f"""# About {user_name}

{about_user}

## {user_name}'s Response Preferences

{response_prefs}
"""
    yield Message("system", prompt_content)


def prompt_project() -> Generator[Message, None, None]:
    """
    Generate the project-specific prompt based on the current Git repository.

    Project-specific prompt can be set in the :ref:`global-config` or :ref:`project-config` files.
    """
    projectdir = get_project_git_dir()
    if not projectdir:
        return

    project_config = get_project_config(projectdir)
    config_prompt = get_config().user.prompt
    project = projectdir.name
    project_info = project_config and project_config.prompt
    if not project_info:
        # TODO: remove project preferences in global config? use only project config
        project_info = (config_prompt.project or {}).get(project)

    yield Message(
        "system",
        f"## Current Project: {project}\n\n{project_info}",
    )


def prompt_tools(
    tools: list[ToolSpec],
    tool_format: ToolFormat = "markdown",
    examples: bool = True,
    model: str | None = None,
) -> Generator[Message, None, None]:
    """Generate the tools overview prompt.

    For reasoning models using native tool-calling (tool_format="tool"), examples are skipped
    per OpenAI best practices for function calling:
    https://platform.openai.com/docs/guides/function-calling#best-practices-for-defining-functions

    For text-based formats (markdown/xml), examples are kept even for reasoning models,
    since they serve as documentation in the system prompt rather than few-shot examples.
    """
    # Only skip examples for native tool-calling format with reasoning models.
    # For markdown/xml, examples are part of the system prompt text and still useful
    # as documentation. The OpenAI guideline specifically targets native function schemas.
    if examples and model and tool_format == "tool":
        model_meta = get_model(model)
        if model_meta.supports_reasoning:
            logger.debug(
                "Skipping tool examples for reasoning model %s (native tool-calling format)",
                model,
            )
            examples = False

    prompt = "# Tools Overview"
    for tool in tools:
        prompt += tool.get_tool_prompt(examples, tool_format)

    prompt += "\n\n*End of Tools List.*"

    yield Message("system", prompt.strip() + "\n\n")


def prompt_systeminfo() -> Generator[Message, None, None]:
    """Generate the system information prompt."""
    if platform.system() == "Linux":
        try:
            release_info = platform.freedesktop_os_release()
        except Exception:
            release_info = {}
        os_info = release_info.get("NAME", "Linux")
        os_version = (
            release_info.get("VERSION_ID")
            or release_info.get("BUILD_ID")
            or platform.release()
        )
    elif platform.system() == "Windows":
        os_info = "Windows"
        os_version = platform.version()
    elif platform.system() == "Darwin":
        os_info = "macOS"
        os_version = platform.mac_ver()[0]
    else:
        os_info = "unknown"
        os_version = ""

    # Get current working directory

    pwd = Path.cwd()

    prompt = f"""## System Information

**OS:** {os_info} {os_version}
**Working Directory:** {pwd}""".strip()

    yield Message(
        "system",
        prompt,
    )


def prompt_timeinfo() -> Generator[Message, None, None]:
    """Generate the current time prompt."""
    # we only set the date in order for prompt caching and such to work
    prompt = (
        f"## Current Date\n\n**UTC:** {datetime.now(timezone.utc).strftime('%Y-%m-%d')}"
    )
    yield Message("system", prompt)


def find_agent_files_in_tree(
    directory: Path,
    exclude: set[str] | None = None,
) -> list[Path]:
    """Find AGENTS.md/CLAUDE.md/GEMINI.md files from home down to the given directory.

    Walks from home → directory (most general first, most specific last), checking
    each directory for agent instruction files. Returns files whose resolved paths
    are not in the ``exclude`` set.

    Used by both :func:`~gptme.prompts.prompt_workspace` at session start and the
    ``agents_md_inject`` hook mid-session when the CWD changes.
    """
    result: list[Path] = []
    home_dir = Path.home().resolve()
    target = directory.resolve()
    _exclude = exclude or set()

    dirs_to_check: list[Path] = []
    current = target
    while current != current.parent:  # Stop at filesystem root
        dirs_to_check.append(current)
        if current == home_dir:
            break  # Don't go above home directory
        current = current.parent

    # Reverse: most general (home) first, most specific (target) last
    dirs_to_check.reverse()

    for dir_path in dirs_to_check:
        for filename in AGENT_FILES:
            agent_file = dir_path / filename
            if agent_file.exists():
                resolved = str(agent_file.resolve())
                if resolved not in _exclude:
                    result.append(agent_file)

    return result


def prompt_workspace(
    workspace: Path | None = None,
    title="Project Workspace",
    include_path: bool = False,
    include_context_cmd: bool = True,
) -> Generator[Message, None, None]:
    """Generate the workspace context prompt."""
    # TODO: update this prompt if the files change
    # TODO: include `git status -vv`, and keep it up-to-date
    sections = []

    if workspace is None:
        return

    # Add workspace path if requested
    if include_path:
        sections.append(f"**Path:** {workspace.resolve()}")

    project = get_project_config(workspace)

    # Agent instruction files: loaded with tree-walking (most general first, most specific last)
    # These are agent instruction files that should always be included
    # Loading order:
    #   1. User config dir (~/.config/gptme/AGENTS.md) - global defaults
    #   2. Walk from home dir down to workspace, loading any AGENTS.md found
    #      e.g., ~/Programming/AGENTS.md → ~/Programming/gptme/AGENTS.md → workspace
    files: list[Path] = []
    seen_paths: set[str] = set()
    workspace_resolved = workspace.resolve()
    config_dir = Path(config_path).expanduser().resolve().parent

    # Initialize the loaded agent files ContextVar so the agents_md_inject hook
    # (gptme/hooks/agents_md_inject.py) can check which files were already loaded
    # and skip re-injecting them when the CWD changes mid-session.
    loaded_agent_files = _loaded_agent_files_var.get()
    if loaded_agent_files is None:
        loaded_agent_files = set()
        _loaded_agent_files_var.set(loaded_agent_files)

    # 1. Load user-level agent files from ~/.config/gptme/ (global)
    for filename in AGENT_FILES:
        user_file = config_dir / filename
        if user_file.exists():
            resolved = str(user_file.resolve())
            if resolved not in seen_paths:
                files.append(user_file)
                seen_paths.add(resolved)
                loaded_agent_files.add(resolved)
                logger.debug(f"Loaded user-level agent file: {user_file}")

    # 2. Walk from home down to workspace, loading any AGENT_FILES found
    #    Uses find_agent_files_in_tree() — shared with the agents_md_inject hook
    for agent_file in find_agent_files_in_tree(workspace_resolved, exclude=seen_paths):
        resolved = str(agent_file.resolve())
        files.append(agent_file)
        seen_paths.add(resolved)
        loaded_agent_files.add(resolved)
        logger.debug(f"Loaded agent file from tree: {agent_file}")

    # Determine which additional file patterns to use (from config or defaults)
    if project is None or project.files is None:
        # No project config or no files specified in config
        file_patterns = DEFAULT_CONTEXT_FILES
        if project is None:
            logger.debug("No project config found, using default context files")
        else:
            logger.debug(
                "Project config has no files specified, using default context files"
            )
    else:
        # Project config exists with files explicitly set (could be empty list)
        file_patterns = project.files
        if not project.files:
            logger.debug(
                "Project config has files explicitly set to empty, not including any files"
            )

    # Process file patterns (additional files from config/defaults)
    for fileglob in file_patterns:
        # expand user
        fileglob = str(Path(fileglob).expanduser())
        # expand with glob
        if new_files := workspace.glob(fileglob):
            for f in new_files:
                # Validate file is within workspace (prevent path traversal)
                try:
                    f.resolve().relative_to(workspace_resolved)
                    resolved = str(f.resolve())
                    # Skip if already loaded (e.g., from ALWAYS_LOAD_FILES)
                    if resolved not in seen_paths:
                        files.append(f)
                        seen_paths.add(resolved)
                except ValueError:
                    logger.warning(
                        f"Skipping file outside workspace: {f} (from glob '{fileglob}')"
                    )
        else:
            # Only warn for explicitly configured files, not defaults
            if project and project.files is not None:
                logger.warning(
                    f"File glob '{fileglob}' specified in project config does not match any files."
                )

    # Also include user-level files from ~/.config/gptme/config.toml
    # Resolution rules:
    # - Absolute paths: used as-is
    # - ~ expansion supported
    # - Relative paths: resolved relative to the config directory (e.g. ~/.config/gptme)
    try:
        user_files = (
            get_config().user.prompt.files
            if get_config().user and get_config().user.prompt
            else []
        )
    except Exception:
        user_files = []
    if user_files:
        for entry in user_files:
            p = Path(entry).expanduser()
            if not p.is_absolute():
                p = config_dir / entry
            try:
                p = p.resolve()
            except Exception:
                # If resolve fails (e.g., path doesn’t exist yet), keep as-is
                pass
            if p.exists():
                rp = str(p)
                if rp not in seen_paths:
                    files.append(p)
                    seen_paths.add(rp)
            else:
                logger.debug(f"User-configured file not found: {p}")

    # Get tree output if enabled
    if tree_output := get_tree_output(workspace):
        sections.append(f"## Project Structure\n\n{md_codeblock('', tree_output)}\n\n")

    if sections:
        yield Message("system", f"# {title}\n\n" + "\n\n".join(sections))

    # Yield files as a separate message (more stable than computed context)
    valid_files: list[FilePath] = [file for file in files if file.exists()]
    if valid_files:
        file_list = "\n".join(f"- {file}" for file in valid_files)
        yield Message(
            "system",
            f"## Selected files\n\nRead more with `cat`.\n\n{file_list}",
            files=valid_files,
        )

    # Computed context last (changes most often, least cacheable)
    if (
        include_context_cmd
        and project
        and project.context_cmd
        and (
            cmd_output := get_project_context_cmd_output(project.context_cmd, workspace)
        )
    ):
        yield Message("system", "## Computed context\n\n" + cmd_output)


# Maximum characters for context_cmd output to prevent context explosion
# ~100k chars ≈ ~25k tokens, a reasonable safeguard for most context windows
CONTEXT_CMD_MAX_CHARS = 100_000


def _truncate_context_output(
    output: str, max_chars: int = CONTEXT_CMD_MAX_CHARS
) -> str:
    """Truncate context output if it exceeds max_chars, with a clear message."""
    if len(output) <= max_chars:
        return output

    # Keep the first portion of output with truncation notice
    truncated = output[:max_chars]
    # Find a good break point (newline) to avoid cutting mid-line
    # Use max(0, ...) to handle edge case where max_chars < 1000
    last_newline = truncated.rfind("\n", max(0, max_chars - 1000), max_chars)
    if last_newline > max(0, max_chars - 1000):
        truncated = truncated[:last_newline]

    original_chars = len(output)
    kept_chars = len(truncated)
    logger.warning(
        f"Context command output truncated: {original_chars:,} chars -> {kept_chars:,} chars "
        f"(limit: {max_chars:,}). Consider optimizing your context_cmd."
    )

    truncation_notice = (
        f"\n\n... [TRUNCATED: output was {original_chars:,} chars, "
        f"showing first {kept_chars:,} chars to prevent context overflow] ..."
    )
    return truncated + truncation_notice


def get_project_context_cmd_output(cmd: str, workspace: Path) -> str | None:
    from .util import console

    console.log(f"Using project context command: {cmd}")
    try:
        start = time.time()
        result = subprocess.run(
            cmd,
            check=False,
            cwd=workspace,
            shell=True,
            capture_output=True,
            text=True,
            timeout=60,
        )
        duration = time.time() - start
        logger.log(
            logging.WARNING if duration > 10.0 else logging.DEBUG,
            f"Context command took {duration:.2f}s",
        )
        if result.returncode == 0:
            output = _truncate_context_output(result.stdout)
            length = len_tokens(output, "gpt-4")
            if length > 10000:
                logger.warning(
                    f"Context command '{cmd}' output is large: ~{length} tokens, consider optimizing."
                )
            return md_codeblock(cmd, output)
        logger.warning(f"Context command '{cmd}' exited with code {result.returncode}")
        # Include both stdout (partial results) and stderr (error details)
        # so LLM can see what worked and what failed for self-recovery
        # Truncate stdout to prevent context explosion, truncate stderr for safety
        output = _truncate_context_output(result.stdout)
        stderr_stripped = result.stderr.strip()
        if stderr_stripped:
            stderr_preview = stderr_stripped[:500]
            if len(stderr_stripped) > 500:
                stderr_preview += "\n... (truncated)"
            output += f"\n\n## Context Generation Error (exit {result.returncode})\n\n{stderr_preview}"
        return md_codeblock(cmd, output)
    except Exception as e:
        logger.error(f"Error running context command: {e}")
    return None


def use_chat_history_context() -> bool:
    """Check if cross-conversation context is enabled."""
    config = get_config()
    flag: str = config.get_env("GPTME_CHAT_HISTORY", "")  # type: ignore[assignment]
    return flag.lower() in ("1", "true", "yes")


def prompt_chat_history() -> Generator[Message, None, None]:
    """
    Generate cross-conversation context from recent conversations.

    Provides continuity by including key information from recent conversations,
    helping the assistant understand context across conversation boundaries.
    """
    if not use_chat_history_context():
        return

    try:
        from .logmanager import LogManager, list_conversations  # fmt: skip

        # Get recent conversations (we'll filter further)
        recent_conversations = list_conversations(limit=20, include_test=False)

        if not recent_conversations:
            return

        # Filter out very short conversations (likely tests or brief interactions)
        substantial_conversations = [
            conv
            for conv in recent_conversations
            if conv.messages >= 4  # At least 2 exchanges
        ]

        if not substantial_conversations:
            return

        # Take the 3 most recent substantial conversations
        conversations_to_summarize = substantial_conversations[:5]

        context_parts = []

        for _, conv in enumerate(conversations_to_summarize, 1):
            try:
                # Load the conversation
                log_manager = LogManager.load(Path(conv.path).parent, lock=False)
                messages = log_manager.log.messages

                # Extract key messages: first few user messages and last assistant message
                user_messages = [msg for msg in messages if msg.role == "user"]
                assistant_messages = [
                    msg for msg in messages if msg.role == "assistant"
                ]

                if not user_messages:
                    continue

                # Find the best assistant message to use as "last response"
                best_assistant_msg = None
                for msg in reversed(assistant_messages):
                    content = extract_content_summary(msg.content)
                    if content and len(content.split()) >= 10:  # At least 10 words
                        best_assistant_msg = msg
                        break

                # Create a concise summary
                summary_parts = []

                # Add conversation metadata
                summary_parts.append(f"## {conv.name}")
                summary_parts.append(
                    f"Modified: {datetime.fromtimestamp(conv.modified, tz=timezone.utc).strftime('%Y-%m-%d %H:%M')}"
                )

                # Always show first exchange to establish context
                first_user = user_messages[0]
                first_user_content = extract_content_summary(first_user.content)
                if first_user_content:
                    summary_parts.append(f"User: {first_user_content}")

                    # Find first assistant response after first user message
                    first_assistant = None
                    first_user_idx = messages.index(first_user)
                    for msg in messages[first_user_idx + 1 :]:
                        if msg.role == "assistant":
                            first_assistant = msg
                            break

                    if first_assistant:
                        # Use 30 words for first response - brief description of what was attempted
                        first_response = extract_content_summary(
                            first_assistant.content, max_words=30
                        )
                        if first_response:
                            summary_parts.append(f"Assistant: {first_response}")

                # Calculate omitted messages (all except first exchange and last assistant)
                messages_shown = 1  # first user
                if first_assistant:
                    messages_shown += 1  # first assistant
                if best_assistant_msg and best_assistant_msg != first_assistant:
                    messages_shown += 1  # last assistant (if different)

                omitted_count = len(messages) - messages_shown
                if omitted_count > 0:
                    summary_parts.append(f"... ({omitted_count} messages omitted) ...")

                # Add last assistant response if different from first
                if best_assistant_msg and best_assistant_msg != first_assistant:
                    # Use 60 words for last response - capture the outcome/conclusion
                    outcome = extract_content_summary(
                        best_assistant_msg.content, max_words=60
                    )
                    if outcome:
                        summary_parts.append(f"Assistant: {outcome}")

                if len(summary_parts) > 2:  # More than just metadata
                    context_parts.append("\n".join(summary_parts))

            except Exception as e:
                logger.debug(f"Failed to process conversation {conv.name}: {e}")
                continue

        sep = "\n---\n"
        if context_parts:
            context_content = f"""# Recent Conversation Context

The following is a summary of your recent conversations with the user to provide continuity:

```history
{sep.join(part for part in context_parts)}
```

Use this context to understand ongoing projects, preferences, and previous discussions.

*Tip: Use the `chats` tool to search past conversations or read their full history.*
"""
            yield Message("system", context_content)

    except Exception as e:
        logger.debug(f"Failed to generate chat history context: {e}")


document_prompt_function(
    interactive=True,
    model=get_recommended_model("anthropic"),
)(prompt_gptme)
document_prompt_function()(prompt_user)
document_prompt_function()(prompt_project)
document_prompt_function(tools=lambda: get_available_tools(), tool_format="markdown")(
    prompt_tools
)
# document_prompt_function(tool_format="xml")(prompt_tools)
# document_prompt_function(tool_format="tool")(prompt_tools)
document_prompt_function()(prompt_systeminfo)
document_prompt_function()(prompt_chat_history)


def prompt_skills_summary() -> Generator[Message, None, None]:
    """Generate a compact skills summary for the system prompt.

    Lists available skills (lessons with name/description metadata) so the agent
    knows what skills are available without loading full content. Skills can be
    read on-demand using `cat <path>`.

    Note: This should only be included when tools are enabled, since loading
    skills on-demand requires tool access (e.g., the shell tool to run `cat`).
    """
    try:
        from .lessons.index import LessonIndex

        index = LessonIndex()

        if not index.lessons:
            return

        # Filter to skills only (have metadata.name)
        skills = [item for item in index.lessons if item.metadata.name]

        if not skills:
            return

        # Sort by name
        skills = sorted(skills, key=lambda s: s.metadata.name or "")

        lines = ["## Available Skills\n"]
        lines.append("Load on-demand with `cat <path>`:\n")

        for skill in skills:
            name = skill.metadata.name
            desc = skill.metadata.description or ""
            # Truncate description to keep it compact
            if len(desc) > 80:
                desc = desc[:77] + "..."
            path = skill.path
            lines.append(f"- **{name}**: {desc}")
            lines.append(f"  `{path}`")

        lines.append(f"\n*{len(skills)} skills available*")

        yield Message("system", "\n".join(lines))

    except Exception as e:
        logger.warning(f"Failed to generate skills summary: {e}")
        return
