package harvey

import (
	"bufio"
	"bytes"
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	anyllm "github.com/mozilla-ai/any-llm-go"
	"golang.org/x/term"
)

// sensitiveCmdEnvVars contains environment variable names that should be
// EXCLUDED from child processes to prevent sensitive data leakage.
//
// Note: filterCommandEnvironment uses a whitelist approach, so variables not
// matching a safe prefix are already blocked. This denylist is defence-in-depth:
// it ensures that if a broad prefix is ever added to safeCmdEnvPrefixes, known
// sensitive names are still stripped first.
var sensitiveCmdEnvVars = []string{
	// LLM provider API keys
	"ANTHROPIC_API_KEY",
	"COHERE_API_KEY",
	"DEEPSEEK_API_KEY",
	"FIREWORKS_API_KEY",
	"GEMINI_API_KEY",
	"GOOGLE_API_KEY",
	"GROQ_API_KEY",
	"HUGGINGFACE_TOKEN",
	"MISTRAL_API_KEY",
	"OPENAI_API_KEY",
	"PERPLEXITY_API_KEY",
	"PUBLICAI_API_KEY",
	"REPLICATE_API_KEY",
	"TOGETHER_API_KEY",
	"XAI_API_KEY",
	// S3-compatible storage credentials (AWS, MinIO, Cloudflare R2)
	"AWS_ACCESS_KEY_ID",
	"AWS_SECRET_ACCESS_KEY",
	"AWS_SESSION_TOKEN",
	"AWS_SECURITY_TOKEN",
	"MINIO_ACCESS_KEY",
	"MINIO_SECRET_KEY",
	// SFTP/SCP credentials
	"SFTP_PASSWORD",
	"SFTP_KEY_PATH",
	// HTTP authentication credentials
	"HTTP_BEARER_TOKEN",
	"HTTP_BASIC_PASSWORD",
}

// safeCmdEnvPrefixes contains environment variable name prefixes that are
// safe to pass to child processes.
var safeCmdEnvPrefixes = []string{
	"PATH",
	"HOME",
	"USER",
	"USERNAME",
	"SHELL",
	"TERM",
	"LANG",
	"LC_",
	"PWD",
	"OLLAMA",
	"HARVEY",
}

/** filterCommandEnvironment returns a filtered copy of the environment for
 * commands executed via /run. Sensitive variables (API keys) are explicitly
 * excluded, and only safe variables are included.
 *
 * Parameters:
 *   env ([]string) — the original environment in "KEY=VALUE" format.
 *
 * Returns:
 *   []string — filtered environment with only safe variables.
 */
func filterCommandEnvironment(env []string) []string {
	sensitiveMap := make(map[string]bool)
	for _, v := range sensitiveCmdEnvVars {
		sensitiveMap[v] = true
	}

	safeMap := make(map[string]bool)
	for _, p := range safeCmdEnvPrefixes {
		safeMap[p] = true
	}

	var result []string
	for _, e := range env {
		idx := strings.IndexByte(e, '=')
		if idx == -1 {
			continue
		}
		varName := e[:idx]

		// Exclude sensitive variables
		if sensitiveMap[varName] {
			continue
		}

		// Include safe variables
		isSafe := false
		for prefix := range safeMap {
			if varName == prefix || strings.HasPrefix(varName, prefix+"_") {
				isSafe = true
				break
			}
		}
		// Also allow HARVEY_* and OLLAMA_* variables
		if strings.HasPrefix(varName, "HARVEY_") || strings.HasPrefix(varName, "OLLAMA_") {
			isSafe = true
		}

		if isSafe {
			result = append(result, e)
		}
	}

	// Always ensure PATH is set
	pathFound := false
	for _, e := range result {
		if strings.HasPrefix(e, "PATH=") {
			pathFound = true
			break
		}
	}
	if !pathFound {
		if path := os.Getenv("PATH"); path != "" {
			result = append(result, "PATH="+path)
		}
	}

	return result
}

/** Command describes a slash command available in the Harvey REPL.
 *
 * Fields:
 *   Usage          (string)   — short usage synopsis shown by /help.
 *   Description    (string)   — one-line description shown by /help.
 *   UserDefined    (bool)     — true for commands generated from compiled skills.
 *   Handler        (func)     — called when the command is dispatched; nil for
 *                               commands handled directly in the REPL (exit, quit).
 *   Subcommands    ([]string) — valid second-token subcommand names; used by
 *                               buildCompleter for tab completion. Empty for
 *                               commands that take no subcommand.
 *   ArgCompletion  (map)      — maps each subcommand name to a function that
 *                               returns candidate strings for its first positional
 *                               argument. Called at tab time; must not make LLM
 *                               or network calls. nil if not applicable.
 *
 * Example:
 *   cmd := &Command{
 *       Usage:       "/greet NAME",
 *       Description: "Print a greeting",
 *       Handler: func(a *Agent, args []string, out io.Writer) error {
 *           fmt.Fprintln(out, "Hello,", args[0])
 *           return nil
 *       },
 *   }
 */
type Command struct {
	Usage       string
	Description string
	UserDefined bool
	// Handler is nil for commands handled directly in the REPL (exit, quit).
	Handler       func(a *Agent, args []string, out io.Writer) error
	Subcommands   []string
	ArgCompletion map[string]func(*Agent) []string
}

/** registerCommands wires the built-in slash commands onto the agent.
 *
 * Example:
 *   agent.registerCommands()
 */
func (a *Agent) registerCommands() {
	a.commands = map[string]*Command{
		"help": {
			Usage:       "/help",
			Description: "List available slash commands",
			Handler:     cmdHelp,
		},
		"status": {
			Usage:       "/status",
			Description: "Show current connection, workspace, and session status",
			Handler:     cmdStatus,
		},
		"clear": {
			Usage:       "/clear",
			Description: "Clear conversation history",
			Handler:     cmdClear,
		},
		"ollama": {
			Usage:        "/ollama <start|stop|status|list|ps|run MODEL|pull MODEL|push MODEL|show MODEL|create NAME|cp SRC DEST|rm MODEL|logs|use MODEL|env>",
			Description:  "Control the local Ollama service and manage models",
			Handler:      cmdOllama,
			Subcommands:  []string{"start", "stop", "status", "list", "ps", "run", "pull", "push", "show", "create", "cp", "rm", "logs", "use", "env", "alias"},
		},
		"llamafile": {
			Usage:       "/llamafile <add [PATH] [NAME]|use NAME|list|start [NAME]|status|drop NAME>",
			Description: "Manage llamafile model backends",
			Handler:     cmdLlamafile,
			Subcommands: []string{"add", "use", "list", "start", "status", "drop"},
			ArgCompletion: map[string]func(*Agent) []string{
				"use":  llamafileNameCandidates,
				"drop": llamafileNameCandidates,
			},
		},
		"kb": {
			Usage:       "/kb <status|search|inject|project|observe|concept> [args...]",
			Description: "Manage and query the workspace knowledge base",
			Handler:     cmdKB,
			Subcommands: []string{"status", "search", "inject", "project", "observe", "concept"},
		},
		"memory": {
			Usage:       "/memory <mine|list|show|flag|forget|status|recall|profile> [args...]",
			Description: "Mine sessions for memories and manage the memory store",
			Handler:     cmdMemory,
			Subcommands: []string{"mine", "list", "show", "flag", "forget", "status", "recall", "profile"},
			ArgCompletion: map[string]func(*Agent) []string{
				"list":   memoryTypeCandidates,
				"show":   memoryIDCandidates,
				"flag":   memoryIDCandidates,
				"forget": memoryIDCandidates,
			},
		},
		"rag": {
			Usage:       "/rag <list|new NAME|use NAME|drop NAME|ingest PATH|status|query TEXT|on|off>",
			Description: "Manage named RAG knowledge stores for context-augmented generation",
			Handler:     cmdRag,
			Subcommands: []string{"list", "new", "use", "drop", "ingest", "status", "query", "on", "off"},
			ArgCompletion: map[string]func(*Agent) []string{
				"use":  ragStoreNameCandidates,
				"drop": ragStoreNameCandidates,
			},
		},
		"files": {
			Usage:       "/files [PATH]",
			Description: "List files in the workspace (or a sub-directory)",
			Handler:     cmdFiles,
		},
		"read": {
			Usage:       "/read FILE [FILE...]",
			Description: "Inject workspace file(s) into conversation context",
			Handler:     cmdRead,
		},
		"attach": {
			Usage:       "/attach FILE",
			Description: "Attach a file to the next turn: image (native or text fallback), PDF (text extraction), or plain text",
			Handler:     cmdAttach,
		},
		"read-pdf": {
			Usage:       "/read-pdf FILE [PAGES]",
			Description: "Extract text from a PDF and inject it into context (requires poppler)",
			Handler:     cmdReadPDF,
		},
		"write": {
			Usage:       "/write PATH",
			Description: "Write the last assistant reply (or its first code block) to a file",
			Handler:     cmdWrite,
		},
		"loop": {
			Usage:       "/loop INTERVAL [--count N] PROMPT|/COMMAND",
			Description: "Repeat a prompt or command on an interval, up to N times (default 10, max 100)",
			Handler:     cmdLoop,
		},
		"run": {
			Usage:       "/run COMMAND [ARGS...]",
			Description: "Run a command in the workspace and inject its output into context",
			Handler:     cmdRun,
		},
		"search": {
			Usage:       "/search PATTERN [PATH]",
			Description: "Search workspace files for a pattern and inject matches into context",
			Handler:     cmdSearch,
		},
		"git": {
			Usage:       "/git <status|diff|log|show|blame> [ARGS...]",
			Description: "Run a read-only git command and inject its output into context",
			Handler:     cmdGit,
			Subcommands: []string{"status", "diff", "log", "show", "blame"},
		},
		"summarize": {
			Usage:       "/summarize",
			Description: "Ask the LLM to summarize history and replace it with the summary",
			Handler:     cmdSummarize,
		},
		"compact": {
			Usage:       "/compact",
			Description: "Alias for /summarize — condense conversation history",
			Handler:     cmdSummarize,
		},
		"context": {
			Usage:       "/context <show|add TEXT...|clear>",
			Description: "Manage pinned context that survives /clear",
			Handler:     cmdContext,
			Subcommands: []string{"show", "add", "clear"},
		},
		"audit": {
			Usage:       "/audit <show [n]|clear|status>",
			Description: "View or clear the audit log of security-relevant events",
			Handler:     cmdAudit,
			Subcommands: []string{"show", "clear", "status"},
		},
		"permissions": {
			Usage:       "/permissions <list [PATH]|set PATH PERMS|reset>",
			Description: "Manage workspace file permissions (read, write, exec, delete)",
			Handler:     cmdPermissions,
			Subcommands: []string{"list", "set", "reset"},
		},
		"security": {
			Usage:       "/security status",
			Description: "Show security settings status (safe mode, permissions, audit)",
			Handler:     cmdSecurity,
			Subcommands: []string{"status"},
		},
		"record": {
			Usage:       "/record <start [FILE]|stop|status>",
			Description: "Record session exchanges to a Markdown file",
			Handler:     cmdRecord,
			Subcommands: []string{"start", "stop", "status"},
		},
		"session": {
			Usage:       "/session <continue FILE|replay FILE [OUTPUT]>",
			Description: "Continue or replay a .spmd/.fountain session recording",
			Handler:     cmdSession,
			Subcommands: []string{"continue", "replay"},
		},
		"rename": {
			Usage:       "/rename NAME",
			Description: "Rename the active session recording file",
			Handler:     cmdRename,
		},
		"read-dir": {
			Usage:       "/read-dir [PATH] [--depth N]",
			Description: "Read all eligible files in a directory into context",
			Handler:     cmdReadDir,
		},
		"file-tree": {
			Usage:       "/file-tree [PATH]",
			Description: "Display a tree listing of the workspace (or a subdirectory)",
			Handler:     cmdFileTree,
		},
		"skill": {
			Usage:       "/skill <list|load NAME|info NAME|status|new|run NAME>",
			Description: "List or load Agent Skills from the skill catalog",
			Handler:     cmdSkill,
			Subcommands: []string{"list", "load", "info", "status", "new", "run"},
			ArgCompletion: map[string]func(*Agent) []string{
				"load": skillNameCandidates,
				"info": skillNameCandidates,
				"run":  skillNameCandidates,
			},
		},
		"skill-set": {
			Usage:       "/skill-set <list|load NAME|info NAME|create NAME|status|unload>",
			Description: "Load or manage named bundles of skills from agents/skill-sets/",
			Handler:     cmdSkillSet,
			Subcommands: []string{"list", "load", "info", "create", "status", "unload"},
		},
		"inspect": {
			Usage:       "/inspect [MODEL]",
			Description: "Show capability details for installed Ollama models; useful for multi-model routing",
			Handler:     cmdInspect,
		},
		"route": {
			Usage:       "/route <add NAME URL [MODEL] | rm NAME | models URL | probe NAME | set NAME tools on|off | list | on | off | status>",
			Description: "Register remote LLM endpoints and dispatch to them with @name in prompts",
			Handler:     cmdRoute,
			Subcommands: []string{"add", "rm", "models", "probe", "set", "list", "on", "off", "status"},
			ArgCompletion: map[string]func(*Agent) []string{
				"rm":    routeNameCandidates,
				"probe": routeNameCandidates,
				"set":   routeNameCandidates,
			},
		},
		"safemode": {
			Usage:       "/safemode <on|off|status|allow CMD|deny CMD|reset>",
			Description: "Enable/disable safe mode or manage the command allowlist",
			Handler:     cmdSafeMode,
			Subcommands: []string{"on", "off", "status", "allow", "deny", "reset"},
		},
		"safe": {
			Usage:       "/safe <on|off|status|allow CMD|deny CMD|reset>",
			Description: "Alias for /safemode",
			Handler:     cmdSafeMode,
			Subcommands: []string{"on", "off", "status", "allow", "deny", "reset"},
		},
		"pipeline": {
			Usage:       "/pipeline <CONFIDENCE%> FILE [FILE ...]",
			Description: "Chain Markdown prompt files through models with confidence gating",
			Handler:     cmdPipeline,
		},
		"plan": {
			Usage:       "/plan <TASK | next | status | show | clear>",
			Description: "Generate a step-by-step plan and execute it with bounded context per step",
			Handler:     cmdPlan,
			Subcommands: []string{"next", "status", "show", "clear"},
		},
		"hint": {
			Usage:       "/hint",
			Description: "Show suggestions for improving results (RAG, memory, KB)",
			Handler:     cmdHint,
		},
		"recall": {
			Usage:       "/recall <query>",
			Description: "Alias for /memory recall — search all knowledge silos",
			Handler: func(a *Agent, args []string, out io.Writer) error {
				return cmdMemory(a, append([]string{"recall"}, args...), out)
			},
		},
		"profile": {
			Usage:       "/profile <list|show|edit|use|rename> [args...]",
			Description: "Alias for /memory profile — manage workspace profile",
			Handler: func(a *Agent, args []string, out io.Writer) error {
				return cmdMemory(a, append([]string{"profile"}, args...), out)
			},
			Subcommands: []string{"list", "show", "edit", "use", "rename"},
			ArgCompletion: map[string]func(*Agent) []string{
				"use": profileTemplateNameCandidates,
			},
		},
		"format": {
			Usage:       "/format FILE [FILE...]",
			Description: "Format source file(s) in-place using the registered formatter for each file's language",
			Handler:     cmdFormat,
		},
		"exit": {
			Usage:       "/exit",
			Description: "Exit Harvey",
			Handler:     nil,
		},
		"quit": {
			Usage:       "/quit",
			Description: "Exit Harvey",
			Handler:     nil,
		},
		"bye": {
			Usage:       "/bye",
			Description: "Exit Harvey",
			Handler:     nil,
		},
	}
}

/** registerSkillCommands registers each compiled skill in a.Skills as a
 * slash command, so users can invoke them as /skill-name [ARGS...] directly.
 * The full argument text after the command name is passed to the script as
 * HARVEY_PROMPT, so positional parameters map naturally to skill variables.
 * Built-in commands are never shadowed. Previously registered skill commands
 * are cleared before re-registering so this method is safe to call repeatedly
 * after compilation or after the skill catalog is refreshed.
 *
 * Parameters:
 *   (receiver) *Agent — agent whose Skills catalog and commands map are updated.
 *
 * Example:
 *   a.Skills = ScanSkills(a.Workspace.Root, a.Config.AgentsDir)
 *   a.registerSkillCommands()
 */
func (a *Agent) registerSkillCommands() {
	// Remove previously registered skill commands.
	for name, cmd := range a.commands {
		if cmd.UserDefined {
			delete(a.commands, name)
		}
	}

	for _, skill := range a.Skills {
		s := skill
		// Never shadow a built-in command.
		if existing, ok := a.commands[s.Name]; ok && !existing.UserDefined {
			continue
		}
		// Only register skills that have been compiled.
		if _, err := os.Stat(CompiledBashPath(s.Path)); err != nil {
			continue
		}

		// Build usage string: /name [VAR1] [VAR2] ...
		usage := "/" + s.Name
		for _, v := range s.Variables {
			usage += " [" + v.Name + "]"
		}

		// Trim description to its first line for the help listing.
		desc := strings.TrimSpace(s.Description)
		if nl := strings.IndexByte(desc, '\n'); nl >= 0 {
			desc = strings.TrimSpace(desc[:nl])
		}

		captured := s
		a.commands[captured.Name] = &Command{
			Usage:       usage,
			Description: desc,
			UserDefined: true,
			Handler: func(ag *Agent, args []string, out io.Writer) error {
				warnIfSkillStale(captured, out)
				prompt := strings.Join(args, " ")
				reader := bufio.NewReaderSize(ag.In, 1)
				_, err := DispatchSkill(context.Background(), ag, captured, prompt, reader, out)
				return err
			},
		}
	}
}

/** dispatch parses a slash command line and runs its handler. Returns
 * (shouldExit, error).
 *
 * Parameters:
 *   input (string)   — the raw slash-command line typed by the user.
 *   out   (io.Writer) — destination for command output.
 *
 * Returns:
 *   bool  — true if the agent should exit after this command.
 *   error — any error returned by the handler.
 *
 * Example:
 *   exit, err := agent.dispatch("/kb status", os.Stdout)
 */
func (a *Agent) dispatch(input string, out io.Writer) (bool, error) {
	parts := strings.Fields(strings.TrimPrefix(input, "/"))
	if len(parts) == 0 {
		return false, nil
	}
	name := strings.ToLower(parts[0])
	args := parts[1:]

	if name == "exit" || name == "quit" || name == "bye" {
		a.stopLlamafileProc()
		return true, nil
	}
	cmd, ok := a.commands[name]
	if !ok {
		fmt.Fprintf(out, yellow("Unknown command: ")+"/%s  (type /help for a list)\n", name)
		return false, nil
	}
	if cmd.Handler != nil {
		return false, cmd.Handler(a, args, out)
	}
	return false, nil
}

// ─── Built-in handlers ───────────────────────────────────────────────────────

func cmdHelp(a *Agent, args []string, out io.Writer) error {
	if len(args) > 0 {
		topic := strings.ToLower(args[0])
		if topic == "topics" || topic == "index" {
			fmt.Fprint(out, HelpTopicsText())
			return nil
		}
		if !PrintHelpTopic(out, topic, "", "", "", "") {
			fmt.Fprintf(out, "  Unknown help topic %q.\n  Type /help topics for the topic index.\n\n", args[0])
		}
		return nil
	}

	var builtins, userDefined []*Command
	for _, cmd := range a.commands {
		if cmd.UserDefined {
			userDefined = append(userDefined, cmd)
		} else {
			builtins = append(builtins, cmd)
		}
	}
	sort.Slice(builtins, func(i, j int) bool { return builtins[i].Usage < builtins[j].Usage })
	sort.Slice(userDefined, func(i, j int) bool { return userDefined[i].Usage < userDefined[j].Usage })

	fmt.Fprintln(out)
	fmt.Fprintf(out, "  %-50s %s\n", "! COMMAND", "Run a shell command, stream output, inject into context")
	fmt.Fprintf(out, "  %-50s %s\n", "@NAME PROMPT", "Send prompt to a registered remote endpoint")
	fmt.Fprintln(out)
	for _, cmd := range builtins {
		fmt.Fprintf(out, "  %-50s %s\n", cmd.Usage, cmd.Description)
	}
	if len(userDefined) > 0 {
		fmt.Fprintln(out)
		fmt.Fprintln(out, "  User-defined commands (compiled skills):")
		for _, cmd := range userDefined {
			fmt.Fprintf(out, "  %-50s %s\n", cmd.Usage, cmd.Description)
		}
	}
	fmt.Fprintln(out)
	fmt.Fprintln(out, "  Type /help TOPIC for a full guide, or /help topics for the topic index.")
	fmt.Fprintln(out)
	return nil
}

func cmdStatus(a *Agent, _ []string, out io.Writer) error {
	if a.Client == nil {
		fmt.Fprintln(out, "Backend:   none")
	} else {
		tag := ""
		if ac, ok := a.Client.(*AnyLLMClient); ok && ac.ProviderName() == "ollama" {
			if a.OllamaStartedByHarvey {
				tag = " [Harvey]"
			} else {
				tag = " [external]"
			}
		}
		fmt.Fprintf(out, "Backend:   %s%s\n", a.Client.Name(), tag)
	}
	if a.Config.Debug {
		fmt.Fprintf(out, "Debug:     on (%s)\n", a.DebugLog.Path())
	}
	fmt.Fprintf(out, "History:   %d messages\n", len(a.History))
	if ac, ok := a.Client.(*AnyLLMClient); ok && ac.ProviderName() == "ollama" && len(a.History) > 0 {
		n, exact := CountTokens(context.Background(), ac.BackendURL(), ac.ModelName(), HistoryText(a.History))
		qualifier := "~"
		if exact {
			qualifier = ""
		}
		limit := a.effectiveContextLimit()
		if limit > 0 {
			pct := n * 100 / limit
			fmt.Fprintf(out, "Tokens:    %s%d / %d (%d%%)\n", qualifier, n, limit, pct)
		} else {
			fmt.Fprintf(out, "Tokens:    %s%d\n", qualifier, n)
		}
	}
	if a.Routes != nil && a.Routes.Enabled && len(a.Routes.Endpoints) > 0 {
		fmt.Fprintf(out, "Routing:   on (%d endpoint(s))\n", len(a.Routes.Endpoints))
	} else {
		fmt.Fprintln(out, "Routing:   off")
	}
	if a.Workspace != nil {
		fmt.Fprintf(out, "Workspace: %s\n", a.Workspace.Root)
	}
	if a.KB != nil {
		fmt.Fprintf(out, "KB:        open (%s)\n", a.KB.Path())
	} else {
		fmt.Fprintln(out, "KB:        not open")
	}
	if a.SessionsDir != "" {
		fmt.Fprintf(out, "Sessions:  %s\n", a.SessionsDir)
	}
	if a.Recorder != nil {
		fmt.Fprintf(out, "Recording: %s\n", a.Recorder.Path())
	} else {
		fmt.Fprintln(out, "Recording: off")
	}
	// Memory store summary
	if a.Config.Memory.Enabled && a.Workspace != nil {
		if store, err := NewMemoryStore(a.Workspace); err == nil {
			defer store.Close()
			if n, err := store.Count(); err == nil {
				sessDir := a.SessionsDir
				if sessDir == "" {
					sessDir = filepath.Join(a.Workspace.Root, harveySubdir, "sessions")
				}
				unminedCount := 0
				if manifest, err := LoadManifest(store.Dir()); err == nil {
					if unmined, err := manifest.UnminedSessions(sessDir); err == nil {
						unminedCount = len(unmined)
					}
				}
				fmt.Fprintf(out, "Memory:    %d active", n)
				if unminedCount > 0 {
					fmt.Fprintf(out, "  (%d session(s) unmined)", unminedCount)
				}
				fmt.Fprintln(out)
			}
			// Active workspace profile
			if profiles, err := store.List(string(MemoryTypeWorkspaceProfile)); err == nil {
				if len(profiles) > 0 {
					fmt.Fprintf(out, "Profile:   %s (%s)\n", profiles[0].Description, profiles[0].ID)
				} else {
					fmt.Fprintln(out, "Profile:   (none — run /profile use to set one)")
				}
			}
		}
	}
	// RAG summary
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		ragState := "off"
		if a.RagOn {
			ragState = "on"
		}
		if a.Rag != nil {
			if n, err := a.Rag.Count(); err == nil {
				fmt.Fprintf(out, "RAG:       %s — store %q, %d chunk(s)\n", ragState, entry.Name, n)
			} else {
				fmt.Fprintf(out, "RAG:       %s — store %q\n", ragState, entry.Name)
			}
		} else {
			fmt.Fprintf(out, "RAG:       %s — store %q (not open)\n", ragState, entry.Name)
		}
	} else {
		fmt.Fprintln(out, "RAG:       not configured")
	}
	if a.Config.SafeMode {
		fmt.Fprintln(out, "Safe mode: on")
	} else {
		fmt.Fprintln(out, "Safe mode: OFF (all commands permitted)")
	}
	return nil
}

func cmdClear(a *Agent, _ []string, out io.Writer) error {
	a.ClearHistory()
	fmt.Fprintln(out, "Conversation history cleared.")
	return nil
}

/** cmdHint prints on-demand suggestions for improving Harvey's results by
 * auditing the three knowledge silos and surfacing actionable advice.
 *
 * Parameters:
 *   a    (*Agent)    — the running agent.
 *   args ([]string)  — unused.
 *   out  (io.Writer) — output destination.
 *
 * Returns:
 *   error — always nil; errors are printed inline.
 *
 * Example:
 *   /hint
 */
func cmdHint(a *Agent, _ []string, out io.Writer) error {
	hints := 0

	// --- Memory store ---
	if a.Config.Memory.Enabled && a.Workspace != nil {
		store, err := NewMemoryStore(a.Workspace)
		if err == nil {
			defer store.Close()
			sessDir := a.SessionsDir
			if sessDir == "" {
				sessDir = filepath.Join(a.Workspace.Root, harveySubdir, "sessions")
			}
			manifest, err := LoadManifest(store.Dir())
			if err == nil {
				unmined, err := manifest.UnminedSessions(sessDir)
				if err == nil && len(unmined) > 0 {
					fmt.Fprintf(out, "  Sessions unmined: %d\n", len(unmined))
					fmt.Fprintln(out, "    Harvey can extract learnings from these sessions.")
					fmt.Fprintln(out, "    Run: /memory mine")
					fmt.Fprintln(out)
					hints++
				}
			}
		}
	}

	// --- RAG store ---
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		if a.Rag != nil {
			if n, err := a.Rag.Count(); err == nil {
				if n == 0 {
					fmt.Fprintf(out, "  RAG store %q is empty.\n", entry.Name)
					fmt.Fprintln(out, "    Ingest reference documents to give Harvey topic-specific knowledge.")
					fmt.Fprintln(out, "    Run: /rag ingest <file>   (PDF, .md, .txt, .go, .ts, ...)")
					fmt.Fprintln(out, "    See: /help rag")
					fmt.Fprintln(out)
					hints++
				} else if !a.RagOn {
					fmt.Fprintf(out, "  RAG is off but store %q has %d chunk(s).\n", entry.Name, n)
					fmt.Fprintln(out, "    Enabling RAG prepends relevant chunks to each prompt.")
					fmt.Fprintln(out, "    Run: /rag on")
					fmt.Fprintln(out)
					hints++
				}
			}
		} else {
			fmt.Fprintf(out, "  RAG store %q is configured but not open.\n", entry.Name)
			fmt.Fprintln(out, "    Run: /rag status")
			fmt.Fprintln(out)
			hints++
		}
	} else {
		fmt.Fprintln(out, "  No RAG store configured.")
		fmt.Fprintln(out, "    Create one to give Harvey access to reference documents.")
		fmt.Fprintln(out, "    Run: /rag new NAME   then   /rag ingest <file>")
		fmt.Fprintln(out, "    See: /help learn")
		fmt.Fprintln(out)
		hints++
	}

	// --- Knowledge base ---
	if a.KB == nil {
		fmt.Fprintln(out, "  Knowledge base not open.")
		fmt.Fprintln(out, "    Use /kb observe to record experiment findings that persist across sessions.")
		fmt.Fprintln(out, "    See: /help kb")
		fmt.Fprintln(out)
		hints++
	}

	if hints == 0 {
		fmt.Fprintln(out, "  Everything looks good — RAG is on with chunks, sessions are mined, KB is open.")
		fmt.Fprintln(out, "  Use /help learn for the full memory overview.")
	}
	return nil
}

/** cmdRoute handles remote endpoint routing configuration for multi-model
 * workflows. Routes allow dispatching prompts to remote LLM endpoints via
 * @mention syntax (e.g., @claude, @mistral) or explicitly via /route.
 *
 * Subcommands:
 *   add NAME URL [MODEL]       — Register a new remote endpoint
 *   rm NAME                    — Remove a registered endpoint
 *   models URL                 — List models available at a provider URL
 *   probe NAME                 — Show capability detail for a registered endpoint
 *   set NAME tools on|off      — Enable or disable tool calling for an endpoint
 *   list                       — List all registered endpoints with capabilities
 *   on                         — Enable routing globally
 *   off                        — Disable routing globally
 *   status                     — Show routing status and endpoints
 *
 * Supported endpoint types:
 *   Local:  ollama://host:port, llamafile://host:port, llamacpp://host:port
 *   Cloud:  anthropic://, deepseek://, gemini://, mistral://, openai://
 *
 * Cloud providers read API keys from environment variables:
 *   ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, GEMINI_API_KEY, MISTRAL_API_KEY, OPENAI_API_KEY
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with route registry.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdRoute(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		return routeStatus(a, out)
	}
	switch strings.ToLower(args[0]) {
	case "add":
		return routeAdd(a, args[1:], out)
	case "rm", "remove":
		if len(args) < 2 {
			names := routeNameCandidates(a)
			if len(names) == 0 {
				fmt.Fprintln(out, "  No routes registered. Use /route add NAME URL to add one.")
				return nil
			}
			chosen, err := SelectFromStrings(names, fmt.Sprintf("Remove which route [1-%d] or Enter to cancel: ", len(names)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return routeRemove(a, args[1], out)
	case "models":
		return routeModels(a, args[1:], out)
	case "probe":
		if len(args) < 2 {
			names := routeNameCandidates(a)
			if len(names) == 0 {
				fmt.Fprintln(out, "  No routes registered.")
				return nil
			}
			chosen, err := SelectFromStrings(names, fmt.Sprintf("Probe which route [1-%d] or Enter to cancel: ", len(names)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return routeProbe(a, args[1], out)
	case "set":
		return routeSet(a, args[1:], out)
	case "list":
		return routeList(a, out)
	case "on":
		return routeOn(a, out)
	case "off":
		return routeOff(a, out)
	case "status":
		return routeStatus(a, out)
	default:
		fmt.Fprintf(out, "  Unknown route subcommand: %q\n", args[0])
		fmt.Fprintln(out, "  Usage: /route <add NAME URL [MODEL] | rm NAME | models URL | probe NAME | set NAME tools on|off | list | on | off | status>")
	}
	return nil
}

func routeAdd(a *Agent, args []string, out io.Writer) error {
	if len(args) < 2 {
		fmt.Fprintln(out, "  Usage: /route add NAME URL [MODEL]")
		fmt.Fprintln(out, "  Local:  ollama://host:port  llamafile://host:port  llamacpp://host:port")
		fmt.Fprintln(out, "  Cloud:  anthropic://  deepseek://  gemini://  mistral://  openai://")
		fmt.Fprintln(out, "  Cloud providers read API keys from environment variables.")
		return nil
	}
	name, rawURL := args[0], args[1]
	model := ""
	if len(args) >= 3 {
		model = args[2]
	}
	kind, err := InferRouteKind(rawURL)
	if err != nil {
		fmt.Fprintf(out, "  %v\n", err)
		return nil
	}
	ep := &RouteEndpoint{Name: name, URL: rawURL, Model: model, Kind: kind}
	a.Routes.Add(ep)
	if saveErr := SaveRouteConfig(a.Workspace, a.Routes); saveErr != nil {
		fmt.Fprintf(out, "  Warning: could not persist route config: %v\n", saveErr)
	}
	fmt.Fprintf(out, "  Added: @%s → %s", name, rawURL)
	if model != "" {
		fmt.Fprintf(out, " (%s)", model)
	}
	fmt.Fprintln(out)
	if strings.HasPrefix(rawURL, "http://") {
		host := strings.TrimPrefix(rawURL, "http://")
		if i := strings.IndexAny(host, "/:"); i >= 0 {
			host = host[:i]
		}
		if !isPrivateHost(host) {
			fmt.Fprintln(out, "  Warning: plain HTTP — prompts and responses travel unencrypted.")
			fmt.Fprintln(out, "           Use https:// if the server supports TLS.")
		}
	}
	return nil
}

// isPrivateHost reports whether host is a loopback, link-local, or RFC-1918
// private address. Accepts bare hostnames too — returns false for those since
// we cannot classify them without a DNS lookup, so the warning is still shown.
func isPrivateHost(host string) bool {
	ip := net.ParseIP(host)
	if ip == nil {
		// Not a numeric IP. Keep the loopback name check for "localhost".
		return host == "localhost"
	}
	return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
}

func routeRemove(a *Agent, name string, out io.Writer) error {
	if a.Routes.Lookup(name) == nil {
		fmt.Fprintf(out, "  Endpoint %q not found. Use /route list to see registered endpoints.\n", name)
		return nil
	}
	a.Routes.Remove(name)
	if saveErr := SaveRouteConfig(a.Workspace, a.Routes); saveErr != nil {
		fmt.Fprintf(out, "  Warning: could not persist route config: %v\n", saveErr)
	}
	fmt.Fprintf(out, "  Removed: @%s\n", name)
	return nil
}

func routeList(a *Agent, out io.Writer) error {
	if len(a.Routes.Endpoints) == 0 {
		fmt.Fprintln(out, "  No endpoints registered. Use /route add NAME URL [MODEL].")
		return nil
	}
	names := make([]string, 0, len(a.Routes.Endpoints))
	for n := range a.Routes.Endpoints {
		names = append(names, n)
	}
	sort.Strings(names)
	fmt.Fprintln(out)
	for _, n := range names {
		ep := a.Routes.Endpoints[n]
		reach := probeRouteEndpoint(ep, a.Config)
		reachStr := green("✓")
		if !reach {
			reachStr = yellow("✗")
		}
		model := ep.Model
		if model == "" {
			model = "(default)"
		}
		toolsSuffix := ""
		if kindSupportsTools(ep.Kind) {
			if ep.Tools {
				toolsSuffix = "  " + green("tools:on")
			} else {
				toolsSuffix = "  tools:off"
			}
		}
		fmt.Fprintf(out, "  %s  @%-16s  %-10s  %s  [%s]%s\n", reachStr, n, ep.Kind, ep.URL, model, toolsSuffix)
	}
	fmt.Fprintln(out)
	return nil
}

func routeOn(a *Agent, out io.Writer) error {
	a.Routes.Enabled = true
	a.Config.RoutingEnabled = true
	if saveErr := SaveRouteConfig(a.Workspace, a.Routes); saveErr != nil {
		fmt.Fprintf(out, "  Warning: could not persist route config: %v\n", saveErr)
	}
	fmt.Fprintln(out, "  Routing on. Prefix your prompt with @name to dispatch to a registered endpoint.")
	return nil
}

func routeOff(a *Agent, out io.Writer) error {
	a.Routes.Enabled = false
	a.Config.RoutingEnabled = false
	if saveErr := SaveRouteConfig(a.Workspace, a.Routes); saveErr != nil {
		fmt.Fprintf(out, "  Warning: could not persist route config: %v\n", saveErr)
	}
	fmt.Fprintln(out, "  Routing off. @mentions will be rejected until you run /route on.")
	return nil
}

func routeStatus(a *Agent, out io.Writer) error {
	enabled := a.Routes != nil && a.Routes.Enabled
	if enabled {
		fmt.Fprintln(out, "  Routing: on")
	} else {
		fmt.Fprintln(out, "  Routing: off")
	}
	count := 0
	if a.Routes != nil {
		count = len(a.Routes.Endpoints)
	}
	if count == 0 {
		fmt.Fprintln(out, "  Endpoints: none registered (use /route add NAME URL [MODEL])")
	} else {
		fmt.Fprintf(out, "  Endpoints: %d registered (use /route list for details)\n", count)
	}
	return nil
}

// ─── /safemode ──────────────────────────────────────────────────────────────

/** cmdSafeMode handles safe mode configuration for restricting which commands
 * can be executed via the ! prefix or /run command. Safe mode provides a
 * command allowlist to prevent execution of potentially dangerous commands.
 *
 * Subcommands:
 *   on       — Enable safe mode (restricts commands to allowlist)
 *   off      — Disable safe mode (all commands allowed)
 *   status  — Show current safe mode status and allowlist
 *   allow CMD — Add a command to the allowlist
 *   deny CMD  — Remove a command from the allowlist
 *   reset    — Reset allowlist to defaults
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with configuration.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdSafeMode(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /safemode <on|off|status|allow CMD|deny CMD|reset>")
		return nil
	}

	switch strings.ToLower(args[0]) {
	case "on":
		return safeModeOn(a, out)
	case "off":
		return safeModeOff(a, out)
	case "status":
		return safeModeStatus(a, out)
	case "allow":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /safemode allow CMD")
			return nil
		}
		return safeModeAllow(a, args[1], out)
	case "deny":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /safemode deny CMD")
			return nil
		}
		return safeModeDeny(a, args[1], out)
	case "reset":
		return safeModeReset(a, out)
	default:
		fmt.Fprintf(out, "Unknown safemode subcommand: %q\n", args[0])
		fmt.Fprintln(out, "Usage: /safemode <on|off|status|allow CMD|deny CMD|reset>")
	}
	return nil
}

func safeModeOn(a *Agent, out io.Writer) error {
	a.Config.SafeMode = true
	if a.Workspace != nil {
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  Warning: could not persist safe mode: %v\n", err)
		}
	}
	fmt.Fprintln(out, "  Safe mode enabled. Only allowed commands can be executed.")
	fmt.Fprintf(out, "  Allowed: %s\n", strings.Join(a.Config.AllowedCommands, ", "))
	return nil
}

func safeModeOff(a *Agent, out io.Writer) error {
	a.Config.SafeMode = false
	if a.Workspace != nil {
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  Warning: could not persist safe mode: %v\n", err)
		}
	}
	fmt.Fprintln(out, "  Safe mode disabled. All commands are allowed.")
	return nil
}

func safeModeStatus(a *Agent, out io.Writer) error {
	if a.Config.SafeMode {
		fmt.Fprintln(out, "  Safe mode: on")
		fmt.Fprintf(out, "  Allowed commands (%d): %s\n", len(a.Config.AllowedCommands), strings.Join(a.Config.AllowedCommands, ", "))
	} else {
		fmt.Fprintln(out, "  Safe mode: off")
		fmt.Fprintln(out, "  All commands are allowed.")
	}
	return nil
}

func safeModeAllow(a *Agent, cmd string, out io.Writer) error {
	oldLen := len(a.Config.AllowedCommands)
	a.Config.AddAllowedCommand(cmd)
	if len(a.Config.AllowedCommands) > oldLen {
		fmt.Fprintf(out, "  Added %q to allowlist.\n", cmd)
	} else {
		fmt.Fprintf(out, "  %q is already in the allowlist.\n", cmd)
	}
	if a.Workspace != nil {
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  Warning: could not persist allowlist: %v\n", err)
		}
	}
	return nil
}

func safeModeDeny(a *Agent, cmd string, out io.Writer) error {
	oldLen := len(a.Config.AllowedCommands)
	a.Config.RemoveAllowedCommand(cmd)
	if len(a.Config.AllowedCommands) < oldLen {
		fmt.Fprintf(out, "  Removed %q from allowlist.\n", cmd)
	} else {
		fmt.Fprintf(out, "  %q is not in the allowlist.\n", cmd)
	}
	if a.Workspace != nil {
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  Warning: could not persist allowlist: %v\n", err)
		}
	}
	return nil
}

func safeModeReset(a *Agent, out io.Writer) error {
	a.Config.ResetAllowedCommands()
	if a.Workspace != nil {
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  Warning: could not persist allowlist: %v\n", err)
		}
	}
	fmt.Fprintln(out, "  Allowlist reset to defaults.")
	fmt.Fprintf(out, "  Allowed commands: %s\n", strings.Join(a.Config.AllowedCommands, ", "))
	return nil
}

// formatCapabilities returns a compact one-line capability summary.
func formatCapabilities(caps anyllm.Capabilities) string {
	f := func(b bool) string {
		if b {
			return "✓"
		}
		return "—"
	}
	return fmt.Sprintf("chat %s  stream %s  tools %s  vision %s  pdf %s  reasoning %s  embed %s",
		f(caps.Completion), f(caps.CompletionStreaming), f(caps.CompletionTools),
		f(caps.CompletionImage), f(caps.CompletionPDF), f(caps.CompletionReasoning),
		f(caps.Embedding))
}

/** routeModels lists available models for a provider URL before a route is
 * registered. Takes a raw URL (e.g. "anthropic://") to infer the provider kind.
 * For Anthropic the v1/models API is called directly; all other providers use
 * the any-llm-go ModelLister interface.
 *
 * Usage: /route models URL
 */
func routeModels(a *Agent, args []string, out io.Writer) error {
	if len(args) < 1 {
		fmt.Fprintln(out, "  Usage: /route models URL")
		fmt.Fprintln(out, "  Example: /route models anthropic://")
		fmt.Fprintln(out, "           /route models ollama://192.168.1.12:11434")
		return nil
	}
	rawURL := args[0]
	kind, err := InferRouteKind(rawURL)
	if err != nil {
		fmt.Fprintf(out, "  %v\n", err)
		return nil
	}

	fmt.Fprintln(out)
	fmt.Fprintf(out, "  Provider: %s\n", kind)

	// Show static capabilities (no network call needed).
	ep := &RouteEndpoint{Kind: kind, URL: rawURL}
	if client, cerr := clientForEndpoint(ep, a.Config); cerr == nil {
		if ac, ok := client.(*AnyLLMClient); ok {
			fmt.Fprintf(out, "  Capabilities: %s\n", formatCapabilities(ac.ProviderCapabilities()))
		}
	}

	fmt.Fprintf(out, "\n  Fetching models from %s ...\n", rawURL)
	ctx := context.Background()
	models, err := listModelsForEndpoint(ctx, kind, rawURL, a.Config)
	if err != nil {
		fmt.Fprintf(out, "  Error: %v\n", err)
		return nil
	}
	if len(models) == 0 {
		fmt.Fprintln(out, "  No models returned.")
		return nil
	}
	sort.Strings(models)
	fmt.Fprintf(out, "  Models (%d):\n", len(models))
	for _, m := range models {
		fmt.Fprintf(out, "    %s\n", m)
	}
	fmt.Fprintln(out)
	return nil
}

/** routeProbe shows detailed capability and status information for a registered
 * endpoint. Unlike /route models, this operates on a named route rather than
 * a raw URL.
 *
 * Usage: /route probe NAME
 */
func routeProbe(a *Agent, name string, out io.Writer) error {
	ep := a.Routes.Lookup(name)
	if ep == nil {
		fmt.Fprintf(out, "  @%s not found. Use /route list to see registered endpoints.\n", name)
		return nil
	}

	reach := probeRouteEndpoint(ep, a.Config)
	reachStr := green("✓ reachable")
	if !reach {
		reachStr = yellow("✗ unreachable")
	}
	model := ep.Model
	if model == "" {
		model = "(default)"
	}

	fmt.Fprintln(out)
	fmt.Fprintf(out, "  Route:    @%s\n", name)
	fmt.Fprintf(out, "  Provider: %s\n", ep.Kind)
	fmt.Fprintf(out, "  URL:      %s\n", ep.URL)
	fmt.Fprintf(out, "  Status:   %s\n", reachStr)
	fmt.Fprintf(out, "  Model:    %s\n", model)

	toolsLine := "off"
	if !kindSupportsTools(ep.Kind) {
		toolsLine = "not supported by provider"
	} else if ep.Tools {
		toolsLine = green("on")
	}
	fmt.Fprintf(out, "  Tools:    %s\n", toolsLine)

	if reach {
		if client, err := clientForEndpoint(ep, a.Config); err == nil {
			if ac, ok := client.(*AnyLLMClient); ok {
				fmt.Fprintf(out, "  Caps:     %s\n", formatCapabilities(ac.ProviderCapabilities()))
			}
		}
	}
	fmt.Fprintf(out, "\n  Use /route models %s to list available models.\n\n", ep.URL)
	return nil
}

/** routeSet updates a per-endpoint setting for a registered route and persists
 * the change. Currently supports one setting:
 *
 *   tools on|off — enable or disable tool calling via ToolExecutor.
 *
 * Usage: /route set NAME tools on|off
 */
func routeSet(a *Agent, args []string, out io.Writer) error {
	if len(args) < 3 {
		fmt.Fprintln(out, "  Usage: /route set NAME tools on|off")
		return nil
	}
	name, key, val := args[0], strings.ToLower(args[1]), strings.ToLower(args[2])
	ep := a.Routes.Lookup(name)
	if ep == nil {
		fmt.Fprintf(out, "  @%s not found. Use /route list to see registered endpoints.\n", name)
		return nil
	}
	switch key {
	case "tools":
		if !kindSupportsTools(ep.Kind) {
			fmt.Fprintf(out, "  @%s provider (%s) does not support tool calling.\n", name, ep.Kind)
			return nil
		}
		switch val {
		case "on", "true", "yes":
			ep.Tools = true
			fmt.Fprintf(out, "  @%s: tools enabled. Dispatches will use Harvey's tool registry.\n", name)
		case "off", "false", "no":
			ep.Tools = false
			fmt.Fprintf(out, "  @%s: tools disabled.\n", name)
		default:
			fmt.Fprintf(out, "  Unknown value %q — use: on | off\n", val)
			return nil
		}
	default:
		fmt.Fprintf(out, "  Unknown setting %q — available settings: tools\n", key)
		return nil
	}
	if err := SaveRouteConfig(a.Workspace, a.Routes); err != nil {
		fmt.Fprintf(out, "  Warning: could not persist route config: %v\n", err)
	}
	return nil
}

// probeRouteEndpoint returns true when ep appears reachable.
// Local providers are probed via HTTP; cloud providers check for a non-empty API key env var.
func probeRouteEndpoint(ep *RouteEndpoint, cfg *Config) bool {
	switch ep.Kind {
	case KindOllama:
		return ProbeOllama(ollamaBaseURL(ep.URL))
	case KindLlamafile:
		return ProbeEncoderfile(LlamafileHealthURL(ep.URL))
	case KindLlamaCpp:
		return ProbeEncoderfile(LlamafileHealthURL(LlamacppAPIURL(ep.URL)))
	case KindAnthropic:
		return os.Getenv("ANTHROPIC_API_KEY") != ""
	case KindDeepSeek:
		return os.Getenv("DEEPSEEK_API_KEY") != ""
	case KindGemini:
		return os.Getenv("GEMINI_API_KEY") != "" || os.Getenv("GOOGLE_API_KEY") != ""
	case KindMistral:
		return os.Getenv("MISTRAL_API_KEY") != ""
	case KindOpenAI:
		return os.Getenv("OPENAI_API_KEY") != ""
	}
	return false
}

func cmdInspect(a *Agent, args []string, out io.Writer) error {
	ac, ok := a.Client.(*AnyLLMClient)
	if !ok || ac.ProviderName() != "ollama" {
		fmt.Fprintln(out, "Inspect requires an Ollama backend. Use /ollama start first.")
		return nil
	}
	oc := NewOllamaClient(ac.BackendURL(), "")
	ctx := context.Background()

	if len(args) > 0 {
		// Detail view for a single named model.
		detail, err := oc.ShowModel(ctx, args[0])
		if err != nil {
			return err
		}
		state := ""
		if detail.Running {
			state = " [loaded]"
		}
		fmt.Fprintf(out, "Model:        %s%s\n", detail.Name, state)
		fmt.Fprintf(out, "Family:       %s\n", detail.Family)
		fmt.Fprintf(out, "Parameters:   %s\n", detail.ParameterSize)
		fmt.Fprintf(out, "Quantization: %s\n", detail.Quantization)
		if detail.ContextLength > 0 {
			fmt.Fprintf(out, "Context:      %d tokens\n", detail.ContextLength)
		}
		if detail.SizeBytes > 0 {
			fmt.Fprintf(out, "Disk size:    %s\n", formatBytes(detail.SizeBytes))
		}
		if detail.RawParameters != "" {
			fmt.Fprintln(out, "\nModelfile parameters:")
			for _, line := range strings.Split(strings.TrimSpace(detail.RawParameters), "\n") {
				fmt.Fprintf(out, "  %s\n", line)
			}
		}
		return nil
	}

	// Summary table for all installed models.
	summaries, err := oc.ModelSummaries(ctx)
	if err != nil {
		return err
	}
	if len(summaries) == 0 {
		fmt.Fprintln(out, "No models installed. Pull one with: /ollama pull <model>")
		return nil
	}

	const colFmt = "%-36s %-10s %-8s %-10s %-10s %6s\n"
	fmt.Fprintf(out, colFmt, "NAME", "FAMILY", "PARAMS", "QUANT", "SIZE", "STATE")
	fmt.Fprintf(out, colFmt,
		strings.Repeat("─", 36),
		strings.Repeat("─", 10),
		strings.Repeat("─", 8),
		strings.Repeat("─", 10),
		strings.Repeat("─", 10),
		strings.Repeat("─", 6),
	)
	for _, s := range summaries {
		state := ""
		if s.Running {
			state = "loaded"
		}
		fmt.Fprintf(out, colFmt,
			truncate(s.Name, 36),
			truncate(s.Family, 10),
			truncate(s.ParameterSize, 8),
			truncate(s.Quantization, 10),
			formatBytes(s.SizeBytes),
			state,
		)
	}
	fmt.Fprintf(out, "\nRun /inspect MODEL for context window size and Modelfile parameters.\n")
	return nil
}

// formatBytes converts a byte count to a human-readable string (GB / MB / KB).
func formatBytes(b int64) string {
	switch {
	case b >= 1<<30:
		return fmt.Sprintf("%.1f GB", float64(b)/float64(1<<30))
	case b >= 1<<20:
		return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
	default:
		return fmt.Sprintf("%.1f KB", float64(b)/float64(1<<10))
	}
}

// truncate shortens s to at most n runes, appending "…" if clipped.
func truncate(s string, n int) string {
	runes := []rune(s)
	if len(runes) <= n {
		return s
	}
	return string(runes[:n-1]) + "…"
}

/** cmdOllama handles Ollama server and model management commands.
 *
 * Subcommands:
 *   start [debug]  — Launch ollama serve; optional debug sets OLLAMA_DEBUG=1
 *   stop           — Print instructions to stop Ollama via system service manager
 *   status         — Check if Ollama server is running
 *   list           — List all installed models with metadata
 *   ps             — Show running models (via ollama ps)
 *   run            — Start a model in interactive mode (detached from Harvey)
 *   pull           — Download a model from Ollama registry
 *   push           — Upload a model to Ollama registry
 *   show           — Display detailed model information
 *   create         — Create a model from a Modelfile
 *   cp             — Copy a model to a new name
 *   rm             — Remove a model from the local store
 *   probe          — Test model capabilities (tools, embeddings)
 *   logs           — Show Ollama server logs
 *   use            — Switch to a different model
 *   env            — Display active Ollama environment variables
 *   alias          — Manage short model aliases (/ollama alias NAME FULLNAME)
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with configuration.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdOllama(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /ollama <start [debug]|stop|status|list|ps|run MODEL|pull MODEL|push MODEL|show MODEL|create NAME|cp SRC DEST|rm MODEL|probe [MODEL|--all]|logs|use MODEL|env|alias [NAME FULLNAME]>")
		return nil
	}
	switch strings.ToLower(args[0]) {
	case "start":
		debug := len(args) > 1 && strings.ToLower(args[1]) == "debug"
		if ProbeOllama(a.Config.OllamaURL) {
			if debug || os.Getenv("OLLAMA_DEBUG") != "" {
				fmt.Fprintln(out, "Ollama is already running externally — it will not have debug logging.")
				fmt.Fprintln(out, "  To start in debug mode, quit Ollama first:")
				fmt.Fprintln(out, "    macOS:  right-click the Ollama menu bar icon → Quit Ollama")
				fmt.Fprintln(out, "    Linux:  systemctl stop ollama")
				fmt.Fprintln(out, "  Then re-run: /ollama start debug")
			} else {
				fmt.Fprintln(out, "Ollama is already running.")
			}
			return nil
		}
		if debug {
			os.Setenv("OLLAMA_DEBUG", "1")
		}
		PrintOllamaEnv(out)
		ollamaLogPath := a.DebugLog.OllamaLogPath()
		if debug {
			fmt.Fprintf(out, "Starting Ollama (debug mode)...")
			if ollamaLogPath != "" {
				fmt.Fprintf(out, " log: %s", ollamaLogPath)
			}
			fmt.Fprintln(out)
		} else {
			fmt.Fprintln(out, "Starting Ollama...")
		}
		if err := StartOllamaService(ollamaLogPath); err != nil {
			return err
		}
		a.OllamaStartedByHarvey = true
		a.DebugLog.LogOllamaStart(debug, ollamaLogPath)
		fmt.Fprintln(out, "Ollama is running.")
	case "stop":
		fmt.Fprintln(out, "Use your system's service manager to stop Ollama (e.g. systemctl stop ollama).")
	case "status":
		if ProbeOllama(a.Config.OllamaURL) {
			fmt.Fprintln(out, "Ollama is running.")
		} else {
			fmt.Fprintln(out, "Ollama is not running.")
		}
	case "list":
		if !ProbeOllama(a.Config.OllamaURL) {
			fmt.Fprintln(out, "Ollama is not running.")
			return nil
		}
		summaries, err := NewOllamaClient(a.Config.OllamaURL, "").ModelSummaries(context.Background())
		if err != nil {
			return err
		}
		if len(summaries) == 0 {
			fmt.Fprintln(out, "No models installed. Run: /ollama pull <model>")
			return nil
		}
		ollamaListTable(a, summaries, out)
	case "ps":
		cmd := exec.Command("ollama", "ps")
		cmd.Stdout = out
		cmd.Stderr = out
		return cmd.Run()
	case "pull":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama pull MODEL")
			return nil
		}
		model := args[1]
		cmd := exec.Command("ollama", "pull", model)
		cmd.Stdout = out
		cmd.Stderr = out
		if err := cmd.Run(); err != nil {
			return err
		}
		// Fast-probe the newly pulled model and cache the result.
		if a.ModelCache != nil {
			ctx := context.Background()
			cap, err := FastProbeModel(ctx, a.Config.OllamaURL, model)
			if err == nil {
				_ = a.ModelCache.Set(cap)
				fmt.Fprintf(out, "  tools: %s   embed: %s   tagged: %s   ctx: %s   [fast probe]\n",
					cap.SupportsTools, cap.SupportsEmbed, cap.SupportsTaggedBlocks, ollamaFormatCtx(cap.ContextLength))
			}
		}
	case "show":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama show MODEL")
			return nil
		}
		cmd := exec.Command("ollama", "show", args[1])
		cmd.Stdout = out
		cmd.Stderr = out
		return cmd.Run()
	case "create":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama create NAME [-f MODELFILE]")
			return nil
		}
		cmd := exec.Command("ollama", append([]string{"create"}, args[1:]...)...)
		cmd.Stdout = out
		cmd.Stderr = out
		return cmd.Run()
	case "run":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama run MODEL [PROMPT]")
			return nil
		}
		cmd := exec.Command("ollama", append([]string{"run"}, args[1:]...)...)
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		return cmd.Run()
	case "push":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama push MODEL")
			return nil
		}
		cmd := exec.Command("ollama", "push", args[1])
		cmd.Stdout = out
		cmd.Stderr = out
		return cmd.Run()
	case "cp":
		if len(args) < 3 {
			fmt.Fprintln(out, "Usage: /ollama cp SOURCE DEST")
			return nil
		}
		cmd := exec.Command("ollama", "cp", args[1], args[2])
		cmd.Stdout = out
		cmd.Stderr = out
		return cmd.Run()
	case "rm":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama rm MODEL [MODEL...]")
			return nil
		}
		models := args[1:]
		cmd := exec.Command("ollama", append([]string{"rm"}, models...)...)
		cmd.Stdout = out
		cmd.Stderr = out
		if err := cmd.Run(); err != nil {
			return err
		}
		// Remove each successfully deleted model from the cache.
		if a.ModelCache != nil {
			for _, m := range models {
				_ = a.ModelCache.Delete(m)
			}
		}
	case "probe":
		return ollamaProbe(a, args[1:], out)
	case "probe-all":
		return ollamaProbe(a, []string{"--all"}, out)
	case "logs":
		// Try the native ollama logs subcommand first; fall back to journalctl.
		cmd := exec.Command("ollama", "logs")
		cmd.Stdout = out
		cmd.Stderr = out
		if err := cmd.Run(); err != nil {
			jcmd := exec.Command("journalctl", "-u", "ollama", "--no-pager", "-n", "100")
			jcmd.Stdout = out
			jcmd.Stderr = out
			return jcmd.Run()
		}
		return nil
	case "env":
		fmt.Fprintln(out, "Ollama environment (Harvey process):")
		PrintOllamaEnv(out)
	case "use":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama use MODEL")
			return nil
		}
		return modelSwitch(a, args[1], out)
	case "alias":
		subargs := args[1:]
		// If the next token is not a known subcommand keyword, treat as implicit "set".
		if len(subargs) > 0 && subargs[0] != "list" && subargs[0] != "set" &&
			subargs[0] != "remove" && subargs[0] != "rm" && subargs[0] != "delete" {
			subargs = append([]string{"set"}, subargs...)
		}
		return cmdModelAlias(a, subargs, out)
	default:
		fmt.Fprintf(out, "Unknown ollama subcommand: %s\n", args[0])
	}
	return nil
}

// ollamaModelTable prints the model capability table.
// When numbered is true each row is prefixed with [N] for interactive selection;
// when false the active model is marked with * instead.
func ollamaModelTable(a *Agent, summaries []ModelSummary, out io.Writer, numbered bool) {
	const nameW = 36
	fmt.Fprintf(out, "%-*s  %7s  %-8s  %6s  %5s  %5s  %6s\n",
		nameW, "NAME", "SIZE", "FAMILY", "CTX", "TOOLS", "EMBED", "TAGGED")
	fmt.Fprintf(out, "%s  %s  %s  %s  %s  %s  %s\n",
		strings.Repeat("─", nameW),
		strings.Repeat("─", 7),
		strings.Repeat("─", 8),
		strings.Repeat("─", 6),
		strings.Repeat("─", 5),
		strings.Repeat("─", 5),
		strings.Repeat("─", 6),
	)

	activeName := ""
	if !numbered {
		if ac, ok := a.Client.(*AnyLLMClient); ok {
			activeName = ac.ModelName()
		}
	}

	unknownCount := 0
	for i, s := range summaries {
		var cap *ModelCapability
		if a.ModelCache != nil {
			cap, _ = a.ModelCache.Get(s.Name)
		}

		tools := CapUnknown
		embed := CapUnknown
		tagged := CapUnknown
		ctx := 0
		if cap != nil {
			tools = cap.SupportsTools
			embed = cap.SupportsEmbed
			tagged = cap.SupportsTaggedBlocks
			ctx = cap.ContextLength
		} else {
			unknownCount++
		}

		var prefix string
		if numbered {
			prefix = fmt.Sprintf("[%2d] ", i+1)
		} else if s.Name == activeName {
			prefix = "* "
		} else {
			prefix = "  "
		}
		displayName := prefix + ollamaTruncateName(s.Name, nameW-len(prefix))

		fmt.Fprintf(out, "%-*s  %7s  %-8s  %6s  %5s  %5s  %6s\n",
			nameW, displayName,
			ollamaFormatBytes(s.SizeBytes),
			ollamaTruncateName(s.Family, 8),
			ollamaFormatCtx(ctx),
			tools.String(),
			embed.String(),
			tagged.String(),
		)
	}

	if unknownCount > 0 {
		fmt.Fprintf(out, "\n  %d model(s) not yet probed — run /ollama probe to fill in capabilities.\n", unknownCount)
	}
}

// ollamaListTable prints the capability table for /ollama list, grouped by tier:
//
//	Tier 1 — full (tools + tagged blocks)
//	Tier 2 — tools only
//	Tier 3 — embed only
//	Tier 4 — base / unprobed
func ollamaListTable(a *Agent, summaries []ModelSummary, out io.Writer) {
	type tier struct {
		label  string
		models []ModelSummary
	}
	tiers := []tier{
		{"Full capability (tools + tagged blocks)", nil},
		{"Tools support", nil},
		{"Embed only", nil},
		{"Base / unprobed", nil},
	}

	for _, s := range summaries {
		var cap *ModelCapability
		if a.ModelCache != nil {
			cap, _ = a.ModelCache.Get(s.Name)
		}
		if cap == nil {
			tiers[3].models = append(tiers[3].models, s)
			continue
		}
		switch {
		case cap.SupportsTools == CapYes && cap.SupportsTaggedBlocks == CapYes:
			tiers[0].models = append(tiers[0].models, s)
		case cap.SupportsTools == CapYes:
			tiers[1].models = append(tiers[1].models, s)
		case cap.SupportsEmbed == CapYes:
			tiers[2].models = append(tiers[2].models, s)
		default:
			tiers[3].models = append(tiers[3].models, s)
		}
	}

	first := true
	for _, t := range tiers {
		if len(t.models) == 0 {
			continue
		}
		if !first {
			fmt.Fprintln(out)
		}
		fmt.Fprintf(out, "  -- %s --\n", t.label)
		ollamaModelTable(a, t.models, out, false)
		first = false
	}
}

// ollamaProbe handles /ollama probe [MODEL|--all].
func ollamaProbe(a *Agent, args []string, out io.Writer) error {
	if !ProbeOllama(a.Config.OllamaURL) {
		fmt.Fprintln(out, "Ollama is not running.")
		return nil
	}
	if a.ModelCache == nil {
		fmt.Fprintln(out, "Model cache is not open.")
		return nil
	}

	ctx := context.Background()
	client := NewOllamaClient(a.Config.OllamaURL, "")

	// /ollama probe MODEL — probe a specific model, always refresh.
	if len(args) == 1 && args[0] != "--all" {
		model := a.Config.ResolveModelAlias(args[0])
		fmt.Fprintf(out, "Probing %s...\n", model)
		cap, err := ThoroughProbeModel(ctx, a.Config.OllamaURL, model)
		if err != nil {
			return fmt.Errorf("probe %s: %w", model, err)
		}
		if err := a.ModelCache.Set(cap); err != nil {
			return err
		}
		fmt.Fprintf(out, "  tools: %s   embed: %s   tagged: %s   ctx: %s   [thorough]\n",
			cap.SupportsTools, cap.SupportsEmbed, cap.SupportsTaggedBlocks, ollamaFormatCtx(cap.ContextLength))
		return nil
	}

	// /ollama probe or /ollama probe --all — probe all installed models.
	// Without --all, skip models already in the cache.
	forceAll := len(args) == 1 && args[0] == "--all"

	summaries, err := client.ModelSummaries(ctx)
	if err != nil {
		return err
	}

	var targets []string
	for _, s := range summaries {
		if forceAll {
			targets = append(targets, s.Name)
			continue
		}
		cap, _ := a.ModelCache.Get(s.Name)
		if cap == nil || cap.ProbeLevel == "none" {
			targets = append(targets, s.Name)
		}
	}

	if len(targets) == 0 {
		fmt.Fprintln(out, "All models are already probed. Use /ollama probe --all to refresh.")
		return nil
	}

	fmt.Fprintf(out, "Probing %d model(s)...\n", len(targets))
	for _, name := range targets {
		cap, err := ThoroughProbeModel(ctx, a.Config.OllamaURL, name)
		if err != nil {
			fmt.Fprintf(out, "  %-36s  error: %v\n", ollamaTruncateName(name, 36), err)
			continue
		}
		if err := a.ModelCache.Set(cap); err != nil {
			fmt.Fprintf(out, "  %-36s  cache write error: %v\n", ollamaTruncateName(name, 36), err)
			continue
		}
		fmt.Fprintf(out, "  %-36s  tools: %s   embed: %s   tagged: %s   [thorough]\n",
			ollamaTruncateName(name, 36), cap.SupportsTools, cap.SupportsEmbed, cap.SupportsTaggedBlocks)
	}
	fmt.Fprintln(out, "Done.")
	return nil
}

// ollamaTruncateName truncates s to at most max runes, appending "…" when cut.
func ollamaTruncateName(s string, max int) string {
	runes := []rune(s)
	if len(runes) <= max {
		return s
	}
	return string(runes[:max-1]) + "…"
}

// ─── /rename ─────────────────────────────────────────────────────────────────

/** cmdRename renames the active session recording file without ending the
 * session. The new name is placed in the same directory as the current file.
 * A .spmd extension is added automatically when omitted.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with an active Recorder.
 *   args ([]string)  — [0] new filename (path components are stripped).
 *   out  (io.Writer) — destination for command output.
 *
 * Returns:
 *   error — on rename failure.
 *
 * Example:
 *   /rename my-feature-session
 */
func cmdRename(a *Agent, args []string, out io.Writer) error {
	if a.Recorder == nil {
		fmt.Fprintln(out, "No active recording. Start one with /record start.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /rename NAME")
		return nil
	}
	name := filepath.Base(args[0])
	if !strings.HasSuffix(name, ".spmd") && !strings.HasSuffix(name, ".fountain") {
		name += ".spmd"
	}
	newPath := filepath.Join(filepath.Dir(a.Recorder.Path()), name)
	if err := a.Recorder.Rename(newPath); err != nil {
		return fmt.Errorf("rename: %w", err)
	}
	fmt.Fprintf(out, "Session renamed to: %s\n", newPath)
	return nil
}

// ─── /file-tree ──────────────────────────────────────────────────────────────

/** cmdFileTree prints a tree-style listing of the workspace directory,
 * skipping hidden files and directories. An optional path argument restricts
 * the listing to a subdirectory of the workspace root.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with a configured Workspace.
 *   args ([]string)  — optional [0] subdirectory path (relative to workspace root).
 *   out  (io.Writer) — destination for command output.
 *
 * Returns:
 *   error — if the path is outside the workspace.
 *
 * Example:
 *   /file-tree
 *   /file-tree harvey/
 */
func cmdFileTree(a *Agent, args []string, out io.Writer) error {
	root := a.Workspace.Root
	if len(args) > 0 {
		abs, err := resolveWorkspacePath(a.Workspace.Root, args[0])
		if err != nil {
			return fmt.Errorf("file-tree: %w", err)
		}
		root = abs
	}
	rel, _ := filepath.Rel(a.Workspace.Root, root)
	fmt.Fprintf(out, "%s\n", rel)
	printDirTree(root, "", out)
	return nil
}

// printDirTree recursively prints a directory tree using box-drawing characters.
// Hidden entries (names starting with ".") are skipped.
func printDirTree(dir, prefix string, out io.Writer) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return
	}
	var visible []fs.DirEntry
	for _, e := range entries {
		if !strings.HasPrefix(e.Name(), ".") {
			visible = append(visible, e)
		}
	}
	for i, e := range visible {
		connector := "├── "
		childPrefix := prefix + "│   "
		if i == len(visible)-1 {
			connector = "└── "
			childPrefix = prefix + "    "
		}
		fmt.Fprintf(out, "%s%s%s\n", prefix, connector, e.Name())
		if e.IsDir() {
			printDirTree(filepath.Join(dir, e.Name()), childPrefix, out)
		}
	}
}

// ollamaFormatBytes returns a human-readable size string from a byte count.
func ollamaFormatBytes(n int64) string {
	if n == 0 {
		return "—"
	}
	if gb := float64(n) / (1024 * 1024 * 1024); gb >= 1 {
		return fmt.Sprintf("%.1f GB", gb)
	}
	mb := float64(n) / (1024 * 1024)
	return fmt.Sprintf("%.0f MB", mb)
}

// ollamaFormatCtx returns a human-readable context-length string.
func ollamaFormatCtx(tokens int) string {
	if tokens <= 0 {
		return "—"
	}
	if tokens >= 1024 {
		return fmt.Sprintf("%dk", tokens/1024)
	}
	return fmt.Sprintf("%d", tokens)
}

// ─── /kb ─────────────────────────────────────────────────────────────────────

/** cmdKB handles Knowledge Base (KB) commands for managing projects,
 * observations, concepts, and full-text search.
 *
 * Subcommands:
 *   status    — Show summary of all projects and recent observations
 *   search    — Full-text search across all KB content
 *   inject   — Inject KB content into conversation context
 *   project  — Manage projects (add, list, info, status)
 *   observe  — Manage observations (add, list)
 *   concept  — Manage concepts (add, list, info)
 *   link     — Link observations/concepts to projects/concepts
 *
 * The knowledge base is a SQLite3 database storing projects, observations,
 * and concepts with FTS5 full-text search. Commands delegate to specialized
 * handlers (kbStatus, kbSearch, kbProject, etc.).
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with active KB connection.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdKB(a *Agent, args []string, out io.Writer) error {
	if a.KB == nil {
		fmt.Fprintln(out, "Knowledge base is not open. This should not happen — please restart Harvey.")
		return nil
	}
	if len(args) == 0 {
		return kbStatus(a, out)
	}
	switch strings.ToLower(args[0]) {
	case "status":
		return kbStatus(a, out)
	case "search":
		return kbSearch(a, args[1:], out)
	case "inject":
		return kbInject(a, args[1:], out)
	case "project":
		return kbProject(a, args[1:], out)
	case "observe":
		return kbObserve(a, args[1:], out)
	case "concept":
		return kbConcept(a, args[1:], out)
	default:
		fmt.Fprintf(out, "Unknown kb subcommand: %s\n", args[0])
		fmt.Fprintln(out, "Usage: /kb <status|search|inject|project|observe|concept> [args...]")
	}
	return nil
}

func kbStatus(a *Agent, out io.Writer) error {
	fmt.Fprintln(out)
	s, err := a.KB.Summary()
	if err != nil {
		return err
	}
	fmt.Fprint(out, s)
	return nil
}

// kbSearch handles /kb search TERM [TERM...] using the FTS5 index.
func kbSearch(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /kb search TERM [TERM...]")
		fmt.Fprintln(out, "Tip:   quote phrases (\"WAL mode\"), use * for prefix (docker*)")
		return nil
	}
	term := strings.Join(args, " ")
	results, err := a.KB.Search(term)
	if err != nil {
		return fmt.Errorf("kb search: %w", err)
	}
	if len(results) == 0 {
		fmt.Fprintf(out, "  No results for %q\n", term)
		return nil
	}
	fmt.Fprintln(out)
	for _, r := range results {
		switch {
		case r.Label != "" && r.Snippet != "":
			fmt.Fprintf(out, "  [%-10s] %s — %s\n", r.Kind, r.Label, r.Snippet)
		case r.Label != "":
			fmt.Fprintf(out, "  [%-10s] %s\n", r.Kind, r.Label)
		default:
			fmt.Fprintf(out, "  [%-10s] %s\n", r.Kind, r.Snippet)
		}
	}
	fmt.Fprintln(out)
	return nil
}

// kbInject formats KB content as Markdown and adds it to the conversation as
// context. With no argument it uses the current project (or all projects when
// none is set); with a project name it injects only that project.
func kbInject(a *Agent, args []string, out io.Writer) error {
	projectID := a.Config.Memory.CurrentProjectID
	label := "all projects"

	if len(args) > 0 {
		name := strings.Join(args, " ")
		p, err := a.KB.ProjectByName(name)
		if err != nil {
			return err
		}
		if p == nil {
			fmt.Fprintf(out, "  Project %q not found. Use /kb project list to see available projects.\n", name)
			return nil
		}
		projectID = p.ID
		label = fmt.Sprintf("project %q", p.Name)
	} else if projectID > 0 {
		label = fmt.Sprintf("current project (id=%d)", projectID)
	}

	md, err := a.KB.FormatMarkdown(projectID)
	if err != nil {
		return err
	}
	if md == "" {
		fmt.Fprintln(out, "  Knowledge base is empty.")
		return nil
	}

	a.AddMessage("user", "[knowledge base context]\n\n"+md)
	fmt.Fprintf(out, green("✓")+" KB context for %s injected (%d bytes).\n", label, len(md))
	return nil
}

// kbProject handles /kb project <list|add NAME [DESC]|use ID>
func kbProject(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /kb project <list|add NAME [DESC]|use ID>")
		return nil
	}
	switch strings.ToLower(args[0]) {
	case "list":
		projects, err := a.KB.Projects()
		if err != nil {
			return err
		}
		if len(projects) == 0 {
			fmt.Fprintln(out, "  (no projects)")
			return nil
		}
		for _, p := range projects {
			active := ""
			if a.Config.Memory.CurrentProjectID == p.ID {
				active = " *"
			}
			fmt.Fprintf(out, "  [%d]%s %s  (%s)\n", p.ID, active, p.Name, p.Status)
			if p.Description != "" {
				fmt.Fprintf(out, "      %s\n", p.Description)
			}
		}
	case "add":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /kb project add NAME [DESCRIPTION]")
			return nil
		}
		name := args[1]
		desc := strings.Join(args[2:], " ")
		id, err := a.KB.AddProject(name, desc)
		if err != nil {
			return err
		}
		a.Config.Memory.CurrentProjectID = id
		fmt.Fprintf(out, "Project %q added (id=%d) and set as current.\n", name, id)
	case "use":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /kb project use ID")
			return nil
		}
		id, err := strconv.ParseInt(args[1], 10, 64)
		if err != nil {
			fmt.Fprintf(out, "Invalid project ID: %s\n", args[1])
			return nil
		}
		a.Config.Memory.CurrentProjectID = id
		fmt.Fprintf(out, "Current project set to id=%d.\n", id)
	default:
		fmt.Fprintf(out, "Unknown project subcommand: %s\n", args[0])
	}
	return nil
}

// kbObserve handles /kb observe KIND BODY...
// KIND defaults to "note" if omitted or invalid.
func kbObserve(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /kb observe [KIND] TEXT")
		fmt.Fprintf(out, "Kinds: %s  (default: note)\n", strings.Join(ValidObservationKinds, ", "))
		return nil
	}
	if a.Config.Memory.CurrentProjectID == 0 {
		fmt.Fprintln(out, "No current project. Use /kb project add NAME or /kb project use ID first.")
		return nil
	}

	kind := "note"
	bodyArgs := args
	if isValidKind(strings.ToLower(args[0])) {
		kind = strings.ToLower(args[0])
		bodyArgs = args[1:]
	}
	if len(bodyArgs) == 0 {
		fmt.Fprintln(out, "Observation text is required.")
		return nil
	}
	body := strings.Join(bodyArgs, " ")
	id, err := a.KB.AddObservation(a.Config.Memory.CurrentProjectID, kind, body)
	if err != nil {
		return err
	}
	fmt.Fprintf(out, "Observation recorded (id=%d, kind=%s).\n", id, kind)
	return nil
}

// kbConcept handles /kb concept <list|add NAME [DESC]>
func kbConcept(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /kb concept <list|add NAME [DESCRIPTION]>")
		return nil
	}
	switch strings.ToLower(args[0]) {
	case "list":
		concepts, err := a.KB.Concepts()
		if err != nil {
			return err
		}
		if len(concepts) == 0 {
			fmt.Fprintln(out, "  (no concepts)")
			return nil
		}
		for _, c := range concepts {
			fmt.Fprintf(out, "  [%d] %s", c.ID, c.Name)
			if c.Description != "" {
				fmt.Fprintf(out, " — %s", c.Description)
			}
			fmt.Fprintln(out)
		}
	case "add":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /kb concept add NAME [DESCRIPTION]")
			return nil
		}
		name := args[1]
		desc := strings.Join(args[2:], " ")
		id, err := a.KB.AddConcept(name, desc)
		if err != nil {
			return err
		}
		fmt.Fprintf(out, "Concept %q added (id=%d).\n", name, id)
	default:
		fmt.Fprintf(out, "Unknown concept subcommand: %s\n", args[0])
	}
	return nil
}

// ─── /record ─────────────────────────────────────────────────────────────────

/** cmdRecord manages session recording to Fountain screenplay files. Harvey
 * records all conversations to .spmd files for auditability and resumption.
 *
 * Subcommands:
 *   start [FILE]  — Begin recording to specified file (or auto-generated path)
 *   stop         — Stop current recording session
 *   status       — Show current recording status and file path
 *
 * Recording is enabled by default on startup. Sessions are saved to
 * agents/sessions/ by default, with filenames like:
 *   harvey-session-YYYYMMDD-HHMMSS.spmd
 *
 * The Fountain format (.spmd) captures all chat turns, file operations,
 * shell commands, and skill executions with proper character attribution
 * (HARVEY, USER, MODEL_NAME, etc.).
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with recording state.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdRecord(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /record <start [FILE]|stop|status>")
		return nil
	}
	switch strings.ToLower(args[0]) {
	case "start":
		if a.Recorder != nil {
			fmt.Fprintf(out, "Already recording to %s. Use /record stop first.\n", a.Recorder.Path())
			return nil
		}
		path := ""
		if len(args) >= 2 {
			path = args[1]
		} else {
			sessDir := a.SessionsDir
			if sessDir == "" {
				sessDir = "."
			}
			path = DefaultSessionPath(sessDir)
		}
		model := "none"
		if a.Client != nil {
			model = a.Client.Name()
		}
		ws := "."
		if a.Workspace != nil {
			ws = a.Workspace.Root
		}
		r, err := NewRecorder(path, model, ws)
		if err != nil {
			return err
		}
		a.Recorder = r
		fmt.Fprintf(out, "Recording started: %s\n", path)
	case "stop":
		if a.Recorder == nil {
			fmt.Fprintln(out, "Not currently recording.")
			return nil
		}
		path := a.Recorder.Path()
		if err := a.Recorder.Close(); err != nil {
			return err
		}
		a.Recorder = nil
		fmt.Fprintf(out, "Recording stopped. Session saved to %s\n", path)
	case "status":
		if a.Recorder != nil {
			fmt.Fprintf(out, "Recording to: %s\n", a.Recorder.Path())
		} else {
			fmt.Fprintln(out, "Not recording.")
		}
	default:
		fmt.Fprintf(out, "Unknown record subcommand: %s\n", args[0])
		fmt.Fprintln(out, "Usage: /record <start [FILE]|stop|status>")
	}
	return nil
}

// ─── /files ──────────────────────────────────────────────────────────────────

func cmdFiles(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	path := "."
	if len(args) > 0 {
		path = args[0]
	}
	entries, err := a.Workspace.ListDir(path)
	if err != nil {
		return fmt.Errorf("files: %w", err)
	}
	fmt.Fprintf(out, "\n  %s/\n", path)
	for _, e := range entries {
		suffix := ""
		if e.IsDir() {
			suffix = "/"
		}
		fmt.Fprintf(out, "    %s%s\n", e.Name(), suffix)
	}
	fmt.Fprintln(out)
	return nil
}

// ─── /read ───────────────────────────────────────────────────────────────────

// cmdRead reads one or more workspace files and injects their contents into
// the conversation as a user-role context message.
func cmdRead(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /read FILE [FILE...]")
		return nil
	}

	var sb strings.Builder
	sb.WriteString("[context: /read")
	for _, f := range args {
		sb.WriteString(" " + f)
	}
	sb.WriteString("]\n")

	ok := 0
	for _, rel := range args {
		// Remote URI: bypass workspace permissions and read directly.
		if parseURIScheme(rel) != "" {
			rr, err := NewRemoteReader(rel)
			if err != nil {
				fmt.Fprintf(out, "  ✗ %s: %v\n", rel, err)
				continue
			}
			var buf bytes.Buffer
			if err := rr.Get(context.Background(), rel, &buf); err != nil {
				fmt.Fprintf(out, "  ✗ %s: %v\n", rel, err)
				continue
			}
			data := buf.Bytes()
			fmt.Fprintf(out, "  ✓ %s (%d bytes)\n", rel, len(data))
			sb.WriteString("\n```" + rel + "\n")
			sb.Write(data)
			if len(data) > 0 && data[len(data)-1] != '\n' {
				sb.WriteByte('\n')
			}
			sb.WriteString("```\n")
			ok++
			continue
		}

		if !a.CheckReadPermission(rel) {
			if a.AuditBuffer != nil {
				a.AuditBuffer.Log(ActionFileRead, rel, StatusDenied)
			}
			fmt.Fprintf(out, "  ✗ %s: read permission denied\n", rel)
			continue
		}
		data, err := a.Workspace.ReadFile(rel)
		if err != nil {
			if a.AuditBuffer != nil {
				a.AuditBuffer.Log(ActionFileRead, rel, StatusError)
			}
			fmt.Fprintf(out, "  ✗ %s: %v\n", rel, err)
			continue
		}
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionFileRead, rel, StatusSuccess)
		}
		fmt.Fprintf(out, "  ✓ %s (%d bytes)\n", rel, len(data))
		sb.WriteString("\n```" + rel + "\n")
		sb.Write(data)
		if len(data) > 0 && data[len(data)-1] != '\n' {
			sb.WriteByte('\n')
		}
		sb.WriteString("```\n")
		ok++
	}

	if ok == 0 {
		return nil
	}
	a.AddMessage("user", sb.String())
	fmt.Fprintf(out, "  %d file(s) added to context.\n", ok)
	return nil
}

// ─── /read-dir ───────────────────────────────────────────────────────────────

// defaultMaxReadDirBytes is the total context cap for /read-dir.
const defaultMaxReadDirBytes = 256 * 1024

/** cmdReadDir walks a workspace directory and injects all eligible files into
 * the conversation as a context message. Files are skipped when hidden,
 * binary, inside agents/, matching sensitive patterns, or over 64 KB.
 * The total injected bytes are capped at 256 KB.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with a configured Workspace.
 *   args ([]string)  — optional [PATH] and [--depth N] flags.
 *   out  (io.Writer) — destination for progress output.
 *
 * Returns:
 *   error — path-resolution or OS errors only; skips are reported, not errored.
 *
 * Example:
 *   /read-dir harvey/ --depth 1
 */
func cmdReadDir(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}

	dirArg := "."
	maxDepth := 2

	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--depth", "-d":
			if i+1 >= len(args) {
				fmt.Fprintln(out, "read-dir: --depth requires a number")
				return nil
			}
			i++
			n, err := strconv.Atoi(args[i])
			if err != nil || n < 0 {
				fmt.Fprintf(out, "read-dir: invalid depth %q\n", args[i])
				return nil
			}
			maxDepth = n
		default:
			if dirArg != "." {
				fmt.Fprintln(out, "Usage: /read-dir [PATH] [--depth N]")
				return nil
			}
			dirArg = args[i]
		}
	}

	absDir, err := a.Workspace.AbsPath(dirArg)
	if err != nil {
		return fmt.Errorf("read-dir: %w", err)
	}

	relDir, _ := filepath.Rel(a.Workspace.Root, absDir)
	if !a.CheckReadPermission(relDir) {
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionFileRead, relDir, StatusDenied)
		}
		fmt.Fprintf(out, "read-dir: read permission denied for %s\n", relDir)
		return nil
	}

	info, err := os.Stat(absDir)
	if err != nil {
		return fmt.Errorf("read-dir: %w", err)
	}
	if !info.IsDir() {
		fmt.Fprintf(out, "read-dir: %s is not a directory\n", dirArg)
		return nil
	}

	const perFileCap = defaultMaxOutputBytes

	var sb strings.Builder
	sb.WriteString("[context: /read-dir " + dirArg + "]\n")

	var ok, skipped, totalBytes int
	stopped := false

	filepath.WalkDir(absDir, func(p string, d fs.DirEntry, werr error) error {
		if werr != nil || stopped {
			return nil
		}
		if d.Type()&fs.ModeSymlink != 0 {
			skipped++
			return nil
		}

		if d.IsDir() {
			if p == absDir {
				return nil
			}
			if strings.HasPrefix(d.Name(), ".") {
				return filepath.SkipDir
			}
			// Enforce maxDepth (0 = unlimited).
			if maxDepth > 0 {
				rel, _ := filepath.Rel(absDir, p)
				level := strings.Count(rel, string(filepath.Separator)) + 1
				if level >= maxDepth {
					return filepath.SkipDir
				}
			}
			return nil
		}

		// Skip hidden files.
		if strings.HasPrefix(d.Name(), ".") {
			skipped++
			return nil
		}

		relPath, _ := filepath.Rel(a.Workspace.Root, p)

		if isAgentsDir(a.Workspace.Root, p) || sensitiveFileDenied(p) {
			skipped++
			return nil
		}

		if !a.CheckReadPermission(relPath) {
			if a.AuditBuffer != nil {
				a.AuditBuffer.Log(ActionFileRead, relPath, StatusDenied)
			}
			skipped++
			return nil
		}

		data, err := os.ReadFile(p)
		if err != nil {
			skipped++
			return nil
		}

		if isBinary(data) {
			skipped++
			return nil
		}

		if len(data) > perFileCap {
			fmt.Fprintf(out, "  ~ %s (%d bytes, exceeds per-file cap — skipped)\n", relPath, len(data))
			skipped++
			return nil
		}

		if totalBytes+len(data) > defaultMaxReadDirBytes {
			stopped = true
			return nil
		}

		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionFileRead, relPath, StatusSuccess)
		}

		sb.WriteString("\n```" + relPath + "\n")
		sb.Write(data)
		if len(data) > 0 && data[len(data)-1] != '\n' {
			sb.WriteByte('\n')
		}
		sb.WriteString("```\n")

		totalBytes += len(data)
		ok++
		fmt.Fprintf(out, "  ✓ %s (%d bytes)\n", relPath, len(data))
		return nil
	})

	if ok == 0 {
		fmt.Fprintln(out, "  No readable files found.")
		return nil
	}

	a.AddMessage("user", sb.String())
	if stopped {
		fmt.Fprintf(out, "  %d file(s) added (%d bytes). Reached %d KB cap — narrow scope with a subdirectory path or --depth.\n",
			ok, totalBytes, defaultMaxReadDirBytes/1024)
	} else {
		fmt.Fprintf(out, "  %d file(s) added to context (%d bytes)", ok, totalBytes)
		if skipped > 0 {
			fmt.Fprintf(out, ", %d skipped (hidden/binary/sensitive/too large)", skipped)
		}
		fmt.Fprintln(out, ".")
	}
	return nil
}

// ─── /read-pdf ───────────────────────────────────────────────────────────────

// readPDFMaxPages is the maximum number of pages /read-pdf will inject in one
// call. PDFs larger than this require an explicit page range.
const readPDFMaxPages = 20

/** cmdReadPDF extracts text from a PDF file and injects it into the
 * conversation as a user-role context message. It uses pdfExtract internally,
 * which requires the poppler utilities (pdfinfo, pdftotext, pdfimages).
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent.
 *   args ([]string)  — [FILE] and optional [PAGES] (e.g. "40-55").
 *   out  (io.Writer) — destination for progress output.
 *
 * Returns:
 *   error — only for unexpected internal failures; user errors are printed to out.
 *
 * Example:
 *   /read-pdf ~/docs/oberon2.pdf 49-63
 */
func cmdReadPDF(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /read-pdf FILE [PAGES]")
		fmt.Fprintln(out, "  Example: /read-pdf ~/docs/spec.pdf 40-55")
		return nil
	}

	if err := checkPopplerTools(); err != nil {
		fmt.Fprintln(out, err.Error())
		return nil
	}

	filePath := args[0]
	var pages string
	if len(args) > 1 {
		pages = args[1]
	}

	absPath, err := resolvePDFPath(filePath)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", filePath, err)
		return nil
	}

	// Enforce page cap before the expensive extraction.
	if pages == "" {
		infoOut, err := runTool("pdfinfo", absPath)
		if err != nil {
			fmt.Fprintf(out, "  ✗ cannot read PDF: %v\n", err)
			return nil
		}
		info := parsePDFInfo(infoOut)
		if info.Pages > readPDFMaxPages {
			fmt.Fprintf(out, "  ✗ %s has %d pages; /read-pdf is limited to %d pages per call.\n",
				filePath, info.Pages, readPDFMaxPages)
			fmt.Fprintf(out, "     Specify a range, e.g.: /read-pdf %s 1-%d\n", filePath, readPDFMaxPages)
			return nil
		}
	} else {
		first, last, err := parsePDFPageRange(pages)
		if err != nil {
			fmt.Fprintf(out, "  ✗ %v\n", err)
			return nil
		}
		if last-first+1 > readPDFMaxPages {
			fmt.Fprintf(out, "  ✗ page range %s spans %d pages; limit is %d.\n",
				pages, last-first+1, readPDFMaxPages)
			fmt.Fprintf(out, "     Narrow the range, e.g.: %d-%d\n", first, first+readPDFMaxPages-1)
			return nil
		}
	}

	fmt.Fprintf(out, "  Extracting %s", filePath)
	if pages != "" {
		fmt.Fprintf(out, " pages %s", pages)
	}
	fmt.Fprintln(out, " …")

	result, err := pdfExtract(absPath, pages)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %v\n", err)
		return nil
	}

	var sb strings.Builder
	fmt.Fprintf(&sb, "[context: /read-pdf %s", filePath)
	if pages != "" {
		fmt.Fprintf(&sb, " pages %s", pages)
	}
	fmt.Fprintln(&sb, "]")
	fmt.Fprintln(&sb)

	if result.Info.Title != "" {
		fmt.Fprintf(&sb, "Title:  %s\n", result.Info.Title)
	}
	if result.Info.Author != "" {
		fmt.Fprintf(&sb, "Author: %s\n", result.Info.Author)
	}
	if result.Info.Pages > 0 {
		fmt.Fprintf(&sb, "Pages:  %d\n", result.Info.Pages)
	}
	if result.Info.CreatedAt != "" {
		fmt.Fprintf(&sb, "Date:   %s\n", result.Info.CreatedAt)
	}
	if len(result.DiagramPages) > 0 {
		pageNums := make([]string, len(result.DiagramPages))
		for i, p := range result.DiagramPages {
			pageNums[i] = strconv.Itoa(p)
		}
		fmt.Fprintf(&sb, "\nNote: page(s) %s appear to contain only vector diagrams — content may be incomplete. Use a vision-capable model to process those pages.\n",
			strings.Join(pageNums, ", "))
	}
	fmt.Fprintln(&sb)
	sb.WriteString(result.Text)

	a.AddMessage("user", sb.String())

	// Count non-empty injected pages for the confirmation message.
	injected := 0
	for _, pt := range strings.Split(result.Text, "\f") {
		if strings.TrimSpace(pt) != "" {
			injected++
		}
	}
	fmt.Fprintf(out, "  ✓ %d page(s) added to context", injected)
	if len(result.DiagramPages) > 0 {
		fmt.Fprintf(out, " (%d diagram-only page(s) flagged)", len(result.DiagramPages))
	}
	fmt.Fprintln(out)
	return nil
}

// resolvePDFPath expands ~ and converts a relative path to absolute.
// Unlike Workspace.AbsPath it does not enforce workspace boundaries, because
// /read-pdf is designed to accept arbitrary file system paths.
func resolvePDFPath(path string) (string, error) {
	if path == "~" || strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("cannot resolve home directory: %w", err)
		}
		if path == "~" {
			return home, nil
		}
		path = filepath.Join(home, path[2:])
	}
	if !filepath.IsAbs(path) {
		abs, err := filepath.Abs(path)
		if err != nil {
			return "", err
		}
		return abs, nil
	}
	return path, nil
}

// ─── /attach ─────────────────────────────────────────────────────────────────

// attachMaxImageBytes is the file-size ceiling for native image attachment.
const attachMaxImageBytes = 5 * 1024 * 1024 // 5 MB

// attachMaxTextBytes is the ceiling for plain-text file injection.
const attachMaxTextBytes = 256 * 1024 // 256 KB

/** cmdAttach attaches a file to the conversation as the most useful form the
 * current route can accept. Images are sent as base64 data-URL ContentParts
 * when the active model supports vision; otherwise a text description is
 * injected. PDFs are extracted via pdfExtract (same page cap as /read-pdf).
 * All other files are injected as plain text when ≤ 256 KB.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent.
 *   args ([]string)  — [FILE].
 *   out  (io.Writer) — destination for progress output.
 *
 * Returns:
 *   error — only for unexpected internal failures; user errors are printed to out.
 *
 * Example:
 *   /attach ~/photos/diagram.png
 *   /attach ~/docs/spec.pdf
 */
func cmdAttach(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /attach FILE")
		fmt.Fprintln(out, "  Images: attached natively if the route supports vision, text description otherwise.")
		fmt.Fprintln(out, "  PDFs:   text extracted via pdfExtract (20-page cap; requires poppler).")
		fmt.Fprintln(out, "  Other:  injected as plain text (≤ 256 KB).")
		return nil
	}

	filePath := args[0]

	// Remote URI: download content and route through the same MIME detection.
	if parseURIScheme(filePath) != "" {
		return cmdAttachRemote(a, filePath, out)
	}

	absPath, err := resolvePDFPath(filePath)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", filePath, err)
		return nil
	}

	fi, err := os.Stat(absPath)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", filePath, err)
		return nil
	}
	if fi.IsDir() {
		fmt.Fprintf(out, "  ✗ %s is a directory; use /read-dir for directories\n", filePath)
		return nil
	}

	// PDFs are routed before reading the full file to avoid loading 100 MB
	// into memory only to hand it off to pdftotext.
	if strings.ToLower(filepath.Ext(absPath)) == ".pdf" {
		return cmdReadPDF(a, []string{absPath}, out)
	}

	data, err := os.ReadFile(absPath)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", filePath, err)
		return nil
	}

	mime := attachDetectMIME(absPath, data)
	base := filepath.Base(absPath)

	if attachIsImageMIME(mime) {
		return attachImage(a, filePath, base, data, mime, out)
	}
	return attachText(a, filePath, base, data, mime, out)
}

// cmdAttachRemote downloads a remote URI and attaches it using the same MIME
// routing as cmdAttach for local files. PDFs are written to a temp file so
// that the existing pdftotext pipeline can operate on them. The URI is shown
// in output; credentials are never revealed.
func cmdAttachRemote(a *Agent, uri string, out io.Writer) error {
	rr, err := NewRemoteReader(uri)
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", uri, err)
		return nil
	}
	var buf bytes.Buffer
	if err := rr.Get(context.Background(), uri, &buf); err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", uri, err)
		return nil
	}
	data := buf.Bytes()
	base := filepath.Base(uri)

	// PDFs: write to temp file and route through the existing pdftotext pipeline.
	if strings.ToLower(filepath.Ext(base)) == ".pdf" {
		f, err := os.CreateTemp("", "harvey-remote-*.pdf")
		if err != nil {
			fmt.Fprintf(out, "  ✗ %s: create temp: %v\n", uri, err)
			return nil
		}
		tmpPath := f.Name()
		defer os.Remove(tmpPath)
		if _, werr := f.Write(data); werr != nil {
			f.Close()
			fmt.Fprintf(out, "  ✗ %s: write temp: %v\n", uri, werr)
			return nil
		}
		f.Close()
		return cmdReadPDF(a, []string{tmpPath}, out)
	}

	mime := attachDetectMIME(base, data)
	if attachIsImageMIME(mime) {
		return attachImage(a, uri, base, data, mime, out)
	}
	return attachText(a, uri, base, data, mime, out)
}

// attachImage attaches an image file to the conversation. When the active
// route reports vision capability the image is encoded as a base64 data-URL
// ContentPart; otherwise a text description is injected so the turn still
// carries the attachment metadata.
func attachImage(a *Agent, filePath, base string, data []byte, mime string, out io.Writer) error {
	if len(data) > attachMaxImageBytes {
		fmt.Fprintf(out, "  ✗ %s: image too large (%s); maximum is 5 MB\n",
			filePath, formatBytes(int64(len(data))))
		return nil
	}

	if attachClientSupportsVision(a) {
		encoded := base64.StdEncoding.EncodeToString(data)
		parts := []anyllm.ContentPart{
			{Type: "text", Text: fmt.Sprintf("[attached: %s]", base)},
			{Type: "image_url", ImageURL: &anyllm.ImageURL{
				URL: "data:" + mime + ";base64," + encoded,
			}},
		}
		a.AddMessageParts("user", parts)
		fmt.Fprintf(out, "  ✓ %s attached natively (%s, %s)\n",
			base, mime, formatBytes(int64(len(data))))
	} else {
		text := fmt.Sprintf(
			"[attached: %s — %s, %s — vision not available on current route; switch to a vision-capable route to process this image natively]",
			base, mime, formatBytes(int64(len(data))))
		a.AddMessage("user", text)
		fmt.Fprintf(out, "  ✓ %s attached as text description (route has no vision capability)\n", base)
		fmt.Fprintln(out, "     Tip: use @name to route the next turn to a vision-capable endpoint.")
	}
	return nil
}

// attachText injects a plain-text (or unknown-format) file into the
// conversation. Binary files are rejected with an explanation.
func attachText(a *Agent, filePath, base string, data []byte, mime string, out io.Writer) error {
	if len(data) > attachMaxTextBytes {
		fmt.Fprintf(out, "  ✗ %s: file too large (%s) for text injection; maximum is 256 KB\n",
			filePath, formatBytes(int64(len(data))))
		fmt.Fprintln(out, "     Use /rag ingest to index large files for retrieval instead.")
		return nil
	}
	// Reject binary content (null byte in sample is the classic heuristic).
	sample := data
	if len(sample) > 512 {
		sample = sample[:512]
	}
	for _, b := range sample {
		if b == 0 {
			fmt.Fprintf(out, "  ✗ %s appears to be binary (%s); /attach supports images, PDFs, and text files\n",
				filePath, mime)
			return nil
		}
	}

	var sb strings.Builder
	fmt.Fprintf(&sb, "[attached: %s]\n\n", base)
	sb.Write(data)
	a.AddMessage("user", sb.String())
	fmt.Fprintf(out, "  ✓ %s attached as text (%s)\n", base, formatBytes(int64(len(data))))
	return nil
}

// attachDetectMIME returns the MIME type of the file. The extension is
// checked first for formats (e.g. WebP) that the sniff algorithm misidentifies;
// then http.DetectContentType is applied to the first 512 bytes.
func attachDetectMIME(path string, data []byte) string {
	switch strings.ToLower(filepath.Ext(path)) {
	case ".webp":
		return "image/webp"
	}
	sample := data
	if len(sample) > 512 {
		sample = sample[:512]
	}
	return http.DetectContentType(sample)
}

// attachIsImageMIME reports whether mime is an image type that can be
// attached natively or described textually.
func attachIsImageMIME(mime string) bool {
	switch strings.SplitN(mime, ";", 2)[0] {
	case "image/jpeg", "image/png", "image/gif", "image/webp":
		return true
	}
	return false
}

// attachClientSupportsVision reports whether the current LLM client declares
// image completion capability.
func attachClientSupportsVision(a *Agent) bool {
	ac, ok := a.Client.(*AnyLLMClient)
	if !ok {
		return false
	}
	return ac.ProviderCapabilities().CompletionImage
}

// ─── /write ──────────────────────────────────────────────────────────────────

// cmdWrite writes the last assistant reply to a workspace file. If the reply
// contains a fenced code block the first such block is extracted; otherwise
// the full reply text is written.
func cmdWrite(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /write PATH")
		return nil
	}
	dest := args[0]

	// Find the last assistant message.
	var reply string
	for i := len(a.History) - 1; i >= 0; i-- {
		if a.History[i].Role == "assistant" {
			reply = a.History[i].Content
			break
		}
	}
	if reply == "" {
		fmt.Fprintln(out, "No assistant reply in history to write.")
		return nil
	}

	content, ok := extractCodeBlock(reply)
	if !ok {
		content = reply
	}

	if !a.CheckWritePermission(dest) {
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionFileWrite, dest, StatusDenied)
		}
		fmt.Fprintf(out, "  write permission denied for %s\n", dest)
		return nil
	}
	if err := a.Workspace.WriteFile(dest, []byte(content), 0o644); err != nil {
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionFileWrite, dest, StatusError)
		}
		return fmt.Errorf("write: %w", err)
	}
	if a.AuditBuffer != nil {
		a.AuditBuffer.Log(ActionFileWrite, dest, StatusSuccess)
	}
	source := "full reply"
	if ok {
		source = "first code block"
	}
	fmt.Fprintf(out, "  ✓ Wrote %s to %s (%d bytes)\n", source, dest, len(content))
	return nil
}

// suggestPathFromHistory scans the last user message in history for a single
// token that looks like a file path (via looksLikePath). Returns that token
// when exactly one candidate is found, or "" when there are zero or more than
// one (ambiguous). Punctuation and backtick quotes are stripped before testing.
func suggestPathFromHistory(history []Message) string {
	for i := len(history) - 1; i >= 0; i-- {
		if history[i].Role != "user" {
			continue
		}
		var candidates []string
		for _, tok := range strings.Fields(history[i].Content) {
			tok = strings.Trim(tok, ".,;:!?\"'`()")
			if looksLikePath(tok) {
				candidates = append(candidates, tok)
			}
		}
		if len(candidates) == 1 {
			return candidates[0]
		}
		return ""
	}
	return ""
}

// extractCodeBlock finds the first fenced code block (``` ... ```) in text
// and returns its contents without the fence lines. Returns ("", false) if
// no fenced block is found.
func extractCodeBlock(text string) (string, bool) {
	lines := strings.Split(text, "\n")
	inBlock := false
	var buf strings.Builder
	for _, line := range lines {
		if !inBlock {
			if strings.HasPrefix(line, "```") {
				inBlock = true
			}
			continue
		}
		if strings.HasPrefix(line, "```") {
			return buf.String(), true
		}
		buf.WriteString(line)
		buf.WriteByte('\n')
	}
	return "", false
}

// ─── /run ────────────────────────────────────────────────────────────────────

// maxRunOutput is the maximum number of bytes of command output injected into
// context. Output beyond this is truncated to protect the context window.
const maxRunOutput = 8000

// cmdRun executes a shell command inside the workspace root, captures combined
// stdout+stderr, and injects the result into the conversation as a user-role
// context message.
//
// Security: Uses direct exec (not shell) and filters environment to prevent
// sensitive data leakage. Uses a timeout context to prevent hanging.
func cmdRun(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /run COMMAND [ARGS...]")
		return nil
	}

	// Safe mode check: verify command is in allowlist
	if a.Config.SafeMode && !a.Config.IsCommandAllowed(args[0]) {
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionCommand, strings.Join(args, " "), StatusDenied)
		}
		fmt.Fprintf(out, yellow("  Command %q is not allowed in safe mode.\n"), args[0])
		fmt.Fprintf(out, "  Allowed commands: %s\n", strings.Join(a.Config.AllowedCommands, ", "))
		fmt.Fprintln(out, "  Use /safemode off to disable, or /safemode allow CMD to add it.")
		return nil
	}

	// Log allowed command execution
	if a.AuditBuffer != nil {
		a.AuditBuffer.Log(ActionCommand, strings.Join(args, " "), StatusAllowed)
	}

	cmdLine := strings.Join(args, " ")
	fmt.Fprintf(out, "  $ %s\n", cmdLine)

	// Validate command line to prevent shell metacharacter injection
	program, cmdArgs, err := parseCommandLine(cmdLine)
	if err != nil {
		fmt.Fprintf(out, yellow("  Invalid command: %v\n"), err)
		if a.AuditBuffer != nil {
			a.AuditBuffer.Log(ActionCommand, cmdLine, StatusDenied)
		}
		return nil
	}

	// Use a context with an optional timeout to prevent hanging commands.
	var ctx context.Context
	var cancel context.CancelFunc
	if a.Config.RunTimeout > 0 {
		ctx, cancel = context.WithTimeout(context.Background(), a.Config.RunTimeout)
	} else {
		ctx, cancel = context.WithCancel(context.Background())
	}
	defer cancel()

	cmd := exec.CommandContext(ctx, program, cmdArgs...)
	cmd.Dir = a.Workspace.Root
	// Filter environment to prevent sensitive data leakage
	cmd.Env = filterCommandEnvironment(os.Environ())
	raw, _ := cmd.CombinedOutput() // error reflected via exit code note below

	truncated := false
	output := raw
	if len(output) > maxRunOutput {
		output = output[:maxRunOutput]
		truncated = true
	}

	exitNote := ""
	if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() != 0 {
		exitNote = fmt.Sprintf(" (exit %d)", cmd.ProcessState.ExitCode())
	}

	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("[context: /run %s%s]\n\n```\n", cmdLine, exitNote))
	sb.Write(output)
	if truncated {
		sb.WriteString("\n... (output truncated)")
	}
	sb.WriteString("\n```\n")

	a.AddMessage("user", sb.String())
	fmt.Fprintf(out, "  %d bytes of output added to context%s.\n", len(output), exitNote)
	return nil
}

// ─── /search ─────────────────────────────────────────────────────────────────

// maxSearchMatches is the maximum number of matching lines injected into context.
const maxSearchMatches = 100

// cmdSearch searches workspace files for a regexp pattern and injects matches
// into the conversation as a user-role context message.
func cmdSearch(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /search PATTERN [PATH]")
		return nil
	}

	re, err := regexp.Compile(args[0])
	if err != nil {
		return fmt.Errorf("search: invalid pattern: %w", err)
	}
	searchRoot := "."
	if len(args) > 1 {
		searchRoot = args[1]
	}
	absRoot, err := a.Workspace.AbsPath(searchRoot)
	if err != nil {
		return fmt.Errorf("search: %w", err)
	}

	type match struct {
		file string
		line int
		text string
	}
	var matches []match
	truncated := false

	filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, werr error) error {
		if werr != nil || truncated {
			return nil
		}
		if d.Type()&fs.ModeSymlink != 0 {
			return nil
		}
		if d.IsDir() {
			if strings.HasPrefix(d.Name(), ".") {
				return filepath.SkipDir
			}
			if path == filepath.Join(a.Workspace.Root, "agents") {
				return filepath.SkipDir
			}
			return nil
		}
		if isAgentsDir(a.Workspace.Root, path) {
			return nil
		}
		data, err := os.ReadFile(path)
		if err != nil || isBinary(data) {
			return nil
		}
		rel, _ := filepath.Rel(a.Workspace.Root, path)
		scanner := bufio.NewScanner(strings.NewReader(string(data)))
		lineNum := 0
		for scanner.Scan() {
			lineNum++
			if re.MatchString(scanner.Text()) {
				matches = append(matches, match{rel, lineNum, scanner.Text()})
				if len(matches) >= maxSearchMatches {
					truncated = true
					return fs.SkipAll
				}
			}
		}
		return nil
	})

	if len(matches) == 0 {
		fmt.Fprintf(out, "  No matches for %q\n", args[0])
		return nil
	}

	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("[context: /search %s]\n\n", strings.Join(args, " ")))
	for _, m := range matches {
		sb.WriteString(fmt.Sprintf("%s:%d: %s\n", m.file, m.line, m.text))
	}
	if truncated {
		sb.WriteString(fmt.Sprintf("... (results truncated at %d matches)\n", maxSearchMatches))
	}

	a.AddMessage("user", sb.String())
	fmt.Fprintf(out, "  %d match(es) for %q added to context", len(matches), args[0])
	if truncated {
		fmt.Fprint(out, " (truncated)")
	}
	fmt.Fprintln(out)
	return nil
}

// isBinary reports whether data appears to be a binary (non-text) file by
// looking for null bytes in the first 512 bytes.
func isBinary(data []byte) bool {
	check := data
	if len(check) > 512 {
		check = check[:512]
	}
	for _, b := range check {
		if b == 0 {
			return true
		}
	}
	return false
}

// ─── /git ────────────────────────────────────────────────────────────────────

// gitAllowedSubcmds is the set of read-only git subcommands /git will run.
var gitAllowedSubcmds = map[string]bool{
	"status": true,
	"diff":   true,
	"log":    true,
	"show":   true,
	"blame":  true,
}

// cmdGit runs a read-only git subcommand in the workspace root and injects
// the output into the conversation as a user-role context message.
func cmdGit(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /git <status|diff|log|show|blame> [ARGS...]")
		return nil
	}

	sub := strings.ToLower(args[0])
	if !gitAllowedSubcmds[sub] {
		fmt.Fprintf(out, "  /git only supports read-only subcommands: status, diff, log, show, blame\n")
		return nil
	}

	gitArgs := append([]string{sub}, args[1:]...)
	cmdLine := "git " + strings.Join(gitArgs, " ")
	fmt.Fprintf(out, "  $ %s\n", cmdLine)

	cmd := exec.Command("git", gitArgs...)
	cmd.Dir = a.Workspace.Root
	// Filter environment to prevent sensitive data leakage
	cmd.Env = filterCommandEnvironment(os.Environ())
	raw, _ := cmd.CombinedOutput()

	if len(raw) == 0 {
		fmt.Fprintln(out, "  (no output)")
		return nil
	}

	truncated := false
	output := raw
	if len(output) > maxRunOutput {
		output = output[:maxRunOutput]
		truncated = true
	}

	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("[context: /git %s]\n\n```\n", strings.Join(gitArgs, " ")))
	sb.Write(output)
	if truncated {
		sb.WriteString("\n... (output truncated)")
	}
	sb.WriteString("\n```\n")

	a.AddMessage("user", sb.String())
	fmt.Fprintf(out, "  %d bytes of output added to context.\n", len(output))
	return nil
}

// taggedBlock is a fenced code block whose opening fence names a target file.
type taggedBlock struct {
	path    string
	content string
}

// findTaggedBlocks scans text for fenced code blocks whose opening fence line
// includes a token that looks like a file path, and returns each as a
// taggedBlock. Two formats are supported:
//
//   - Space-separated:  ```go harvey/spinner.go
//   - Colon-separated:  ```bash:testout/hello.bash
//
// In both cases the language hint is stripped and only the path is stored.
func findTaggedBlocks(text string) []taggedBlock {
	var blocks []taggedBlock
	lines := strings.Split(text, "\n")
	var cur *taggedBlock
	for _, line := range lines {
		if cur == nil {
			if strings.HasPrefix(line, "```") {
				fence := strings.TrimSpace(strings.TrimPrefix(line, "```"))
				path := fencePathToken(fence)
				if path != "" {
					cur = &taggedBlock{path: path}
				}
			}
		} else {
			if strings.HasPrefix(line, "```") {
				blocks = append(blocks, *cur)
				cur = nil
			} else {
				cur.content += line + "\n"
			}
		}
	}
	return blocks
}

// fencePathToken extracts a file path from a fenced-code-block opening line's
// content (the text after the triple backtick). It handles two conventions:
//
//   - "lang path"  (space-separated, e.g. "go harvey/spinner.go")
//   - "lang:path"  (colon-separated, e.g. "bash:testout/hello.bash")
//
// Returns the path token, or "" if none is found.
func fencePathToken(fence string) string {
	// Colon-separated: treat everything after the first colon as the path.
	if idx := strings.IndexByte(fence, ':'); idx >= 0 {
		candidate := fence[idx+1:]
		if looksLikePath(candidate) {
			return candidate
		}
	}
	// Space-separated: find the first token that looks like a path.
	for _, tok := range strings.Fields(fence) {
		if looksLikePath(tok) {
			return tok
		}
	}
	return ""
}

// looksLikePath reports whether s looks like a file path rather than a
// language identifier. A token is treated as a path if it contains a
// directory separator or ends with a recognised file extension.
// Extension recognition is delegated to the language registry so that adding
// new languages automatically extends this function.
func looksLikePath(s string) bool {
	if strings.Contains(s, "/") {
		return true
	}
	return registryHasExt(s)
}

// ─── /summarize ──────────────────────────────────────────────────────────────

// summarizePrompt is appended to the history when requesting a summary.
const summarizePrompt = "Please summarize this conversation concisely. Capture the key topics discussed, files mentioned, code changes proposed or made, and any open questions or next steps. This summary will replace the full conversation history to keep the context window manageable."

// cmdSummarize asks the connected LLM to condense the conversation history
// into a single summary message, then replaces the history with that summary.
func cmdSummarize(a *Agent, args []string, out io.Writer) error {
	if a.Client == nil {
		fmt.Fprintln(out, "No backend connected. Use /ollama start.")
		return nil
	}

	// Count non-system messages to decide if there's anything worth summarising.
	meaningful := 0
	for _, m := range a.History {
		if m.Role != "system" {
			meaningful++
		}
	}
	if meaningful < 2 {
		fmt.Fprintln(out, "Not enough conversation history to summarize.")
		return nil
	}

	request := append(append([]Message(nil), a.History...),
		Message{Role: "user", Content: summarizePrompt})

	fmt.Fprintln(out)
	var buf strings.Builder
	sp := newSpinner(out, 0, a.spinnerLabel())
	_, chatErr := a.Client.Chat(context.Background(), request, &buf)
	sp.stop()

	if chatErr != nil {
		return fmt.Errorf("summarize: %w", chatErr)
	}
	summary := strings.TrimSpace(buf.String())
	if summary == "" {
		fmt.Fprintln(out, "  Received empty summary — history unchanged.")
		return nil
	}

	// Replace history: system prompt + pinned context + summary.
	a.ClearHistory()
	a.AddMessage("user", "[Conversation summary]\n\n"+summary)
	fmt.Fprintf(out, "  History condensed to %d chars.\n", len(summary))
	return nil
}

// ─── /context ────────────────────────────────────────────────────────────────

// cmdContext manages the agent's PinnedContext: text that persists across
// /clear and is re-injected into history after the system prompt.
func cmdContext(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 || strings.ToLower(args[0]) == "show" {
		if a.PinnedContext == "" {
			fmt.Fprintln(out, "  (pinned context is empty)")
		} else {
			fmt.Fprintf(out, "  Pinned context (%d chars):\n\n%s\n", len(a.PinnedContext), a.PinnedContext)
		}
		return nil
	}

	switch strings.ToLower(args[0]) {
	case "clear":
		a.PinnedContext = ""
		// Remove any existing pinned context message from history.
		filtered := a.History[:0]
		for _, m := range a.History {
			if !(m.Role == "user" && strings.HasPrefix(m.Content, "[pinned context]")) {
				filtered = append(filtered, m)
			}
		}
		a.History = filtered
		fmt.Fprintln(out, "  Pinned context cleared.")

	case "add":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /context add TEXT...")
			return nil
		}
		text := strings.Join(args[1:], " ")
		if a.PinnedContext == "" {
			a.PinnedContext = text
		} else {
			a.PinnedContext += "\n" + text
		}
		// Update or insert the pinned context message in history.
		updated := false
		for i, m := range a.History {
			if m.Role == "user" && strings.HasPrefix(m.Content, "[pinned context]") {
				a.History[i].Content = "[pinned context]\n\n" + a.PinnedContext
				updated = true
				break
			}
		}
		if !updated {
			a.AddMessage("user", "[pinned context]\n\n"+a.PinnedContext)
		}
		fmt.Fprintf(out, "  Pinned context updated (%d chars).\n", len(a.PinnedContext))

	default:
		fmt.Fprintf(out, "Unknown context subcommand: %s\n", args[0])
		fmt.Fprintln(out, "Usage: /context <show|add TEXT...|clear>")
	}
	return nil
}

// ─── /session ────────────────────────────────────────────────────────────────

/** cmdSession manages Harvey session recordings.
 *
 * Subcommands:
 *   continue FILE     — load chat history from a .spmd/.fountain file and continue in REPL.
 *   replay FILE [OUT] — re-send turns from a session file to the current backend
 *                       and record fresh responses to OUT (default: auto-named in sessions dir).
 *
 * Parameters:
 *   a    (*Agent)    — the running agent.
 *   args ([]string)  — subcommand and its arguments.
 *   out  (io.Writer) — destination for command output.
 *
 * Returns:
 *   error — on I/O failure.
 *
 * Example:
 *   /session continue agents/sessions/harvey-session-20260430.spmd
 *   /session replay old.spmd new.spmd
 */
/** cmdSession handles session file operations for loading and replaying
 * recorded conversations. Session files use the Fountain screenplay format
 * (.spmd extension) and capture complete conversation history.
 *
 * Subcommands:
 *   continue FILE    — Load a session file's chat history and continue
 *   replay FILE [OUT] — Re-send all user prompts to current model, save to new file
 *
 * Continue mode loads the conversation history into the current session's
 * context, allowing you to pick up where you left off with full context intact.
 * The model used in the original session is automatically selected if available.
 *
 * Replay mode re-sends each user message from the source session to the
 * currently active LLM backend, capturing fresh responses in a new session file.
 * This is useful for comparing responses from different models or after
 * model updates. Tagged code blocks in replies are applied with backup
 * protection.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with workspace.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure (non-fatal errors are printed to out).
 */
func cmdSession(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /session <continue FILE|replay FILE [OUTPUT]>")
		return nil
	}
	switch strings.ToLower(args[0]) {
	case "continue":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /session continue FILE")
			return nil
		}
		n, err := a.ContinueFromFountain(args[1])
		if err != nil {
			fmt.Fprintf(out, "  ✗ %v\n", err)
			return nil
		}
		fmt.Fprintf(out, green("✓")+" Loaded %d turns from %s\n", n, args[1])
	case "replay":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /session replay FILE [OUTPUT]")
			return nil
		}
		src := args[1]
		outPath := ""
		if len(args) >= 3 {
			outPath = args[2]
		} else {
			outPath = DefaultSessionPath(a.SessionsDir)
		}
		if a.Client == nil {
			fmt.Fprintln(out, "  No backend connected. Use /ollama start.")
			return nil
		}
		return a.ReplayFromFountain(context.Background(), src, outPath, out)
	default:
		fmt.Fprintf(out, "Unknown session subcommand: %s\n", args[0])
		fmt.Fprintln(out, "Usage: /session <continue FILE|replay FILE [OUTPUT]>")
	}
	return nil
}

// ─── /skill ──────────────────────────────────────────────────────────────────

/** cmdSkill lists or loads Agent Skills from the catalog discovered at startup.
 *
 * Subcommands:
 *   list           — list all available skills with name and description.
 *   load NAME      — inject the full skill body into the conversation as context.
 *   info NAME      — show path, source, license, and compatibility for a skill.
 *   status         — show total skill count broken down by scope.
 *
 * Parameters:
 *   a    (*Agent)    — the running agent.
 *   args ([]string)  — subcommand and its arguments.
 *   out  (io.Writer) — destination for output.
 *
 * Returns:
 *   error — always nil (errors are reported inline).
 *
 * Example:
 *   /skill list
 *   /skill load go-review
 *   /skill info go-review
 */
/** cmdSkill handles Agent Skills management and execution. Skills are
 * structured tasks defined in SKILL.md files that can be loaded into
 * context, executed directly, or triggered automatically.
 *
 * Subcommands:
 *   list    — List all discovered skills in the skills directory
 *   load NAME — Load a skill's instructions into the conversation context
 *   info NAME — Show metadata (description, version, author, etc.) for a skill
 *   status  — Show skills directory paths and loaded skill status
 *   new     — Create a new skill interactively via the skill wizard
 *   run NAME — Execute a compiled skill directly
 *
 * Skills are discovered from the agents/skills/ directory tree on startup.
 * Each skill is defined in a SKILL.md file with YAML frontmatter containing
 * metadata (name, description, trigger, etc.) and Markdown body containing
 * instructions.
 *
 * Skills can be triggered automatically when a user's prompt matches the
 * skill's trigger pattern (see skill_dispatch.go for trigger matching logic).
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with skills catalog.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdSkill(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 || strings.ToLower(args[0]) == "list" {
		return skillList(a, out)
	}
	switch strings.ToLower(args[0]) {
	case "load":
		if len(args) < 2 {
			names := skillNameCandidates(a)
			if len(names) == 0 {
				fmt.Fprintln(out, "Usage: /skill load NAME")
				return nil
			}
			chosen, err := SelectFromStrings(names, fmt.Sprintf("Load which skill [1-%d] or Enter to cancel: ", len(names)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return skillLoad(a, args[1], out)
	case "info":
		if len(args) < 2 {
			names := skillNameCandidates(a)
			if len(names) == 0 {
				fmt.Fprintln(out, "Usage: /skill info NAME")
				return nil
			}
			chosen, err := SelectFromStrings(names, fmt.Sprintf("Info for which skill [1-%d] or Enter to cancel: ", len(names)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return skillInfo(a, args[1], out)
	case "status":
		return skillStatus(a, out)
	case "new":
		return skillNew(a, out)
	case "run":
		if len(args) < 2 {
			names := skillNameCandidates(a)
			if len(names) == 0 {
				fmt.Fprintln(out, "Usage: /skill run NAME")
				return nil
			}
			chosen, err := SelectFromStrings(names, fmt.Sprintf("Run which skill [1-%d] or Enter to cancel: ", len(names)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return skillRun(a, args[1], out)
	default:
		fmt.Fprintf(out, "Unknown skill subcommand: %s\n", args[0])
		fmt.Fprintln(out, "Usage: /skill <list|load NAME|info NAME|status|new|run NAME>")
	}
	return nil
}

func skillList(a *Agent, out io.Writer) error {
	if len(a.Skills) == 0 {
		fmt.Fprintln(out, "  No skills discovered. See /help skills for setup instructions.")
		return nil
	}
	names := make([]string, 0, len(a.Skills))
	for n := range a.Skills {
		names = append(names, n)
	}
	sort.Strings(names)

	model := "(no model)"
	if a.Client != nil {
		model = a.Client.Name()
	}

	fmt.Fprintln(out)
	fmt.Fprintf(out, "  Current model: %s\n", model)
	fmt.Fprintln(out)
	for _, n := range names {
		s := a.Skills[n]
		fmt.Fprintf(out, "  %-28s [%s]\n", n, s.Source)
		fmt.Fprintf(out, "    %s\n", s.Description)
		if s.Compatibility != "" {
			fmt.Fprintf(out, "    Compatibility: %s\n", s.Compatibility)
		}
	}
	fmt.Fprintln(out)
	fmt.Fprintln(out, "  Use /skill load NAME to activate a skill.")
	return nil
}

func skillLoad(a *Agent, name string, out io.Writer) error {
	if len(a.Skills) == 0 {
		fmt.Fprintln(out, "  No skills available. See /help skills for setup instructions.")
		return nil
	}
	skill, ok := a.Skills[name]
	if !ok {
		fmt.Fprintf(out, "  Skill %q not found. Use /skill list to see available skills.\n", name)
		return nil
	}
	if skill.Body == "" {
		fmt.Fprintf(out, "  Skill %q has no body content.\n", name)
		return nil
	}
	a.AddMessage("user", fmt.Sprintf("[skill: %s]\n\n%s", name, skill.Body))
	a.ActiveSkill = name
	if a.Recorder != nil {
		_ = a.Recorder.RecordSkillLoad(name, skill.Description, skill.Body)
	}
	fmt.Fprintf(out, "  ✓ Skill %q loaded into context (%d chars).\n", name, len(skill.Body))
	return nil
}

func skillInfo(a *Agent, name string, out io.Writer) error {
	if len(a.Skills) == 0 {
		fmt.Fprintln(out, "  No skills available.")
		return nil
	}
	skill, ok := a.Skills[name]
	if !ok {
		fmt.Fprintf(out, "  Skill %q not found. Use /skill list to see available skills.\n", name)
		return nil
	}
	fmt.Fprintf(out, "  Name:          %s\n", skill.Name)
	fmt.Fprintf(out, "  Description:   %s\n", skill.Description)
	fmt.Fprintf(out, "  Source:        %s\n", skill.Source)
	fmt.Fprintf(out, "  Path:          %s\n", skill.Path)
	if skill.License != "" {
		fmt.Fprintf(out, "  License:       %s\n", skill.License)
	}
	if skill.Compatibility != "" {
		fmt.Fprintf(out, "  Compatibility: %s\n", skill.Compatibility)
	}
	if len(skill.Metadata) > 0 {
		keys := make([]string, 0, len(skill.Metadata))
		for k := range skill.Metadata {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		fmt.Fprintln(out, "  Metadata:")
		for _, k := range keys {
			fmt.Fprintf(out, "    %s: %s\n", k, skill.Metadata[k])
		}
	}
	return nil
}

func skillStatus(a *Agent, out io.Writer) error {
	if len(a.Skills) == 0 {
		fmt.Fprintln(out, "  No skills discovered. See /help skills for setup instructions.")
		return nil
	}
	proj, user := 0, 0
	for _, s := range a.Skills {
		if s.Source == SkillSourceProject {
			proj++
		} else {
			user++
		}
	}
	fmt.Fprintf(out, "  Total: %d skill(s)\n", len(a.Skills))
	if proj > 0 {
		fmt.Fprintf(out, "    Project scope: %d\n", proj)
	}
	if user > 0 {
		fmt.Fprintf(out, "    User scope:    %d\n", user)
	}
	return nil
}

// skillNew runs the interactive skill wizard via /skill new.
func skillNew(a *Agent, out io.Writer) error {
	reader := bufio.NewReaderSize(a.In, 1)
	relPath, err := RunSkillWizard(a.Workspace, a.Config.AgentsDir, reader, out)
	if err != nil {
		return err
	}
	fmt.Fprintf(out, green("✓")+" Skill created: %s\n", relPath)
	fmt.Fprintln(out, "  To make it runnable, add compiled.bash / compiled.ps1 under scripts/.")
	a.Skills = ScanSkills(a.Workspace.Root, a.Config.AgentsDir)
	a.registerSkillCommands()
	return nil
}

// skillCompile compiles a named skill to compiled.bash and compiled.ps1.
func skillCompile(a *Agent, name string, out io.Writer) error {
	if a.Client == nil {
		fmt.Fprintln(out, "  No backend connected. Use /ollama start first.")
		return nil
	}
	skill, ok := a.Skills[name]
	if !ok {
		fmt.Fprintf(out, "  Skill %q not found. Use /skill list to see available skills.\n", name)
		return nil
	}
	fmt.Fprintf(out, "  Compiling skill %q...\n", name)
	sp := newSpinner(out, 0, a.spinnerLabel()+" · compiling")
	err := CompileSkill(context.Background(), a.Client, skill, io.Discard)
	sp.stop()
	if err != nil {
		return err
	}
	fmt.Fprintln(out, green("✓")+" Compiled: scripts/compiled.bash and scripts/compiled.ps1")
	a.Skills = ScanSkills(a.Workspace.Root, a.Config.AgentsDir)
	a.registerSkillCommands()
	fmt.Fprintf(out, "  Tip: you can now run it as /%s\n", name)
	return nil
}

// skillRun dispatches a named skill using DispatchSkill.
func skillRun(a *Agent, name string, out io.Writer) error {
	skill, ok := a.Skills[name]
	if !ok {
		fmt.Fprintf(out, "  Skill %q not found. Use /skill list to see available skills.\n", name)
		return nil
	}
	warnIfSkillStale(skill, out)
	reader := bufio.NewReaderSize(a.In, 1)
	_, err := DispatchSkill(context.Background(), a, skill, "", reader, out)
	return err
}

// warnIfSkillStale prints a warning when SKILL.md is newer than the compiled scripts.
func warnIfSkillStale(skill *SkillMeta, out io.Writer) {
	stale, err := IsStale(skill)
	if err == nil && stale {
		fmt.Fprintf(out, "  Warning: %s/SKILL.md has been updated since it was compiled.\n", skill.Name)
		fmt.Fprintln(out, "  Running the old compiled version. Recompile on a capable system to pick up changes.")
	}
}

// ─── /skill-set ──────────────────────────────────────────────────────────────

/** cmdSkillSet manages named YAML bundles of skills stored in
 * agents/skill-sets/. Subcommands: list, load, info, create, status, unload.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent.
 *   args ([]string)  — subcommand and optional name.
 *   out  (io.Writer) — destination for output.
 *
 * Returns:
 *   error — non-nil on unexpected I/O errors only; user errors are printed.
 *
 * Example:
 *   /skill-set load fountain
 */
func cmdSkillSet(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "No workspace initialised.")
		return nil
	}
	if len(args) == 0 || strings.ToLower(args[0]) == "list" {
		return skillSetList(a, out)
	}
	switch strings.ToLower(args[0]) {
	case "load":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /skill-set load NAME")
			return nil
		}
		return skillSetLoad(a, args[1], out)
	case "info":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /skill-set info NAME")
			return nil
		}
		return skillSetInfo(a, args[1], out)
	case "create":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /skill-set create NAME")
			return nil
		}
		return skillSetCreate(a, args[1], out)
	case "status":
		return skillSetStatus(a, out)
	case "unload":
		return skillSetUnload(a, out)
	default:
		fmt.Fprintf(out, "  Unknown subcommand %q. Usage: /skill-set <list|load NAME|info NAME|create NAME|status|unload>\n", args[0])
	}
	return nil
}

func skillSetList(a *Agent, out io.Writer) error {
	names, err := listSkillSetNames(a.Workspace)
	if err != nil {
		return err
	}
	if len(names) == 0 {
		fmt.Fprintln(out, "  No skill-sets found in agents/skill-sets/.")
		fmt.Fprintln(out, "  Create one with: /skill-set create NAME")
		return nil
	}
	fmt.Fprintln(out)
	for _, name := range names {
		path := filepath.Join(skillSetDir(a.Workspace), name+".yaml")
		ss, err := ParseSkillSet(path)
		if err != nil {
			fmt.Fprintf(out, "  %-24s  (parse error: %v)\n", name, err)
			continue
		}
		desc := ss.Description
		if len(desc) > 60 {
			desc = desc[:57] + "..."
		}
		active := ""
		if a.ActiveSkillSet == name {
			active = " ✓"
		}
		fmt.Fprintf(out, "  %-24s%s  %s\n", name, active, desc)
	}
	fmt.Fprintln(out)
	fmt.Fprintln(out, "  Use /skill-set load NAME to activate a bundle.")
	return nil
}

func skillSetLoad(a *Agent, name string, out io.Writer) error {
	if len(a.Skills) == 0 {
		fmt.Fprintln(out, "  No skills discovered. Run Harvey from a workspace with agents/skills/.")
		return nil
	}

	ss, err := findSkillSet(a.Workspace, name)
	if err != nil {
		fmt.Fprintf(out, "  %v\n", err)
		return nil
	}

	if err := validateSkillSet(ss, a.Skills); err != nil {
		fmt.Fprintf(out, "  %v\n", err)
		return nil
	}

	// Count tokens for the combined skill bodies.
	var combined strings.Builder
	for _, skillName := range ss.Skills {
		combined.WriteString(a.Skills[skillName].Body)
		combined.WriteString("\n")
	}
	ctx := context.Background()
	model := ""
	if a.Client != nil {
		model = a.Client.Name()
	}
	tokens, exact := CountTokens(ctx, a.Config.OllamaURL, model, combined.String())
	contextLimit := a.effectiveContextLimit()

	if contextLimit > 0 {
		pct := tokens * 100 / contextLimit
		switch {
		case pct >= 100:
			fmt.Fprintf(out, "  ✗ Skill-set %q would use ~%d tokens (%d%% of %d-token context) — too large to load.\n",
				ss.Name, tokens, pct, contextLimit)
			fmt.Fprintln(out, "  Use /skill-set info to review the bundle and reduce its skills.")
			return nil
		case pct >= 50:
			fmt.Fprintf(out, "  ⚠ Skill-set %q uses ~%d tokens (%d%% of %d-token context).\n",
				ss.Name, tokens, pct, contextLimit)
		}
	}

	// Load each skill in order.
	loaded := 0
	for _, skillName := range ss.Skills {
		if err := skillLoad(a, skillName, out); err != nil {
			fmt.Fprintf(out, "  ✗ %s: %v\n", skillName, err)
		} else {
			loaded++
		}
	}

	a.ActiveSkillSet = ss.Name
	a.ActiveSkill = "" // skill-set takes priority in the status line

	exactStr := "~"
	if exact {
		exactStr = ""
	}
	fmt.Fprintf(out, "  Skill-set %q loaded: %d/%d skills, %s%d tokens",
		ss.Name, loaded, len(ss.Skills), exactStr, tokens)
	if contextLimit > 0 {
		fmt.Fprintf(out, " (%d%% of context)", tokens*100/contextLimit)
	}
	fmt.Fprintln(out, ".")
	return nil
}

func skillSetInfo(a *Agent, name string, out io.Writer) error {
	ss, err := findSkillSet(a.Workspace, name)
	if err != nil {
		fmt.Fprintf(out, "  %v\n", err)
		return nil
	}
	fmt.Fprintln(out)
	fmt.Fprintf(out, "  Name:        %s\n", ss.Name)
	if ss.Description != "" {
		fmt.Fprintf(out, "  Description: %s\n", strings.ReplaceAll(strings.TrimRight(ss.Description, "\n"), "\n", "\n               "))
	}
	if len(ss.Metadata) > 0 {
		keys := make([]string, 0, len(ss.Metadata))
		for k := range ss.Metadata {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		for _, k := range keys {
			fmt.Fprintf(out, "  %-12s %s\n", k+":", ss.Metadata[k])
		}
	}
	fmt.Fprintf(out, "  Skills (%d):\n", len(ss.Skills))
	for _, skillName := range ss.Skills {
		status := "✓ found"
		desc := ""
		if a.Skills != nil {
			if meta, ok := a.Skills[skillName]; ok {
				d := meta.Description
				if len(d) > 60 {
					d = d[:57] + "..."
				}
				desc = " — " + d
			} else {
				status = "✗ not found"
			}
		}
		fmt.Fprintf(out, "    [%s] %s%s\n", status, skillName, desc)
	}
	fmt.Fprintln(out)
	return nil
}

func skillSetCreate(a *Agent, name string, out io.Writer) error {
	dir := skillSetDir(a.Workspace)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("skill-set create: %w", err)
	}
	path := filepath.Join(dir, name+".yaml")
	if _, err := os.Stat(path); err == nil {
		fmt.Fprintf(out, "  Skill-set %q already exists at %s\n", name, path)
		return nil
	}
	content := fmt.Sprintf(`name: %s
description: |
  Describe when to use this skill bundle.
skills:
  - skill-name-here
metadata:
  version: "1.0"
`, name)
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		return fmt.Errorf("skill-set create: %w", err)
	}
	fmt.Fprintf(out, "  Created %s\n", path)
	fmt.Fprintln(out, "  Edit the file to list the skills you want in this bundle.")
	return nil
}

func skillSetStatus(a *Agent, out io.Writer) error {
	if a.ActiveSkillSet == "" {
		fmt.Fprintln(out, "  No skill-set loaded.")
		if a.ActiveSkill != "" {
			fmt.Fprintf(out, "  Individual skill active: %s\n", a.ActiveSkill)
		}
		return nil
	}
	fmt.Fprintf(out, "  Active skill-set: %s\n", a.ActiveSkillSet)
	if ss, err := findSkillSet(a.Workspace, a.ActiveSkillSet); err == nil {
		for _, skillName := range ss.Skills {
			fmt.Fprintf(out, "    - %s\n", skillName)
		}
	}
	return nil
}

func skillSetUnload(a *Agent, out io.Writer) error {
	if a.ActiveSkillSet == "" && a.ActiveSkill == "" {
		fmt.Fprintln(out, "  No skill-set or skill currently active.")
		return nil
	}
	prev := a.ActiveSkillSet
	a.ActiveSkillSet = ""
	a.ActiveSkill = ""
	if prev != "" {
		fmt.Fprintf(out, "  Skill-set %q unloaded from status indicator.\n", prev)
	} else {
		fmt.Fprintln(out, "  Skill indicator cleared.")
	}
	fmt.Fprintln(out, "  Note: skill content already injected into context history remains.")
	fmt.Fprintln(out, "  Use /clear for a clean slate.")
	return nil
}

// cmdModelAlias manages the model_aliases map in harvey.yaml.
// Subcommands: list, set ALIAS FULLNAME, remove ALIAS
func cmdModelAlias(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 || args[0] == "list" {
		if len(a.Config.ModelAliases) == 0 {
			fmt.Fprintln(out, "  No model aliases defined.")
			fmt.Fprintln(out, "  Use: /ollama alias set ALIAS FULL_MODEL_NAME")
			return nil
		}
		// Collect and sort for deterministic output.
		aliases := make([]string, 0, len(a.Config.ModelAliases))
		for k := range a.Config.ModelAliases {
			aliases = append(aliases, k)
		}
		sortStrings(aliases)
		fmt.Fprintln(out, "  Model aliases:")
		for _, k := range aliases {
			fmt.Fprintf(out, "    %-20s → %s\n", k, a.Config.ModelAliases[k])
		}
		return nil
	}

	switch args[0] {
	case "set":
		if len(args) < 3 {
			fmt.Fprintln(out, "Usage: /ollama alias set ALIAS FULL_MODEL_NAME")
			return nil
		}
		alias := strings.ToLower(args[1])
		full := args[2]
		if a.Config.ModelAliases == nil {
			a.Config.ModelAliases = make(map[string]string)
		}
		// Reject if the alias name clashes with an installed model name.
		if aliasClashesWithModel(a, alias) {
			fmt.Fprintf(out, "  ✗ %q is already an installed model name — choose a different alias.\n", alias)
			return nil
		}
		// Warn if updating an existing alias.
		if existing, ok := a.Config.ModelAliases[alias]; ok && existing != full {
			fmt.Fprintf(out, "  ⚠ Updating alias %q: %s → %s\n", alias, existing, full)
		}
		a.Config.ModelAliases[alias] = full
		if err := SaveModelAliases(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  ✗ Failed to save: %v\n", err)
			return nil
		}
		fmt.Fprintf(out, "  Alias set: %s → %s\n", alias, full)

	case "remove", "rm", "delete":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /ollama alias remove ALIAS")
			return nil
		}
		alias := strings.ToLower(args[1])
		if _, ok := a.Config.ModelAliases[alias]; !ok {
			fmt.Fprintf(out, "  Alias %q not found.\n", alias)
			return nil
		}
		delete(a.Config.ModelAliases, alias)
		if err := SaveModelAliases(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "  ✗ Failed to save: %v\n", err)
			return nil
		}
		fmt.Fprintf(out, "  Alias %q removed.\n", alias)

	default:
		fmt.Fprintf(out, "  Unknown subcommand %q. Use: list, set, remove\n", args[0])
	}
	return nil
}

// aliasClashesWithModel reports whether name matches an installed Ollama model.
// It checks the model cache first (no network call); if the cache is empty it
// falls back to a live Ollama query. Returns false when neither source is
// available so that a downed Ollama server never blocks alias creation.
func aliasClashesWithModel(a *Agent, name string) bool {
	// Check model cache first.
	if a.ModelCache != nil {
		if caps, err := a.ModelCache.All(); err == nil && len(caps) > 0 {
			for _, cap := range caps {
				if strings.EqualFold(cap.Name, name) {
					return true
				}
			}
			return false
		}
	}
	// Fall back to live Ollama list.
	if !ProbeOllama(a.Config.OllamaURL) {
		return false
	}
	models, err := newOllamaLLMClient(a.Config.OllamaURL, "", a.Config.OllamaTimeout).
		Models(context.Background())
	if err != nil {
		return false
	}
	for _, m := range models {
		if strings.EqualFold(m, name) {
			return true
		}
	}
	return false
}

// modelSwitch changes the active model. An "ollama://" prefix forces the Ollama
// backend. Without a prefix the model is looked up in Ollama; if found it is
// activated, otherwise the user is told it was not found.
func modelSwitch(a *Agent, name string, out io.Writer) error {
	ctx := context.Background()

	const ollamaPrefix = "ollama://"

	if strings.HasPrefix(name, ollamaPrefix) {
		name = strings.TrimPrefix(name, ollamaPrefix)
	}

	// Resolve alias → full model name before the Ollama lookup.
	if resolved := a.Config.ResolveModelAlias(name); resolved != name {
		fmt.Fprintf(out, "  Resolving alias %q → %s\n", name, resolved)
		name = resolved
	}

	if !ProbeOllama(a.Config.OllamaURL) {
		fmt.Fprintln(out, "  Ollama is not running. Use /ollama start first.")
		return nil
	}

	models, err := newOllamaLLMClient(a.Config.OllamaURL, "", a.Config.OllamaTimeout).Models(ctx)
	if err != nil {
		return fmt.Errorf("listing models: %w", err)
	}
	for _, m := range models {
		if m == name {
			a.Config.OllamaModel = name
			a.Client = newOllamaLLMClient(a.Config.OllamaURL, name, a.Config.OllamaTimeout)
			fmt.Fprintf(out, "  Switched to model: %s\n", name)
			return nil
		}
	}
	fmt.Fprintf(out, "  Model %q not found in Ollama.\n", name)
	fmt.Fprintln(out, "  Use /ollama list to see available models.")
	return nil
}

// ─── auto-execute ─────────────────────────────────────────────────────────────

// actionChoice represents the user's decision at an action confirmation prompt.
type actionChoice int

const (
	actionYes  actionChoice = iota // execute this action
	actionNo                       // skip this action
	actionAll                      // execute this and all remaining actions without prompting
	actionQuit                     // skip this and all remaining actions
)

// promptAction displays a box-drawing preview of a proposed action and reads
// the user's choice. Returns actionYes for empty input (Enter = yes).
//
// Parameters:
//
//	r       (*bufio.Reader) — reads the user's single-key response.
//	out     (io.Writer)     — destination for the preview box.
//	header  (string)        — short label shown in the box title (e.g. "Write: path/to/file").
//	preview (string)        — content preview shown inside the box; empty = no body.
//
// Returns:
//
//	actionChoice — the user's decision.
func promptAction(r *bufio.Reader, out io.Writer, header, preview string) actionChoice {
	const boxWidth = 56
	const maxPreviewLines = 8

	// Top border
	title := "  ┌─ " + header + " "
	pad := boxWidth - len(title) + 2
	if pad < 1 {
		pad = 1
	}
	fmt.Fprint(out, title+strings.Repeat("─", pad)+"┐\n")

	// Preview lines
	if preview != "" {
		lines := strings.Split(strings.TrimRight(preview, "\n"), "\n")
		for i, line := range lines {
			if i >= maxPreviewLines {
				fmt.Fprintf(out, "  │  %s… (%d more lines)\n", "", len(lines)-maxPreviewLines)
				break
			}
			fmt.Fprintf(out, "  │  %s\n", line)
		}
	}

	// Bottom border + prompt
	fmt.Fprintf(out, "  └%s┘\n", strings.Repeat("─", boxWidth-1))
	fmt.Fprint(out, "  [y]es  [n]o  [A]ll  [q]uit > ")

	line, _ := r.ReadString('\n')
	switch strings.ToLower(strings.TrimSpace(line)) {
	case "n", "no":
		return actionNo
	case "a", "all":
		return actionAll
	case "q", "quit":
		return actionQuit
	default: // "", "y", "yes" — Enter defaults to yes
		return actionYes
	}
}

/** autoExecuteReply scans reply for tagged code blocks, previews each with an
 * interactive confirmation prompt, writes confirmed blocks, and records the
 * full proposal/choice/outcome flow to the Recorder (if active).
 *
 * Parameters:
 *   reply  (string)          — the raw assistant reply text.
 *   out    (io.Writer)       — destination for status messages.
 *   reader (*bufio.Reader)   — reads confirmation keystrokes from the user.
 *   ctx    (context.Context) — used to cancel long-running commands.
 *
 * Example:
 *   agent.autoExecuteReply(replyText, os.Stdout, reader, ctx)
 */
func (a *Agent) autoExecuteReply(reply string, out io.Writer, reader *bufio.Reader, ctx context.Context) {
	if a.Workspace == nil {
		return
	}

	blocks := findTaggedBlocks(reply)

	// Open an agent scene in the recording if there is anything to act on.
	if len(blocks) > 0 && a.Recorder != nil {
		desc := fmt.Sprintf("Harvey proposes to write %d file(s).", len(blocks))
		_ = a.Recorder.StartAgentScene(desc)
	}

	applyAll := false

	// 1. Tagged code blocks — always offer to apply.
	for _, b := range blocks {
		choice := actionYes
		if !applyAll {
			choice = promptAction(reader, out, "Write: "+b.path, b.content)
		}
		switch choice {
		case actionNo:
			fmt.Fprintf(out, "  skipped %s\n", b.path)
			a.logAction("write", b.path, choice, "skipped")
			continue
		case actionQuit:
			fmt.Fprintln(out, "  aborted remaining actions.")
			a.logAction("write", b.path, choice, "aborted")
			return
		case actionAll:
			applyAll = true
		}
		if err := a.Workspace.WriteFile(b.path, []byte(b.content), 0o644); err != nil {
			fmt.Fprintf(out, "  ✗ %s: %v\n", b.path, err)
			a.logAction("write", b.path, choice, "error: "+err.Error())
		} else {
			fmt.Fprintf(out, "  ✓ wrote %s (%d bytes)\n", b.path, len(b.content))
			a.logAction("write", b.path, choice, "ok")
		}
	}

	// 2. Fallback for models that ignore the tagged-fence convention: if no
	// tagged blocks were found but the reply contains a plain fenced code block,
	// offer the user an interactive write prompt.
	if len(blocks) == 0 {
		content, ok := extractCodeBlock(reply)
		if ok {
			var dest string
			if suggested := suggestPathFromHistory(a.History); suggested != "" {
				// Path inferred from conversation — show the promptAction box
				// (same UX as tagged blocks: Enter = yes, n = skip).
				choice := promptAction(reader, out, "Write: "+suggested, content)
				if choice != actionNo && choice != actionQuit {
					dest = suggested
				}
			} else {
				// No path known — ask the user to supply one.
				fmt.Fprint(out, "  Untagged code block found. Write to file? (enter path, or press Enter to skip)\n  Path: ")
				line, _ := reader.ReadString('\n')
				dest = strings.TrimSpace(line)
			}
			if dest != "" {
				if !a.CheckWritePermission(dest) {
					if a.AuditBuffer != nil {
						a.AuditBuffer.Log(ActionFileWrite, dest, StatusDenied)
					}
					fmt.Fprintf(out, "  write permission denied for %s\n", dest)
				} else if err := a.Workspace.WriteFile(dest, []byte(content), 0o644); err != nil {
					if a.AuditBuffer != nil {
						a.AuditBuffer.Log(ActionFileWrite, dest, StatusError)
					}
					fmt.Fprintf(out, "  ✗ %s: %v\n", dest, err)
					a.logAction("write", dest, actionYes, "error: "+err.Error())
				} else {
					if a.AuditBuffer != nil {
						a.AuditBuffer.Log(ActionFileWrite, dest, StatusSuccess)
					}
					fmt.Fprintf(out, "  ✓ wrote %s (%d bytes)\n", dest, len(content))
					a.logAction("write", dest, actionYes, "ok")
				}
			}
		}
	}

}

// choiceStr converts an actionChoice to the string recorded in the script.
func choiceStr(c actionChoice) string {
	switch c {
	case actionNo:
		return "no"
	case actionAll:
		return "all"
	case actionQuit:
		return "quit"
	default:
		return "yes"
	}
}

// logAction records one agent action to the Recorder if one is active.
func (a *Agent) logAction(kind, target string, choice actionChoice, outcome string) {
	if a.Recorder != nil {
		_ = a.Recorder.RecordAgentAction(kind, target, choiceStr(choice), outcome)
	}
}

// ─── /rag ────────────────────────────────────────────────────────────────────

/** cmdRag handles Retrieval-Augmented Generation (RAG) store management and
 * context injection. RAG allows Harvey to retrieve relevant document snippets
 * and inject them into the conversation context before each prompt.
 *
 * Subcommands:
 *   status    — Show active store and all registered stores
 *   list      — List all registered RAG stores
 *   on        — Enable RAG context injection for current session
 *   off       — Disable RAG context injection for current session
 *   setup     — Create a new RAG store (interactive or with defaults)
 *   new       — Create a named RAG store with interactive setup
 *   use       — Activate a different RAG store
 *   drop      — Remove a store from the registry
 *   ingest    — Ingest files/directories into the active store
 *   query     — Query the active store and show matching chunks
 *
 * RAG stores are SQLite databases bound to a specific embedding model.
 * Only the active store is kept open in memory. Each store can be queried
 * independently and switched as needed for different projects or domains.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent with RAG configuration.
 *   args ([]string)  — Command arguments from user input.
 *   out  (io.Writer) — Destination for command output.
 *
 * Returns:
 *   error — On command execution failure.
 */
func cmdRag(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		return ragStatus(a, out)
	}
	switch strings.ToLower(args[0]) {
	case "status":
		return ragStatus(a, out)
	case "list":
		return ragList(a, out)
	case "on":
		if a.Rag == nil {
			fmt.Fprintln(out, "RAG is not configured. Run /rag new NAME first.")
			return nil
		}
		a.RagOn = true
		a.Config.Memory.RagEnabled = true
		fmt.Fprintln(out, "RAG context injection: on")
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "Warning: could not save config: %v\n", err)
		}
	case "off":
		a.RagOn = false
		a.Config.Memory.RagEnabled = false
		fmt.Fprintln(out, "RAG context injection: off")
		if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
			fmt.Fprintf(out, "Warning: could not save config: %v\n", err)
		}
	case "new":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /rag new NAME [--embedder ollama|encoderfile] [--embedder-url URL]")
			return nil
		}
		kind, url := ParseEmbedderFlags(args[2:])
		return ragWizard(a, args[1], kind, url, out)
	case "use":
		if len(args) < 2 {
			items := ragStoreSelectItems(a)
			if len(items) == 0 {
				fmt.Fprintln(out, "No RAG stores registered. Run /rag new NAME to create one.")
				return nil
			}
			chosen, err := SelectFrom(items, fmt.Sprintf("Select store [1-%d] or Enter to cancel: ", len(items)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return ragSwitch(a, args[1], out)
	case "drop":
		if len(args) < 2 {
			items := ragStoreSelectItems(a)
			if len(items) == 0 {
				fmt.Fprintln(out, "No RAG stores registered.")
				return nil
			}
			chosen, err := SelectFrom(items, fmt.Sprintf("Drop which store [1-%d] or Enter to cancel: ", len(items)), a.In, out)
			if err != nil || chosen == "" {
				return err
			}
			args = append(args, chosen)
		}
		return ragDrop(a, args[1], out)
	case "ingest":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /rag ingest PATH [PATH...]")
			return nil
		}
		return ragIngest(a, args[1:], out)
	case "query":
		if len(args) < 2 {
			fmt.Fprintln(out, "Usage: /rag query TEXT")
			return nil
		}
		return ragQuery(a, strings.Join(args[1:], " "), out)
	default:
		fmt.Fprintf(out, "Unknown rag subcommand: %s\n", args[0])
	}
	return nil
}

// ragStatus prints the active store details and the full store registry.
func ragStatus(a *Agent, out io.Writer) error {
	enabled := "off"
	if a.RagOn {
		enabled = "on"
	}
	fmt.Fprintf(out, "RAG context injection: %s\n", enabled)

	entry := a.Config.Memory.ActiveRagStore()
	if entry == nil {
		fmt.Fprintln(out, "No store configured. Run /rag new NAME to get started.")
		return nil
	}

	fmt.Fprintf(out, "Active store:    %s\n", entry.Name)
	fmt.Fprintf(out, "  Database:      %s\n", entry.DBPath)
	fmt.Fprintf(out, "  Embed model:   %s\n", entry.EmbeddingModel)
	if entry.EmbedderKind == "encoderfile" {
		fmt.Fprintf(out, "  Embedder:      encoderfile (%s)\n", entry.EmbedderURL)
	}
	if a.Rag != nil {
		if n, err := a.Rag.Count(); err == nil {
			fmt.Fprintf(out, "  Chunks:        %d\n", n)
		}
	} else {
		fmt.Fprintln(out, "  (store not open)")
	}
	if len(entry.ModelMap) > 0 {
		fmt.Fprintln(out, "  Model map:")
		for gen, emb := range entry.ModelMap {
			fmt.Fprintf(out, "    %-36s → %s\n", gen, emb)
		}
	}

	if len(a.Config.Memory.RagStores) > 1 {
		fmt.Fprintf(out, "\nAll stores (%d):\n", len(a.Config.Memory.RagStores))
		for _, e := range a.Config.Memory.RagStores {
			marker := "  "
			if e.Name == a.Config.Memory.RagActive {
				marker = "* "
			}
			fmt.Fprintf(out, "  %s%-16s %s  (%s)\n", marker, e.Name, e.DBPath, e.EmbeddingModel)
		}
	}
	return nil
}

// ragList prints a brief listing of all registered stores.
func ragList(a *Agent, out io.Writer) error {
	if len(a.Config.Memory.RagStores) == 0 {
		fmt.Fprintln(out, "No RAG stores registered. Run /rag new NAME to create one.")
		return nil
	}
	fmt.Fprintf(out, "RAG stores (%d):\n", len(a.Config.Memory.RagStores))
	for _, e := range a.Config.Memory.RagStores {
		marker := "  "
		if e.Name == a.Config.Memory.RagActive {
			marker = "* "
		}
		fmt.Fprintf(out, "  %s%-16s %s  (%s)\n", marker, e.Name, e.DBPath, e.EmbeddingModel)
	}
	return nil
}

// ragSwitch closes the current store and opens the named one.
func ragSwitch(a *Agent, name string, out io.Writer) error {
	entry := a.Config.Memory.RagStoreByName(name)
	if entry == nil {
		fmt.Fprintf(out, "Store %q not found. Use /rag list to see available stores.\n", name)
		return nil
	}
	if a.Rag != nil {
		_ = a.Rag.Close()
		a.Rag = nil
	}
	dbPath, err := a.Workspace.AbsPath(entry.DBPath)
	if err != nil {
		return fmt.Errorf("rag use: %w", err)
	}
	store, err := NewRagStore(dbPath, entry.EmbeddingModel)
	if err != nil {
		return fmt.Errorf("rag use: open store: %w", err)
	}
	a.Rag = store
	a.Config.Memory.RagActive = name
	if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
		fmt.Fprintf(out, "Warning: could not persist active store: %v\n", err)
	}
	fmt.Fprintf(out, "Active store: %s (%s)\n", entry.Name, entry.DBPath)
	return nil
}

// ragDrop removes a store from the registry (does not delete the .db file).
func ragDrop(a *Agent, name string, out io.Writer) error {
	entry := a.Config.Memory.RagStoreByName(name)
	if entry == nil {
		fmt.Fprintf(out, "Store %q not found.\n", name)
		return nil
	}
	fmt.Fprintf(out, "Remove store %q from registry? The .db file will NOT be deleted.\n", name)
	fmt.Fprintf(out, "  Database: %s\n", entry.DBPath)
	fmt.Fprint(out, "Confirm? [y/N] ")
	scanner := bufio.NewScanner(a.In)
	scanner.Scan()
	if answer := strings.ToLower(strings.TrimSpace(scanner.Text())); answer != "y" && answer != "yes" {
		fmt.Fprintln(out, "Cancelled.")
		return nil
	}
	if name == a.Config.Memory.RagActive {
		if a.Rag != nil {
			_ = a.Rag.Close()
			a.Rag = nil
		}
		a.RagOn = false
		a.Config.Memory.RagActive = ""
	}
	a.Config.Memory.RemoveRagStore(name)
	if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
		fmt.Fprintf(out, "Warning: could not persist registry: %v\n", err)
	}
	fmt.Fprintf(out, "Store %q removed. To delete the database: rm %s\n", name, entry.DBPath)
	return nil
}

/** ParseEmbedderFlags extracts --embedder and --embedder-url values from args.
 * Unrecognised tokens are silently ignored. Both values default to "".
 *
 * Parameters:
 *   args ([]string) — remaining arguments after the store name.
 *
 * Returns:
 *   kind (string) — embedder kind: "ollama", "encoderfile", or "".
 *   url  (string) — embedder base URL, or "".
 *
 * Example:
 *   kind, url := ParseEmbedderFlags([]string{"--embedder", "encoderfile", "--embedder-url", "http://localhost:8080"})
 */
func ParseEmbedderFlags(args []string) (kind, url string) {
	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--embedder":
			if i+1 < len(args) {
				i++
				kind = args[i]
			}
		case "--embedder-url":
			if i+1 < len(args) {
				i++
				url = args[i]
			}
		}
	}
	return kind, url
}

// ragWizard runs the interactive setup wizard for a named store, creating or
// reconfiguring it in the registry. embedderKind and embedderURL select the
// embedder backend: "" or "ollama" uses Ollama; "encoderfile" uses an
// Encoderfile binary server at embedderURL.
func ragWizard(a *Agent, name, embedderKind, embedderURL string, out io.Writer) error {
	ctx := context.Background()
	ragDir := filepath.Join(harveySubdir, "rag")
	dbPath := filepath.Join(ragDir, name+".db")

	// ── Encoderfile path ───────────────────────────────────────────────────────
	if embedderKind == "encoderfile" {
		if embedderURL == "" {
			fmt.Fprintln(out, "Encoderfile requires --embedder-url, e.g. --embedder-url http://localhost:8080")
			return nil
		}
		if !ProbeEncoderfile(embedderURL) {
			fmt.Fprintf(out, "Encoderfile server not reachable at %s\n", embedderURL)
			fmt.Fprintln(out, "Start the server: ./your-model.encoderfile serve")
			return nil
		}
		modelID, err := ProbeEncoderfileModel(embedderURL)
		if err != nil {
			return fmt.Errorf("rag wizard: %w", err)
		}
		fmt.Fprintf(out, "Proposed RAG store %q (Encoderfile embedder: %s):\n\n", name, modelID)
		fmt.Fprintf(out, "  Database:      %s\n", dbPath)
		fmt.Fprintf(out, "  Embedder URL:  %s\n", embedderURL)
		fmt.Fprintf(out, "  Model ID:      %s\n\n", modelID)
		fmt.Fprint(out, "Accept? [Y/n] ")

		scanner := bufio.NewScanner(a.In)
		scanner.Scan()
		answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
		if answer != "" && answer != "y" && answer != "yes" {
			fmt.Fprintln(out, "Setup cancelled.")
			return nil
		}
		if err := a.Workspace.MkdirAll(ragDir); err != nil {
			return fmt.Errorf("rag setup: create directory: %w", err)
		}
		entry := RagStoreEntry{
			Name:           name,
			DBPath:         dbPath,
			EmbeddingModel: modelID,
			EmbedderKind:   "encoderfile",
			EmbedderURL:    embedderURL,
		}
		return ragCommitEntry(a, entry, out)
	}

	// ── Ollama path ────────────────────────────────────────────────────────────
	if !ProbeOllama(a.Config.OllamaURL) {
		fmt.Fprintln(out, "Ollama is not running. Use /ollama start first.")
		return nil
	}

	// Step 0: detect available embedding models via the model cache.
	var embedModels []string
	if a.ModelCache != nil {
		all, err := a.ModelCache.All()
		if err == nil {
			for _, c := range all {
				if c.SupportsEmbed == CapYes {
					embedModels = append(embedModels, c.Name)
				}
			}
		}
	}

	// If cache is empty or no embedding models found, fall back to live detection.
	if len(embedModels) == 0 {
		summaries, err := NewOllamaClient(a.Config.OllamaURL, "").ModelSummaries(ctx)
		if err == nil {
			for _, s := range summaries {
				if hasEmbedKeyword(s.Name) {
					embedModels = append(embedModels, s.Name)
				}
			}
		}
	}

	if len(embedModels) == 0 {
		fmt.Fprintln(out, "No embedding models found on this Ollama server.")
		fmt.Fprintln(out, "")
		fmt.Fprintln(out, "Recommended options (run /ollama pull to install):")
		fmt.Fprintln(out, "  nomic-embed-text        (~274 MB) — best general-purpose retrieval")
		fmt.Fprintln(out, "  mxbai-embed-large       (~670 MB) — high quality retrieval")
		fmt.Fprintln(out, "  qllama/bge-small-en-v1.5 (~46 MB) — small but retrieval-optimized")
		fmt.Fprintln(out, "  bge-m3                  (~1.2 GB) — multilingual (good for SEA-LION)")
		fmt.Fprintln(out, "  (avoid all-minilm — it is similarity-tuned, not retrieval-tuned)")
		fmt.Fprintln(out, "")
		fmt.Fprintln(out, "After pulling an embedding model, run /rag new NAME again.")
		fmt.Fprintln(out, "Or use an Encoderfile binary: /rag new NAME --embedder encoderfile --embedder-url URL")
		return nil
	}

	// Pick preferred embedding model in quality order; all are retrieval-optimized
	// except all-minilm (similarity-only) which is the last resort.
	preferred := embedModels[0]
	for _, pref := range []string{"nomic-embed-text", "mxbai-embed-large", "bge-m3", "bge-", "gte-", "e5-", "jina", "all-minilm"} {
		for _, m := range embedModels {
			if strings.Contains(strings.ToLower(m), pref) {
				preferred = m
				goto foundPref
			}
		}
	}
foundPref:

	// Build proposed model map: all non-embedding generation models → preferred embedder.
	genModels, _ := newOllamaLLMClient(a.Config.OllamaURL, "", a.Config.OllamaTimeout).Models(ctx)
	proposed := make(map[string]string)
	for _, m := range genModels {
		if !hasEmbedKeyword(m) {
			embedFor := preferred
			// Multilingual hint: suggest bge-m3 for models with multilingual signals.
			lower := strings.ToLower(m)
			if strings.Contains(lower, "sea") || strings.Contains(lower, "lion") ||
				strings.Contains(lower, "multilingual") || strings.Contains(lower, "multi") {
				for _, em := range embedModels {
					if strings.Contains(strings.ToLower(em), "bge-m3") {
						embedFor = em
						break
					}
				}
			}
			proposed[m] = embedFor
		}
	}

	// Display proposed mapping for human review.
	fmt.Fprintf(out, "Proposed RAG store %q (embedding model: %s):\n\n", name, preferred)
	fmt.Fprintf(out, "  Database: %s\n\n", dbPath)
	if len(proposed) > 0 {
		fmt.Fprintf(out, "  %-36s  %s\n", "Generation model", "Embedding model")
		fmt.Fprintf(out, "  %s  %s\n", strings.Repeat("─", 36), strings.Repeat("─", 24))
		for gen, emb := range proposed {
			fmt.Fprintf(out, "  %-36s  %s\n", ollamaTruncateName(gen, 36), emb)
		}
	}
	fmt.Fprintln(out, "")
	fmt.Fprint(out, "Accept? [Y/n] ")

	scanner := bufio.NewScanner(a.In)
	scanner.Scan()
	answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
	if answer != "" && answer != "y" && answer != "yes" {
		fmt.Fprintln(out, "Setup cancelled.")
		return nil
	}

	// Ensure the rag/ subdirectory exists.
	if err := a.Workspace.MkdirAll(ragDir); err != nil {
		return fmt.Errorf("rag setup: create directory: %w", err)
	}

	entry := RagStoreEntry{
		Name:           name,
		DBPath:         dbPath,
		EmbeddingModel: preferred,
		ModelMap:       proposed,
	}
	return ragCommitEntry(a, entry, out)
}

// ragCommitEntry persists entry as the active RAG store, opens its database,
// and enables RAG injection. It is called by both the Ollama and Encoderfile
// wizard paths.
func ragCommitEntry(a *Agent, entry RagStoreEntry, out io.Writer) error {
	a.Config.Memory.AddOrUpdateRagStore(entry)
	a.Config.Memory.RagActive = entry.Name
	a.Config.Memory.RagEnabled = true

	if err := SaveMemoryConfig(a.Workspace, a.Config); err != nil {
		return fmt.Errorf("rag setup: save config: %w", err)
	}

	// Close any previously open store, then open the new one.
	if a.Rag != nil {
		_ = a.Rag.Close()
		a.Rag = nil
	}
	absDB, err := a.Workspace.AbsPath(entry.DBPath)
	if err != nil {
		return err
	}
	store, err := NewRagStore(absDB, entry.EmbeddingModel)
	if err != nil {
		return fmt.Errorf("rag setup: open store: %w", err)
	}
	a.Rag = store
	a.RagOn = true

	fmt.Fprintf(out, "RAG store %q configured and enabled.\n", entry.Name)
	fmt.Fprintf(out, "Next step: run /rag ingest <file-or-directory> to populate the store.\n")
	return nil
}

// ragIngest chunks and embeds each path into the RAG store.
// ragLargeFileThreshold is the file size above which /rag ingest shows the
// document list and asks for confirmation before starting.
const ragLargeFileThreshold = 1000 * 1024 // 1000 KB

// ragIngestableExts is the set of file extensions eligible for RAG ingestion.
var ragIngestableExts = map[string]bool{
	".md": true, ".txt": true, ".go": true, ".ts": true, ".js": true, ".css": true, ".py": true,
	".Mod": true, ".obn": true, ".pas": true, ".lisp": true, ".bas": true, ".c": true, ".cpp": true,
	".rs": true, ".yaml": true, ".yml": true, ".toml": true, ".sql": true, ".pdf": true,
}

// ragCollectFiles expands a list of paths (files and directories) into the
// ordered list of absolute file paths that ragIngest would process.
func ragCollectFiles(paths []string, absPathFn func(string) (string, error)) ([]string, error) {
	var files []string
	for _, p := range paths {
		abs, err := absPathFn(p)
		if err != nil {
			return nil, fmt.Errorf("collect %s: %w", p, err)
		}
		info, err := os.Stat(abs)
		if err != nil {
			return nil, fmt.Errorf("collect %s: %w", p, err)
		}
		if info.IsDir() {
			err := filepath.WalkDir(abs, func(path string, d fs.DirEntry, werr error) error {
				if werr != nil || d.IsDir() {
					return werr
				}
				if d.Type()&fs.ModeSymlink != 0 {
					return nil
				}
				if ragIngestableExts[strings.ToLower(filepath.Ext(path))] {
					files = append(files, path)
				}
				return nil
			})
			if err != nil {
				return nil, err
			}
		} else if ragIngestableExts[strings.ToLower(filepath.Ext(abs))] {
			files = append(files, abs)
		}
	}
	return files, nil
}

// ragCountLarge returns the number of files whose size exceeds ragLargeFileThreshold.
func ragCountLarge(files []string) int {
	n := 0
	for _, f := range files {
		info, err := os.Stat(f)
		if err == nil && info.Size() > ragLargeFileThreshold {
			n++
		}
	}
	return n
}

func ragIngest(a *Agent, paths []string, out io.Writer) error {
	if a.Rag == nil {
		fmt.Fprintln(out, "RAG is not configured. Run /rag new NAME first.")
		return nil
	}
	entry := a.Config.Memory.ActiveRagStore()
	if entry == nil {
		fmt.Fprintln(out, "No active RAG store. Run /rag use NAME to select one.")
		return nil
	}
	embedder := NewEmbedderForEntry(entry, a.Config.OllamaURL)

	// Separate remote URIs from local paths. Remote S3 prefixes are ingested
	// directly (download → ingest → remove per object) without the large-file
	// confirmation flow, since the user explicitly addressed them by URI.
	var localPaths []string
	for _, p := range paths {
		if parseURIScheme(p) == "s3" {
			ragIngestS3Prefix(a, p, embedder, out)
		} else if parseURIScheme(p) != "" {
			fmt.Fprintf(out, "  ⚠ remote ingest only supports s3:// URIs, skipping %s\n", p)
		} else {
			localPaths = append(localPaths, p)
		}
	}
	if len(localPaths) == 0 {
		return nil
	}

	// Collect all candidate files across all given local paths.
	files, err := ragCollectFiles(localPaths, a.Workspace.AbsPath)
	if err != nil {
		fmt.Fprintf(out, "  error collecting files: %v\n", err)
		return nil
	}
	if len(files) == 0 {
		fmt.Fprintln(out, "No ingestable files found.")
		return nil
	}

	// When there are multiple files or any file is large, show the list and
	// ask for confirmation before starting the embedding work.
	largeCount := ragCountLarge(files)
	if len(files) > 1 || largeCount > 0 {
		fmt.Fprintf(out, "Files to ingest (%d):\n", len(files))
		for _, f := range files {
			info, err := os.Stat(f)
			sizeNote := ""
			if err == nil && info.Size() > ragLargeFileThreshold {
				sizeNote = fmt.Sprintf("  [%.0f KB]", float64(info.Size())/1024)
			}
			fmt.Fprintf(out, "  %s%s\n", f, sizeNote)
		}
		if largeCount > 0 {
			fmt.Fprintf(out, "\nNote: %d file(s) exceed 100 KB and may take longer to embed.\n", largeCount)
		}
		fmt.Fprint(out, "Proceed? [y/N] ")
		scanner := bufio.NewScanner(a.In)
		scanner.Scan()
		if answer := strings.ToLower(strings.TrimSpace(scanner.Text())); answer != "y" && answer != "yes" {
			fmt.Fprintln(out, "Cancelled.")
			return nil
		}
	}

	// Ingest each file individually, reporting progress.
	var total int
	for i, absFile := range files {
		fmt.Fprintf(out, "  [%d/%d] %s", i+1, len(files), absFile)
		if strings.ToLower(filepath.Ext(absFile)) == ".pdf" {
			n, diagrams, err := ragIngestPDF(a.Rag, embedder, absFile)
			if err != nil {
				fmt.Fprintf(out, " — error: %v\n", err)
			} else {
				fmt.Fprintf(out, " — %d chunk(s)", n)
				if len(diagrams) > 0 {
					fmt.Fprintf(out, " (%d diagram-only page(s) flagged)", len(diagrams))
				}
				fmt.Fprintln(out)
				total += n
			}
		} else {
			n, err := ragIngestFile(a.Rag, embedder, absFile)
			if err != nil {
				fmt.Fprintf(out, " — error: %v\n", err)
			} else {
				fmt.Fprintf(out, " — %d chunk(s)\n", n)
				total += n
			}
		}
	}
	fmt.Fprintf(out, "Ingested %d chunk(s) total from %d file(s).\n", total, len(files))
	return nil
}

// ragIngestS3Prefix lists all ingestable objects under an S3 prefix URI and
// ingests each one by downloading to a temp file, ingesting, and removing the
// temp file immediately. This keeps peak disk usage bounded to a single object.
func ragIngestS3Prefix(a *Agent, uri string, embedder Embedder, out io.Writer) {
	s3r, err := newS3Reader()
	if err != nil {
		fmt.Fprintf(out, "  ✗ %s: %v\n", uri, err)
		return
	}
	objects, err := s3r.List(context.Background(), uri)
	if err != nil {
		fmt.Fprintf(out, "  ✗ list %s: %v\n", uri, err)
		return
	}
	var ingested int
	for _, obj := range objects {
		if obj.IsDir {
			continue
		}
		ext := strings.ToLower(filepath.Ext(obj.URI))
		if !ragIngestableExts[ext] {
			continue
		}
		f, err := os.CreateTemp("", "harvey-s3-*"+ext)
		if err != nil {
			fmt.Fprintf(out, "  ✗ temp for %s: %v\n", obj.URI, err)
			continue
		}
		tmpPath := f.Name()
		if err := s3r.Get(context.Background(), obj.URI, f); err != nil {
			f.Close()
			os.Remove(tmpPath)
			fmt.Fprintf(out, "  ✗ download %s: %v\n", obj.URI, err)
			continue
		}
		f.Close()

		fmt.Fprintf(out, "  %s", obj.URI)
		var n int
		if ext == ".pdf" {
			var diagrams []int
			n, diagrams, err = ragIngestPDF(a.Rag, embedder, tmpPath)
			if err != nil {
				fmt.Fprintf(out, " — error: %v\n", err)
			} else {
				fmt.Fprintf(out, " — %d chunk(s)", n)
				if len(diagrams) > 0 {
					fmt.Fprintf(out, " (%d diagram-only page(s) flagged)", len(diagrams))
				}
				fmt.Fprintln(out)
				ingested += n
			}
		} else {
			n, err = ragIngestFile(a.Rag, embedder, tmpPath)
			if err != nil {
				fmt.Fprintf(out, " — error: %v\n", err)
			} else {
				fmt.Fprintf(out, " — %d chunk(s)\n", n)
				ingested += n
			}
		}
		os.Remove(tmpPath) // immediate cleanup regardless of ingest outcome
	}
	if ingested > 0 {
		fmt.Fprintf(out, "  S3: ingested %d chunk(s) from %s\n", ingested, uri)
	}
}

// ragIngestFile reads a file, splits it into chunks (language-aware when a
// chunker is registered, paragraph-based otherwise), and ingests them.
// Binary files are silently skipped (returns 0, nil).
func ragIngestFile(store *RagStore, embedder Embedder, path string) (int, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return 0, err
	}

	// Skip binary files silently.
	if !isTextContent(data) {
		return 0, nil
	}

	// Use filepath.Ext directly (case-preserving) so .Mod → oberon and .mod → go.
	ext := filepath.Ext(path)
	langID, _ := globalRegistry.DetectFromExtension(ext)

	var enriched []EnrichedChunk
	if langID != "" {
		if chunker := globalRegistry.GetChunker(langID); chunker != nil {
			enriched = chunker.Chunk(string(data), path)
		}
	}

	// Fallback: generic paragraph chunking wrapped as EnrichedChunks.
	if len(enriched) == 0 {
		for _, text := range ragChunk(string(data)) {
			enriched = append(enriched, EnrichedChunk{
				Content:   text,
				ChunkType: "code",
			})
		}
	}

	if len(enriched) == 0 {
		return 0, nil
	}

	// Populate Docs fields using documentation extractor when available.
	if langID != "" {
		if extractor := globalRegistry.GetExtractor(langID); extractor != nil {
			symDocs := extractor.ExtractSymbols(string(data))
			for i := range enriched {
				if len(enriched[i].Symbols) > 0 {
					if doc, ok := symDocs[enriched[i].Symbols[0]]; ok {
						enriched[i].Docs = doc
					}
				}
			}
		}
	}

	if err := store.IngestEnriched(path, enriched, embedder); err != nil {
		return 0, err
	}
	return len(enriched), nil
}

// ragIngestPDF extracts text from a PDF with pdfExtract and ingests it into
// store. If the document looks like a scholarly paper (isPaperLike), it is
// chunked by section via scholarlyChunk and ingested with IngestEnriched so
// each chunk carries its section type and the document's scholarly
// identifiers/citations. Otherwise it falls back to flat per-page chunks,
// each prefixed with the document title and page number so retrieved context
// always carries its provenance. Diagram-only pages (sparse text, no raster
// images) are stored with an incomplete-content marker so retrieval results
// can surface the caveat.
// Returns (chunkCount, diagramPageNumbers, error).
func ragIngestPDF(store *RagStore, embedder Embedder, path string) (int, []int, error) {
	result, err := pdfExtract(path, "")
	if err != nil {
		return 0, nil, err
	}

	title := filepath.Base(path)
	if result.Info.Title != "" {
		title = result.Info.Title
	}

	diagramSet := make(map[int]bool, len(result.DiagramPages))
	for _, p := range result.DiagramPages {
		diagramSet[p] = true
	}

	pageTexts := strings.Split(result.Text, "\f")
	for len(pageTexts) > 0 && strings.TrimSpace(pageTexts[len(pageTexts)-1]) == "" {
		pageTexts = pageTexts[:len(pageTexts)-1]
	}

	if isPaperLike(pageTexts) {
		chunks := scholarlyChunk(pageTexts, title, diagramSet)
		if len(chunks) == 0 {
			return 0, result.DiagramPages, nil
		}
		if err := store.IngestEnriched(path, chunks, embedder); err != nil {
			return 0, nil, err
		}
		return len(chunks), result.DiagramPages, nil
	}

	totalPages := result.Info.Pages
	if totalPages == 0 {
		totalPages = len(pageTexts)
	}

	var allChunks []string
	for i, pageText := range pageTexts {
		pageNum := i + 1
		isDiagram := diagramSet[pageNum]

		header := fmt.Sprintf("[PDF: %q, page %d of %d]", title, pageNum, totalPages)
		if isDiagram {
			header += diagramPageWarning
		}

		chunks := ragChunk(pageText)
		if len(chunks) == 0 {
			if isDiagram {
				// Store a placeholder so the diagram page is retrievable.
				allChunks = append(allChunks, header)
			}
			continue
		}
		for _, chunk := range chunks {
			allChunks = append(allChunks, header+"\n\n"+chunk)
		}
	}

	if len(allChunks) == 0 {
		return 0, result.DiagramPages, nil
	}
	if err := store.Ingest(path, allChunks, embedder); err != nil {
		return 0, nil, err
	}
	return len(allChunks), result.DiagramPages, nil
}

// ragChunk splits text into paragraph-sized chunks of at most ~500 characters,
// further splitting oversized paragraphs at sentence boundaries.
func ragChunk(text string) []string {
	const maxChunk = 500

	paragraphs := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n\n")
	var chunks []string
	for _, p := range paragraphs {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		if len(p) <= maxChunk {
			chunks = append(chunks, p)
			continue
		}
		// Split long paragraphs at sentence ends.
		sentences := strings.FieldsFunc(p, func(r rune) bool {
			return r == '.' || r == '!' || r == '?'
		})
		var buf strings.Builder
		for _, s := range sentences {
			s = strings.TrimSpace(s)
			if s == "" {
				continue
			}
			if buf.Len()+len(s)+2 > maxChunk && buf.Len() > 0 {
				chunks = append(chunks, buf.String())
				buf.Reset()
			}
			if buf.Len() > 0 {
				buf.WriteString(". ")
			}
			buf.WriteString(s)
		}
		if buf.Len() > 0 {
			chunks = append(chunks, buf.String())
		}
	}
	return chunks
}

// ragQuery runs a manual retrieval test against the RAG store.
func ragQuery(a *Agent, query string, out io.Writer) error {
	if a.Rag == nil {
		fmt.Fprintln(out, "RAG is not configured. Run /rag new NAME first.")
		return nil
	}
	entry := a.Config.Memory.ActiveRagStore()
	if entry == nil {
		fmt.Fprintln(out, "No active RAG store. Run /rag use NAME to select one.")
		return nil
	}
	embedder := NewEmbedderForEntry(entry, a.Config.OllamaURL)
	chunks, err := a.Rag.Query(query, embedder, 5)
	if err != nil {
		return fmt.Errorf("rag query: %w", err)
	}
	if len(chunks) == 0 {
		fmt.Fprintln(out, "No results. The store may be empty — run /rag ingest first.")
		return nil
	}
	fmt.Fprintf(out, "Top %d result(s) for %q:\n\n", len(chunks), query)
	for i, c := range chunks {
		preview := c.Content
		if len([]rune(preview)) > 120 {
			preview = string([]rune(preview)[:119]) + "…"
		}
		if c.Source != "" {
			fmt.Fprintf(out, "  [%d] score=%.3f  source=%s\n      %s\n\n", i+1, c.Score, c.Source, preview)
		} else {
			fmt.Fprintf(out, "  [%d] score=%.3f  %s\n\n", i+1, c.Score, preview)
		}
	}
	return nil
}

// ── /memory ──────────────────────────────────────────────────────────────────

/** cmdMemory dispatches /memory subcommands: mine, list, show, flag, forget,
 * status, recall, profile.
 *
 * Parameters:
 *   a    (*Agent)    — Harvey agent.
 *   args ([]string)  — subcommand and arguments.
 *   out  (io.Writer) — output writer.
 *
 * Returns:
 *   error — on store open or subcommand failure.
 *
 * Example:
 *   /memory mine
 *   /memory list --type tool_use --kind pitfall
 *   /memory show git_fix_a3f891
 *   /memory flag git_fix_a3f891
 *   /memory forget git_fix_a3f891
 *   /memory status
 */
func cmdMemory(a *Agent, args []string, out io.Writer) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /memory <mine|list|show|flag|forget|status|recall|profile> [args...]")
		return nil
	}
	store, err := NewMemoryStore(a.Workspace)
	if err != nil {
		return fmt.Errorf("/memory: open store: %w", err)
	}
	defer store.Close()

	switch args[0] {
	case "mine":
		return cmdMemoryMine(a, args[1:], out, store)
	case "list":
		return cmdMemoryList(a, args[1:], out, store)
	case "show":
		return cmdMemoryShow(a, args[1:], out, store)
	case "flag":
		return cmdMemoryFlag(a, args[1:], out, store)
	case "forget":
		return cmdMemoryForget(a, args[1:], out, store)
	case "status":
		return cmdMemoryStatus(a, args[1:], out, store)
	case "recall":
		return cmdMemoryRecall(a, args[1:], out, store)
	case "profile":
		return cmdMemoryProfile(a, args[1:], out, store)
	default:
		fmt.Fprintf(out, "Unknown /memory subcommand: %q\n", args[0])
		fmt.Fprintln(out, "Usage: /memory <mine|list|show|flag|forget|status|recall|profile> [args...]")
		return nil
	}
}

// cmdMemoryMine mines a session file for memories with interactive review.
func cmdMemoryMine(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	force := false
	var sessionPath string
	for _, arg := range args {
		if arg == "--force" {
			force = true
		} else {
			sessionPath = arg
		}
	}

	manifest, err := LoadManifest(store.Dir())
	if err != nil {
		return fmt.Errorf("memory mine: load manifest: %w", err)
	}

	if sessionPath == "" {
		sessDir := a.SessionsDir
		if sessDir == "" {
			sessDir = filepath.Join(a.Workspace.Root, harveySubdir, "sessions")
		}
		var candidates []string
		if force {
			entries, _ := os.ReadDir(sessDir)
			for _, e := range entries {
				if !e.IsDir() && (filepath.Ext(e.Name()) == ".spmd" || filepath.Ext(e.Name()) == ".fountain") {
					candidates = append(candidates, filepath.Join(sessDir, e.Name()))
				}
			}
			sort.Strings(candidates)
		} else {
			candidates, err = manifest.UnminedSessions(sessDir)
			if err != nil {
				return fmt.Errorf("memory mine: list sessions: %w", err)
			}
		}
		if len(candidates) == 0 {
			fmt.Fprintln(out, "No unmined sessions found.")
			return nil
		}
		sessionPath = candidates[len(candidates)-1]
	}

	if force {
		filtered := manifest.Sessions[:0]
		for _, e := range manifest.Sessions {
			if e.Path != sessionPath {
				filtered = append(filtered, e)
			}
		}
		manifest.Sessions = filtered
	}

	var embedder Embedder
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		embedder = NewEmbedderForEntry(entry, a.Config.OllamaURL)
	}

	miner := NewMiner(store, manifest, a.Workspace)
	return miner.Mine(context.Background(), sessionPath, a, embedder, out, a.In)
}

// cmdMemoryList lists non-archived memories, optionally filtered by type or kind.
func cmdMemoryList(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	typeFilter := ""
	kindFilter := ""
	for i, arg := range args {
		if arg == "--type" && i+1 < len(args) {
			typeFilter = args[i+1]
		}
		if arg == "--kind" && i+1 < len(args) {
			kindFilter = args[i+1]
		}
	}
	metas, err := store.List(typeFilter)
	if err != nil {
		return fmt.Errorf("memory list: %w", err)
	}
	if len(metas) == 0 {
		fmt.Fprintln(out, "No memories found.")
		return nil
	}
	for _, m := range metas {
		if kindFilter != "" && m.Kind != kindFilter {
			continue
		}
		kind := m.Kind
		if kind == "" {
			kind = "-"
		}
		fmt.Fprintf(out, "%-30s  %-16s  %-14s  %.1f  %s\n",
			m.ID, m.Type, kind, m.Confidence, m.Description)
	}
	return nil
}

// cmdMemoryShow displays the full content of a memory by ID.
func cmdMemoryShow(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	if len(args) == 0 {
		items := memorySelectItems(store)
		if len(items) == 0 {
			fmt.Fprintln(out, "No memories found.")
			return nil
		}
		chosen, err := SelectFrom(items, fmt.Sprintf("Show which memory [1-%d] or Enter to cancel: ", len(items)), a.In, out)
		if err != nil || chosen == "" {
			return err
		}
		args = []string{chosen}
	}
	doc, err := store.ByID(args[0])
	if err != nil {
		return fmt.Errorf("memory show: %w", err)
	}
	if doc == nil {
		fmt.Fprintf(out, "Memory %q not found.\n", args[0])
		return nil
	}
	data, err := doc.Bytes()
	if err != nil {
		return fmt.Errorf("memory show: serialise: %w", err)
	}
	fmt.Fprint(out, string(data))
	return nil
}

// cmdMemoryForget archives a memory by ID immediately.
func cmdMemoryForget(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	if len(args) == 0 {
		items := memorySelectItems(store)
		if len(items) == 0 {
			fmt.Fprintln(out, "No memories found.")
			return nil
		}
		chosen, err := SelectFrom(items, fmt.Sprintf("Forget which memory [1-%d] or Enter to cancel: ", len(items)), a.In, out)
		if err != nil || chosen == "" {
			return err
		}
		args = []string{chosen}
	}
	if err := store.Archive(args[0]); err != nil {
		return fmt.Errorf("memory forget: %w", err)
	}
	fmt.Fprintf(out, "Memory %q archived.\n", args[0])
	return nil
}

// cmdMemoryFlag reduces a memory's confidence by 0.1. When confidence falls
// to or below 0.2 the memory is auto-archived. Use /memory forget for
// immediate archival without the confidence step.
func cmdMemoryFlag(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	if len(args) == 0 {
		items := memorySelectItems(store)
		if len(items) == 0 {
			fmt.Fprintln(out, "No memories found.")
			return nil
		}
		chosen, err := SelectFrom(items, fmt.Sprintf("Flag which memory [1-%d] or Enter to cancel: ", len(items)), a.In, out)
		if err != nil || chosen == "" {
			return err
		}
		args = []string{chosen}
	}
	id := args[0]
	newConf, err := store.SetConfidence(id, -0.1)
	if errors.Is(err, ErrAutoArchived) {
		fmt.Fprintf(out, "%s: confidence → %.1f — auto-archived (below threshold)\n", id, newConf)
		return nil
	}
	if err != nil {
		return fmt.Errorf("memory flag: %w", err)
	}
	fmt.Fprintf(out, "%s: confidence → %.1f\n", id, newConf)
	return nil
}

// cmdMemoryStatus shows manifest summary and memory counts.
func cmdMemoryStatus(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	n, err := store.Count()
	if err != nil {
		return fmt.Errorf("memory status: count: %w", err)
	}

	manifest, err := LoadManifest(store.Dir())
	if err != nil {
		return fmt.Errorf("memory status: load manifest: %w", err)
	}

	sessDir := a.SessionsDir
	if sessDir == "" {
		sessDir = filepath.Join(a.Workspace.Root, harveySubdir, "sessions")
	}
	unmined, _ := manifest.UnminedSessions(sessDir)

	totalCreated := 0
	for _, e := range manifest.Sessions {
		totalCreated += len(e.MemoriesCreated)
	}

	fmt.Fprintf(out, "Memory store:    %s\n", store.Dir())
	fmt.Fprintf(out, "Active memories: %d\n", n)
	fmt.Fprintf(out, "Sessions mined:  %d  (total memories created: %d)\n", len(manifest.Sessions), totalCreated)
	fmt.Fprintf(out, "Sessions pending: %d\n", len(unmined))

	// Budget stats.
	budgetPct := a.Config.Memory.BudgetPct
	contextLen := a.effectiveContextLimit()
	modelName := "unknown"
	if a.Client != nil {
		modelName = a.Client.Name()
	}
	if contextLen > 0 {
		budget := int(float64(contextLen) * budgetPct)
		fmt.Fprintf(out, "Memory budget:   %.0f%% of context (%d tokens on %s)\n",
			budgetPct*100, budget, modelName)
	} else {
		fmt.Fprintf(out, "Memory budget:   %.0f%% of context (context window unknown)\n",
			budgetPct*100)
	}

	statsCount, _ := store.StatsCount()
	if statsCount >= 10 {
		avgSat, compRate, avgTps, statsErr := store.BudgetStats(10)
		if statsErr == nil {
			satPct := int(avgSat * 100)
			switch {
			case avgSat > 0.90 && (avgTps == 0 || avgTps >= 2.0):
				newPct := budgetPct * 1.4
				fmt.Fprintf(out, "Budget advice:   avg utilisation %d%% over last 10 sessions —\n", satPct)
				fmt.Fprintf(out, "                 consider increasing memory.budget_pct to %.2f\n", newPct)
			case avgTps > 0 && avgTps < 2.0 && avgSat > 0.70:
				newPct := budgetPct * 0.75
				fmt.Fprintf(out, "Budget advice:   avg utilisation %d%%, throughput %.1f tok/s — context pressure high;\n", satPct, avgTps)
				fmt.Fprintf(out, "                 consider reducing memory.budget_pct to %.2f\n", newPct)
			default:
				fmt.Fprintf(out, "Budget advice:   avg utilisation %d%% — current setting looks good\n", satPct)
			}
			if compRate > 0.50 {
				fmt.Fprintf(out, "Compression:     rolling summary fired in %.0f%% of recent sessions\n", compRate*100)
			}
		}
	}
	return nil
}

// cmdMemoryRecall queries all memory silos and prints grouped results.
func cmdMemoryRecall(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /memory recall <query>")
		return nil
	}
	query := strings.Join(args, " ")

	var embedder Embedder
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		embedder = NewEmbedderForEntry(entry, a.Config.OllamaURL)
	}

	um := NewUnifiedMemory(store, &a.Config.Memory, a.Workspace)
	results, err := um.Recall(query, embedder, 0)
	if err != nil {
		return fmt.Errorf("memory recall: %w", err)
	}
	if len(results) == 0 {
		fmt.Fprintln(out, "No memories found.")
		return nil
	}

	curSource := ""
	for _, r := range results {
		if r.Source != curSource {
			if curSource != "" {
				fmt.Fprintln(out)
			}
			fmt.Fprintf(out, "[%s]\n", sourceHeader(r.Source))
			curSource = r.Source
		}
		fmt.Fprintf(out, "  [%.2f] %s\n", r.Score, r.Content)
	}
	return nil
}

// cmdMemoryProfile dispatches /memory profile subcommands.
func cmdMemoryProfile(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	sub := "list"
	if len(args) > 0 {
		sub = args[0]
	}
	switch sub {
	case "list":
		return cmdMemoryProfileList(a, args[1:], out, store)
	case "show":
		return cmdMemoryProfileShowContent(a, out, store)
	case "edit":
		return cmdMemoryProfileUpdate(a, out, store)
	case "update":
		fmt.Fprintln(out, dim("  ⚠  /memory profile update is deprecated; use /memory profile edit"))
		return cmdMemoryProfileUpdate(a, out, store)
	case "use":
		return cmdMemoryProfileUse(a, args[1:], out, store)
	case "rename":
		return cmdMemoryProfileRename(a, args[1:], out, store)
	default:
		fmt.Fprintf(out, "Usage: /memory profile <list|show|edit|use|rename> [args...]\n")
		return nil
	}
}

// cmdMemoryProfileList lists active and archived workspace profiles (old "show" behavior).
func cmdMemoryProfileList(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	return cmdMemoryList(a, append([]string{"--type", string(MemoryTypeWorkspaceProfile)}, args...), out, store)
}

// cmdMemoryProfileShowContent prints the full content of the active workspace profile.
func cmdMemoryProfileShowContent(a *Agent, out io.Writer, store *MemoryStore) error {
	metas, err := store.List(string(MemoryTypeWorkspaceProfile))
	if err != nil {
		return err
	}
	if len(metas) == 0 {
		fmt.Fprintln(out, "  No workspace profiles found. Run /profile use to set one.")
		return nil
	}
	active := metas[0]
	doc, err := store.ByID(active.ID)
	if err != nil {
		return fmt.Errorf("profile show: %w", err)
	}
	if doc == nil {
		fmt.Fprintln(out, "  Profile document not found on disk.")
		return nil
	}
	fmt.Fprintf(out, "\nActive workspace profile: %s (%s)\n\n", active.Description, active.ID)
	fmt.Fprintln(out, strings.Repeat("─", 60))
	fmt.Fprintln(out, strings.TrimSpace(doc.FountainBody))
	fmt.Fprintln(out, strings.Repeat("─", 60))

	// RAG context summary — shown when a store is active and has chunks.
	if a.Rag != nil {
		if n, err := a.Rag.Count(); err == nil && n > 0 {
			storeName := ""
			if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
				storeName = entry.Name
			}
			if a.RagOn {
				fmt.Fprintf(out, "\nRAG context: %s (%d chunk(s), on)\n", storeName, n)
			} else {
				fmt.Fprintf(out, "\nRAG context: %s (%d chunk(s), off — /rag on to enable)\n", storeName, n)
			}
		}
	}
	return nil
}

// cmdMemoryProfileRename updates the description/title of the active workspace profile.
func cmdMemoryProfileRename(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /memory profile rename NAME")
		return nil
	}
	newName := strings.Join(args, " ")
	metas, err := store.List(string(MemoryTypeWorkspaceProfile))
	if err != nil || len(metas) == 0 {
		fmt.Fprintln(out, "  No active workspace profile to rename.")
		return nil
	}
	active := metas[0]
	doc, err := store.ByID(active.ID)
	if err != nil {
		return fmt.Errorf("profile rename: %w", err)
	}
	if doc == nil {
		fmt.Fprintln(out, "  Profile document not found on disk.")
		return nil
	}
	doc.Meta.Description = newName
	doc.FountainBody = rewriteProfileTitle(doc.FountainBody, newName)
	var embedder Embedder
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		embedder = NewEmbedderForEntry(entry, a.Config.OllamaURL)
	}
	if err := store.Save(doc, embedder); err != nil {
		return fmt.Errorf("profile rename: %w", err)
	}
	fmt.Fprintf(out, green("✓")+" Workspace renamed to %q\n", newName)
	return nil
}

// rewriteProfileTitle replaces the TITLE: field or INT. scene heading in a
// Fountain profile body with newName. TITLE: takes priority; if absent, the
// INT. WORKSPACE PROFILE line is updated (using an uppercased name).
func rewriteProfileTitle(body, newName string) string {
	lines := strings.Split(body, "\n")
	upper := strings.ToUpper(newName)
	// First pass: prefer the explicit TITLE: field.
	for i, line := range lines {
		if strings.HasPrefix(line, "TITLE:") {
			lines[i] = "TITLE: " + newName
			return strings.Join(lines, "\n")
		}
	}
	// Second pass: fall back to the INT. scene heading.
	for i, line := range lines {
		if strings.HasPrefix(line, "INT. WORKSPACE PROFILE") {
			lines[i] = "INT. WORKSPACE PROFILE - " + upper
			return strings.Join(lines, "\n")
		}
	}
	return body
}

// cmdMemoryProfileUse switches to a new workspace profile:
//  1. Writes a structural handoff document to agents/hand-off/.
//  2. Archives the current active workspace_profile memories.
//  3. Selects a template (by name or interactive picker) and saves it as the
//     new workspace_profile.
//  4. Calls ClearHistory() so the new profile is injected on the next turn.
func cmdMemoryProfileUse(a *Agent, args []string, out io.Writer, store *MemoryStore) error {
	// Step 1 — write handoff (non-fatal).
	if a.Workspace != nil {
		if handoffDir, err := ResolveHandoffDir(a.Workspace); err == nil {
			if path, err := a.WriteHandoff(store, handoffDir); err != nil {
				fmt.Fprintf(out, yellow("  ✗")+" Handoff: %v\n", err)
			} else {
				fmt.Fprintf(out, dim("  Handoff saved: %s\n"), filepath.Base(path))
			}
		}
	}

	// Step 2 — archive existing active profiles (non-fatal).
	if metas, err := store.List(string(MemoryTypeWorkspaceProfile)); err == nil {
		for _, m := range metas {
			if archErr := store.Archive(m.ID); archErr != nil {
				fmt.Fprintf(out, yellow("  ✗")+" Archive %s: %v\n", m.ID, archErr)
			}
		}
	}

	// Step 3 — select template and save new profile.
	wsRoot := ""
	if a.Workspace != nil {
		wsRoot = a.Workspace.Root
	}
	chosen, templateName := profileSelectTemplate(a, args, wsRoot, out)

	var embedder Embedder
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		embedder = NewEmbedderForEntry(entry, a.Config.OllamaURL)
	}

	wsName := filepath.Base(wsRoot)
	if wsName == "" || wsName == "." {
		wsName = "workspace"
	}
	id := GenerateMemoryID(MemoryTypeWorkspaceProfile)
	description := templateName + " — " + wsName
	summary := templateBodySummary(chosen)
	if summary == "" {
		summary = description
	}
	tags := []string{
		"workspace_profile",
		"template:" + strings.ToLower(strings.ReplaceAll(templateName, " ", "-")),
		strings.ToLower(wsName),
	}
	ts := time.Now().UTC().Format("2006-01-02 15:04:05")
	doc := NewMemoryDoc(id, MemoryTypeWorkspaceProfile, description, summary, tags)
	doc.FountainBody = buildProfileFountainBody(ts, templateName, chosen)
	if err := store.Save(doc, embedder); err != nil {
		return fmt.Errorf("profile use: save: %w", err)
	}
	fmt.Fprintf(out, green("✓")+" Switched to %q. Type your first message to continue.\n", templateName)

	// Step 4 — reset history so the new profile is injected on the next turn.
	a.ClearHistory()
	return nil
}

// profileSelectTemplate resolves the template to use for /profile use.
// If args[0] is provided, it attempts to load by stem name or display name.
// If not found, or if no name is given, it shows the interactive picker.
// Returns raw template bytes and the display name.
func profileSelectTemplate(a *Agent, args []string, wsRoot string, out io.Writer) ([]byte, string) {
	if len(args) > 0 {
		name := args[0]
		templates := ListTemplates(wsRoot)
		for _, t := range templates {
			stem := strings.TrimSuffix(t.File, ".fountain")
			if stem == name || strings.EqualFold(t.Name, name) {
				var data []byte
				var err error
				if t.Source == "workspace" && wsRoot != "" {
					localPath := filepath.Join(wsRoot, "agents", "templates", "profiles", t.File)
					data, err = os.ReadFile(localPath)
				}
				if data == nil || err != nil {
					data, err = LoadTemplate(t.File)
				}
				if err == nil {
					return maybeEditTemplate(data, out), t.Name
				}
			}
		}
		fmt.Fprintf(out, "  Template %q not found — showing picker.\n", name)
	}

	// Interactive picker via SelectFrom.
	templates := ListTemplates(wsRoot)
	fmt.Fprintln(out, "\nChoose a profile:")
	items := make([]SelectItem, len(templates))
	for i, t := range templates {
		label := t.Name
		if t.Recommended != "" {
			label += "\n        " + t.Recommended
		}
		items[i] = SelectItem{Value: t.Name, Label: label}
	}
	chosen, err := SelectFrom(items, fmt.Sprintf("Select [1-%d] or press Enter for Blank: ", len(items)), a.In, out)
	if err != nil {
		return nil, ""
	}
	// "" means cancelled → use Blank
	if chosen == "" {
		data, _ := LoadTemplate("blank")
		return maybeEditTemplate(data, out), "Blank"
	}
	// Find the template by name and load it.
	for _, t := range templates {
		if t.Name == chosen {
			var data []byte
			if t.Source == "workspace" && wsRoot != "" {
				localPath := filepath.Join(wsRoot, "agents", "templates", "profiles", t.File)
				data, _ = os.ReadFile(localPath)
			}
			if data == nil {
				data, _ = LoadTemplate(t.File)
			}
			if data != nil {
				return maybeEditTemplate(data, out), t.Name
			}
		}
	}
	// Template not found (shouldn't happen) → Blank.
	data, _ := LoadTemplate("blank")
	return maybeEditTemplate(data, out), "Blank"
}

// maybeEditTemplate opens the template in $EDITOR when running interactively.
// Returns the original content on error or when not interactive.
func maybeEditTemplate(content []byte, out io.Writer) []byte {
	if !term.IsTerminal(int(os.Stdin.Fd())) {
		return content
	}
	edited, err := editTemplateRaw(content, out)
	if err != nil {
		fmt.Fprintf(out, yellow("  ✗")+" Editor: %v — using template as-is.\n", err)
		return content
	}
	return edited
}

// cmdMemoryProfileUpdate opens the most recent workspace_profile in $EDITOR and re-saves it.
func cmdMemoryProfileUpdate(a *Agent, out io.Writer, store *MemoryStore) error {
	metas, err := store.List(string(MemoryTypeWorkspaceProfile))
	if err != nil {
		return fmt.Errorf("profile update: %w", err)
	}
	if len(metas) == 0 {
		fmt.Fprintln(out, "No workspace_profile memories found. Start Harvey in a fresh workspace to run onboarding.")
		return nil
	}
	// List is ordered by updated_at DESC; first entry is most recent.
	doc, err := store.ByID(metas[0].ID)
	if err != nil {
		return fmt.Errorf("profile update: load doc: %w", err)
	}
	if doc == nil {
		fmt.Fprintln(out, "Profile document not found on disk.")
		return nil
	}
	edited, err := editInEditor(doc, out)
	if err != nil {
		return fmt.Errorf("profile update: editor: %w", err)
	}
	var embedder Embedder
	if entry := a.Config.Memory.ActiveRagStore(); entry != nil {
		embedder = NewEmbedderForEntry(entry, a.Config.OllamaURL)
	}
	if err := store.Save(edited, embedder); err != nil {
		return fmt.Errorf("profile update: save: %w", err)
	}
	fmt.Fprintf(out, green("✓")+" Profile updated: %s\n", edited.Meta.ID)
	return nil
}

// ─── /format ─────────────────────────────────────────────────────────────────

// cmdFormat formats one or more workspace files in-place using the registered
// formatter for each file's language.  File-mode (external, in-place) formatters
// are skipped when safe mode is enabled.
func cmdFormat(a *Agent, args []string, out io.Writer) error {
	if a.Workspace == nil {
		fmt.Fprintln(out, "  /format requires a workspace.")
		return nil
	}
	if len(args) == 0 {
		fmt.Fprintln(out, "Usage: /format FILE [FILE...]")
		return nil
	}
	for _, relPath := range args {
		data, err := a.Workspace.ReadFile(relPath)
		if err != nil {
			fmt.Fprintf(out, "  %s: read error: %v\n", relPath, err)
			continue
		}
		ext := filepath.Ext(relPath)
		langID, ok := globalRegistry.DetectFromExtension(ext)
		if !ok {
			fmt.Fprintf(out, "  %s: no language registered for extension %q\n", relPath, ext)
			continue
		}
		f := globalRegistry.GetFormatter(langID)
		if f == nil {
			fmt.Fprintf(out, "  %s: no formatter registered for %q\n", relPath, langID)
			continue
		}
		if f.Mode() == FileFormatter && a.Config.SafeMode {
			fmt.Fprintf(out, "  %s: file-mode formatter requires safe mode off (/safemode off)\n", relPath)
			continue
		}
		absPath, err := a.Workspace.AbsPath(relPath)
		if err != nil {
			fmt.Fprintf(out, "  %s: path error: %v\n", relPath, err)
			continue
		}
		original := string(data)
		formatted, err := f.Format(original, absPath)
		if err != nil {
			fmt.Fprintf(out, "  %s: formatter error: %v\n", relPath, err)
			continue
		}
		if formatted == original {
			fmt.Fprintf(out, "  %s: already formatted\n", relPath)
			continue
		}
		if err := os.WriteFile(absPath, []byte(formatted), 0o644); err != nil {
			fmt.Fprintf(out, "  %s: write error: %v\n", relPath, err)
			continue
		}
		fmt.Fprintf(out, "  %s: formatted (%d → %d bytes)\n", relPath, len(original), len(formatted))
	}
	return nil
}
