import Foundation

/// On-device/cloud "intelligence" the UI uses for soft features: glanceable
/// conversation summaries and auto-generated session names. The app provides an
/// Apple Foundation Models implementation; `HeuristicIntelligence` is the
/// dependency-free fallback (also used in tests and when AFM is unavailable/off).
public protocol IntelligenceProviding: Sendable {
    /// A short "what just happened" blurb for the conversation summary card.
    func summarize(session: Session, events: [AgentEvent]) async -> String
    /// A concise title derived from the session's first user message, or nil to
    /// keep the existing (e.g. randomly generated) name.
    func sessionName(fromFirstMessage message: String) async -> String?
    /// A concise title re-derived once the session has actually run, from the opening
    /// request plus a `digest` of what the agent did — used to upgrade a name that the
    /// first message alone got wrong (a vague "continue", pasted error text, etc.).
    /// Returns nil to keep the current name. Has a heuristic default (keep the name);
    /// the model-backed provider overrides it.
    func sessionName(fromFirstMessage message: String, context digest: String) async -> String?
    /// A one-line gist of a cluster of captured to-do ideas (the home to-do groups).
    /// `group` identifies which group/project the batch belongs to (its id, "" for the
    /// unassigned bucket) so a model-backed provider can coalesce redundant in-flight work
    /// per group. Has a heuristic default; the model-backed provider overrides it.
    func summarizeTodos(_ items: [String], group: String) async -> String
    /// A short, glanceable summary of a single captured idea (shown above its full
    /// text on the list). Has a heuristic default; the model-backed provider
    /// overrides it.
    func summarizeTodo(_ text: String) async -> String
    /// Classifies how the agent's most recent turn ended — asking the user
    /// something (`.awaitingInput`) vs. finishing the work (`.completed`) — from its
    /// last reply, so the sidebar can tell the two apart. Has a heuristic default;
    /// the model-backed provider overrides it.
    func classifyTurn(lastReply: String) async -> TurnDisposition
    /// Triage a batch of captured ideas: rank them by IMPACT (urgent/risky work still
    /// floats to the top) and assign each a level for the inbox's colored badges. Triage
    /// is per-project, so `group` identifies the project the batch belongs to (its id, ""
    /// for the unassigned bucket) — a model-backed provider coalesces redundant in-flight
    /// passes per project. Has a heuristic default; the model-backed provider overrides it.
    func triageTodos(_ items: [TriageInput], group: String) async -> [TriageItem]
    /// Map step of the Bash summary: a one-line, purpose-first gist of a SINGLE shell
    /// command for the collapsed tool-call block — "Ran the test suite" for `swift test`,
    /// never the raw command echoed back. The per-command gists accumulate into the
    /// block's running list (`AppStore`), which `mergeBashSummaries` then condenses. Has a
    /// heuristic default (the command itself); the model-backed provider rephrases it.
    func summarizeBashCommand(_ command: String) async -> String
    /// Reduce step of the Bash summary: merge a block's per-command gists (from
    /// `summarizeBashCommand`) into ONE shorter line. Called additively — re-run as the
    /// block grows — so a long run of commands reads as a single "Built and tested the
    /// app" rather than a command log. Has a heuristic default ("Ran a, b, c"); the
    /// model-backed provider condenses the list.
    func mergeBashSummaries(_ summaries: [String]) async -> String
    /// A non-Read, non-Bash family's merged line(s) when summarization runs over "all tool
    /// calls" — three Greps fold into one "Searched for …". Bash takes the map/reduce path
    /// above; Read is never model-summarized (always the deterministic "Read x, y, z"
    /// merge). `worktreeRoot`, when given, makes file paths render relative to the project's
    /// working directory. Has a heuristic default; the model-backed provider rewrites it.
    func summarizeToolFamily(_ calls: [ToolCall], worktreeRoot: String?) async -> [String]
    /// Fold one newly-landed chunk of trunk work into the running summary of everything the
    /// nvrsion trunk has accumulated since its last promotion (NVRSION §6). `previous` is the
    /// summary so far ("" on the first chunk); `digest` describes the latest contribution (one
    /// session's turn — its goal and the files it landed). Returns the updated summary: a short,
    /// plain-language, imperative-verb description of the trunk's changes, used as the body of the
    /// promotion commit message. Maintained INCREMENTALLY — each call sees only the newest
    /// increment laid over the prior summary — so a small on-device model stays accurate where
    /// summarizing the whole accumulated diff at once would not. Has a heuristic default (accumulate
    /// short clauses); the model-backed provider rewrites it into a clean summary.
    func updateTrunkSummary(previous: String, with digest: String) async -> String
}

/// Which tool-call families the AI rewrites in the collapsed block. Read families are
/// never summarized regardless (they get the deterministic "Read x, y, z" merge); this
/// only governs the rest, so the model's effort goes where a bare list is least useful.
public enum ToolSummaryScope: String, CaseIterable, Sendable {
    /// Only Bash families are model-summarized; every other family stays deterministic.
    case bashOnly
    /// Every non-Read family is model-summarized.
    case all
}

extension IntelligenceProviding {
    /// Default: a dependency-free gist, so conformers (and tests) need not implement
    /// it. Declared as a protocol requirement above so the model-backed provider's
    /// override is dynamically dispatched.
    public func summarizeTodos(_ items: [String], group: String) async -> String {
        HeuristicSummary.todoGist(items)  // model-free gist needs no per-group coalescing
    }

    /// Default: keep the current name — without a model there's no better signal than
    /// the first-message name already chosen (see `summarizeTodos` for why this is both
    /// a requirement and an extension default).
    public func sessionName(fromFirstMessage message: String, context digest: String) async -> String? {
        nil
    }

    /// Default: a dependency-free single-line clip (see `summarizeTodos` for why this
    /// is both a requirement and an extension default).
    public func summarizeTodo(_ text: String) async -> String {
        HeuristicSummary.todoLine(text)
    }

    /// Default: a dependency-free guess from the reply's shape (see `summarizeTodos`
    /// for why this is both a requirement and an extension default).
    public func classifyTurn(lastReply: String) async -> TurnDisposition {
        HeuristicTurnClassifier.classify(lastReply)
    }

    /// Default: a dependency-free impact ranking (see `summarizeTodos` for why this is
    /// both a requirement and an extension default).
    public func triageTodos(_ items: [TriageInput], group: String) async -> [TriageItem] {
        HeuristicTriage.triage(items)  // model-free ranking needs no per-project coalescing
    }

    /// Default: the command itself is its own "gist" — `mergeBashSummaries` then lists
    /// these under the "Ran" verb (see `summarizeTodos` for why this is both a requirement
    /// and an extension default).
    public func summarizeBashCommand(_ command: String) async -> String { command }

    /// Default: the deterministic "Ran a, b, c" merge of the per-command gists (deduped,
    /// capped) — the model-free fallback for the reduce step.
    public func mergeBashSummaries(_ summaries: [String]) async -> String {
        HeuristicSummary.joinBashSummaries(summaries)
    }

    /// Default: the deterministic per-family merge ("Searched for x, y") — the model-free
    /// fallback for a non-Bash eligible family.
    public func summarizeToolFamily(_ calls: [ToolCall], worktreeRoot: String?) async -> [String] {
        HeuristicSummary.families(calls).map { HeuristicSummary.line(for: $0, relativeTo: worktreeRoot) }
    }

    /// Default: accumulate a short, deduped "; "-joined clause list (the model-free fold) — the
    /// fallback when Intelligence is off/unavailable, and the seed the model-backed provider
    /// refines (see `summarizeTodos` for why this is both a requirement and an extension default).
    public func updateTrunkSummary(previous: String, with digest: String) async -> String {
        HeuristicSummary.foldTrunkSummary(previous: previous, digest: digest)
    }
}

/// Decides — without a model — whether an agent's final reply ends by asking the
/// user something (`.awaitingInput`) or by reporting finished work (`.completed`).
/// The fallback when Intelligence is off/unavailable, and the basis the model refines.
public enum HeuristicTurnClassifier {
    public static func classify(_ reply: String) -> TurnDisposition {
        let trimmed = reply.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return .completed }
        // An agent's ask, if any, almost always lands at the very end — so weigh the
        // tail rather than the whole (often long) reply. `suffix` keeps punctuation,
        // so a trailing question mark still counts.
        let tail = String(trimmed.suffix(400)).lowercased()
        if tail.contains("?") { return .awaitingInput }
        let askMarkers = [
            "let me know", "would you like", "do you want", "want me to", "should i",
            "shall i", "could you", "can you", "please confirm", "please let me",
            "which would you", "which one", "how would you", "what would you",
            "your call", "up to you", "or should", "go ahead and let",
        ]
        return askMarkers.contains(where: tail.contains) ? .awaitingInput : .completed
    }
}

/// Pure, local fallback — no model required.
public struct HeuristicIntelligence: IntelligenceProviding {
    public init() {}

    public func summarize(session: Session, events: [AgentEvent]) async -> String {
        HeuristicSummary.text(session: session, events: events)
    }

    public func sessionName(fromFirstMessage message: String) async -> String? {
        nil  // keep the existing name when there's no model to generate a better one
    }
}

/// Derives a readable title from a message without a model — the reliable fallback
/// when the model is off or returns something that isn't a title.
public enum HeuristicTitle {
    public static func fromMessage(_ message: String) -> String {
        let firstLine = message.split(whereSeparator: \.isNewline).first.map(String.init)
            ?? message
        let words = firstLine
            .split(whereSeparator: { $0 == " " || $0 == "\t" })
            .map(String.init)
            .prefix(6)
        guard !words.isEmpty else { return "New chat" }
        let cased = words.map { word -> String in
            word.prefix(1).uppercased() + word.dropFirst()
        }.joined(separator: " ")
        let trimmed = cased.trimmingCharacters(in: CharacterSet(charactersIn: " .?!,:;\"'`"))
        return trimmed.isEmpty ? "New chat" : clip(trimmed)
    }

    /// Truncate to `max` characters without splitting a word — drops the trailing
    /// partial word ("Fix The Authentication Middlewar" → "Fix The Authentication")
    /// rather than leaving a mangled fragment. Hard-cuts a single over-long word.
    public static func clip(_ raw: String, max: Int = 48) -> String {
        let s = raw.trimmingCharacters(in: .whitespaces)
        guard s.count > max else { return s }
        let hard = String(s.prefix(max))
        if let lastSpace = hard.lastIndex(of: " ") {
            let onWordBoundary = hard[..<lastSpace].trimmingCharacters(in: .whitespaces)
            if !onWordBoundary.isEmpty { return String(onWordBoundary) }
        }
        return hard.trimmingCharacters(in: .whitespaces)
    }

    /// Whether a model-produced string is plausibly a title (vs. a refusal/answer/gibberish).
    public static func looksLikeTitle(_ candidate: String) -> Bool {
        let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, trimmed.count <= 60 else { return false }
        if trimmed.contains("\n") { return false }
        let words = trimmed.split(separator: " ")
        if words.count > 9 { return false }
        // Small models sometimes loop a token ("Login Login Button Button"); a run of
        // two identical adjacent words is the tell-tale, so reject it.
        let lowerWords = words.map { $0.lowercased() }
        if lowerWords.count >= 2 {
            for i in 1..<lowerWords.count where lowerWords[i] == lowerWords[i - 1] { return false }
        }
        let lowered = trimmed.lowercased()
        let badMarkers = [
            "not applicable", "cannot", "can't", "i'm sorry", "sorry", "as an ai",
            "i can", "unable", "n/a", "gpt", "language model", "apple",
            "title:", "request:",
        ]
        return !badMarkers.contains { lowered.contains($0) }
    }

    /// Whether a first message is too thin to name a session well — a one-liner, or a
    /// bare go-ahead like "continue" / "ok do it" that says nothing about the work. Such
    /// names are re-derived from the agent's actual actions once the first turn runs.
    public static func isWeakFirstMessage(_ message: String) -> Bool {
        let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
        let words = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0.isNewline })
        if words.count < 4 { return true }
        let lowered = trimmed.lowercased()
        let vagueOpeners = [
            "continue", "go ahead", "do it", "go for it", "keep going", "carry on",
            "ok", "okay", "yes", "yep", "sure", "please proceed", "proceed",
            "fix this", "fix it", "help me", "do the thing",
        ]
        return vagueOpeners.contains { lowered == $0 || lowered.hasPrefix("\($0) ") }
    }
}

/// Builds a glanceable summary string, grounded in the AGENT'S OWN ACTIONS in the
/// latest turn (the tools it ran, files it touched) rather than the prose it wrote —
/// otherwise a summary of e.g. "what's in this repo" reports the repo's contents as
/// if the agent accomplished them.
public enum HeuristicSummary {
    /// Display verb for a sub-agent spawn (`Task`/`Agent`) line. Exposed so the
    /// transcript can recognize the generic line for a block that *leads* with a
    /// sub-agent and replace it with an emphasized headline of the agent's purpose.
    public static let subagentVerb = "Ran a subagent"

    /// One turn's worth of grounded signal: the user's request, the agent's concrete
    /// actions, and the gist of its reply.
    public struct Turn {
        public var request: String?
        public var actions: [String]
        public var reply: String?
    }

    /// Extracts the most recent turn (everything since the last user message).
    public static func lastTurn(_ events: [AgentEvent]) -> Turn {
        var start = events.startIndex
        for index in events.indices {
            if case .userText = events[index].kind { start = index }
        }
        var turn = Turn(request: nil, actions: [], reply: nil)
        var seen = Set<String>()
        for event in events[start...] {
            switch event.kind {
            case .userText(let chunk):
                turn.request = chunk.text
            case .toolCallCompleted(let call):
                if let action = actionDescription(call), seen.insert(action).inserted {
                    turn.actions.append(action)
                }
            case .fileChange(let change):
                let action = "\(change.kind.rawValue) \(change.path)"
                if seen.insert(action).inserted { turn.actions.append(action) }
            case .assistantText(let chunk) where !chunk.isPartial:
                turn.reply = chunk.text  // keep the last one
            default:
                break
            }
        }
        return turn
    }

    /// Short, action-first description of a completed tool call.
    public static func actionDescription(_ call: ToolCall) -> String? {
        func path(_ key: String) -> String? { call.input[key]?.stringValue }
        switch call.name {
        case "Read": return path("file_path").map { "read \($0)" }
        case "Write": return path("file_path").map { "wrote \($0)" }
        case "Edit", "MultiEdit": return path("file_path").map { "edited \($0)" }
        case "NotebookEdit": return path("notebook_path").map { "edited \($0)" }
        case "Bash": return path("command").map { "ran `\($0.prefix(80))`" }
        case "Grep": return path("pattern").map { "searched for “\(naturalizeSearchPattern($0))”" }
        case "Glob": return path("pattern").map { "listed \(naturalizeGlobPattern($0))" }
        case "WebFetch": return path("url").map { "fetched \($0)" }
        case "WebSearch": return path("query").map { "web-searched “\($0)”" }
        case "Task": return "ran a subagent"
        case MCPApprovalServer.qualifiedHostExecToolName:
            return path("command").map { "ran `\($0.prefix(80))` on the host" }
        default: return call.name
        }
    }

    /// Buckets a run of events into task-level phrases ("explored the codebase",
    /// "edited 2 files") rather than literal commands, so the summary reads as an
    /// overview instead of a command log.
    public static func coarsePhrases(_ events: ArraySlice<AgentEvent>) -> [String] {
        var edited = Set<String>()
        var reads = 0, searches = 0, web = 0
        for event in events {
            switch event.kind {
            case .toolCallCompleted(let call):
                switch call.name {
                case "Write", "Edit", "MultiEdit", "NotebookEdit":
                    if let path = call.input["file_path"]?.stringValue
                        ?? call.input["notebook_path"]?.stringValue { edited.insert(fileName(path)) }
                case "Read": reads += 1
                case "Grep", "Glob": searches += 1
                case "WebFetch", "WebSearch": web += 1
                default: break
                }
            case .fileChange(let change):
                edited.insert(fileName(change.path))
            default:
                break
            }
        }
        var phrases: [String] = []
        if !edited.isEmpty {
            let names = edited.sorted().prefix(3).joined(separator: ", ")
            let extra = edited.count > 3 ? " +\(edited.count - 3) more" : ""
            phrases.append("edited \(edited.count) file\(edited.count == 1 ? "" : "s") (\(names)\(extra))")
        }
        if reads > 0 || searches > 0 { phrases.append("explored the codebase") }
        if web > 0 { phrases.append("looked things up online") }
        return phrases
    }

    /// A run of same-kind tool calls grouped under one verb ("Read", "Ran", …). The
    /// unit a tool-call block's headline is built from: each family becomes one line —
    /// either the deterministic merge (`line(for:relativeTo:)`) or, when the family is
    /// `aiEligible`, a model-written line in its place.
    public struct ToolFamily: Sendable {
        public let key: String
        public let verb: String
        public let calls: [ToolCall]
        public var isRead: Bool { key == "read" }
        public var isBash: Bool { key == "bash" }
        /// Families whose item is a file path (Read/Write/Edit/MultiEdit/NotebookEdit),
        /// so their line factors out a shared directory rather than listing it per file.
        public var isFileFamily: Bool { key == "read" || key == "write" || key == "edit" }

        /// Whether the model should rewrite this family's line. Read families never are
        /// (they read best as a plain merged list). Bash is summarized by PURPOSE rather
        /// than by merging redundant calls, so even a lone command qualifies — "Ran the
        /// test suite" beats echoing `swift test` — and it's allowed under both scopes.
        /// Every other family only benefits from the model when there's more than one
        /// call to merge, and only the broader "all tool calls" scope opts it in.
        public func aiEligible(scope: ToolSummaryScope) -> Bool {
            if isRead { return false }
            if isBash { return true }
            return calls.count > 1 && scope == .all
        }
    }

    /// The display verb for a tool's family, and the key that buckets calls into it
    /// (Edit/MultiEdit/NotebookEdit all share the "edit" family).
    private static func familyKind(_ name: String) -> (key: String, verb: String) {
        switch name {
        case "Read":              ("read", "Read")
        case "Write":             ("write", "Wrote")
        case "Edit", "MultiEdit": ("edit", "Edited")
        case "NotebookEdit":      ("edit", "Edited")
        case "Bash":              ("bash", "Ran")
        case "Grep":              ("grep", "Searched for")
        case "Glob":              ("glob", "Listed")
        case "WebFetch":          ("webfetch", "Fetched")
        case "WebSearch":         ("websearch", "Searched the web for")
        case "Task", "Agent":     ("task", subagentVerb)
        case MCPApprovalServer.qualifiedHostExecToolName: ("hostexec", "Ran on host")
        default:                  (name, name)
        }
    }

    /// Buckets a run of tool calls into families, keeping them in the order each first
    /// appears so the headline reads in run order.
    public static func families(_ calls: [ToolCall]) -> [ToolFamily] {
        var order: [String] = []
        var verbs: [String: String] = [:]
        var grouped: [String: [ToolCall]] = [:]
        for call in calls {
            let kind = familyKind(call.name)
            if grouped[kind.key] == nil { order.append(kind.key); verbs[kind.key] = kind.verb }
            grouped[kind.key, default: []].append(call)
        }
        return order.map { ToolFamily(key: $0, verb: verbs[$0] ?? $0, calls: grouped[$0] ?? []) }
    }

    /// The most informative argument of a call, for a headline item: a working-dir-relative
    /// path for file tools, an ~40-char clip of the command/pattern/query for the rest.
    private static func itemLabel(_ call: ToolCall, relativeTo root: String?) -> String? {
        func arg(_ k: String) -> String? { call.input[k]?.stringValue }
        // Trim any worktree path mentioned in the command/pattern before clipping, so the
        // visible ~40 chars carry signal rather than a long absolute prefix.
        func clip(_ s: String) -> String { String(relativizePaths(s, relativeTo: root).prefix(40)) }
        switch call.name {
        case "Read", "Write", "Edit", "MultiEdit": return arg("file_path").map { displayPath($0, relativeTo: root) }
        case "NotebookEdit":                       return arg("notebook_path").map { displayPath($0, relativeTo: root) }
        case "Bash":                               return arg("command").map(clip)
        case "Grep":                               return arg("pattern").map { naturalizeSearchPattern(relativizePaths($0, relativeTo: root), limit: 40) }
        case "Glob":                               return arg("pattern").map { naturalizeGlobPattern(relativizePaths($0, relativeTo: root), limit: 40) }
        case "WebFetch":                           return arg("url").map(clip)
        case "WebSearch":                          return arg("query").map(clip)
        case MCPApprovalServer.qualifiedHostExecToolName: return arg("command").map(clip)
        default:                                   return nil
        }
    }

    /// One family's deterministic line: its verb plus the deduped, order-preserving list
    /// of its calls' labels (clipped to the first four so it stays glanceable). File
    /// families additionally factor out a shared directory; the rest are a plain list.
    public static func line(for family: ToolFamily, relativeTo root: String? = nil) -> String {
        var seen = Set<String>(); var unique: [String] = []
        for call in family.calls {
            guard let label = itemLabel(call, relativeTo: root), !label.isEmpty,
                  seen.insert(label).inserted else { continue }
            unique.append(label)
        }
        guard !unique.isEmpty else { return family.verb }
        let body = family.isFileFamily ? mergeFilePaths(unique) : clipList(unique)
        return "\(family.verb) \(body)"
    }

    /// Whether a model-written summary line for a run of shell commands is really just
    /// those commands parroted back ("Ran swift build, swift test" for `swift build` +
    /// `swift test`) rather than a summary of what the run accomplished. Compared on the
    /// alphanumeric core — with an optional leading verb ("Ran") dropped — so backticks,
    /// commas, and the verb prefix can't hide a verbatim echo. Lets the AFM provider
    /// reject a regurgitation and fall back to the deterministic line instead of showing
    /// the raw command dressed up as an AI summary.
    public static func echoesCommands(_ line: String, commands: [String]) -> Bool {
        func core(_ string: String) -> String {
            var scalars = String.UnicodeScalarView()
            for scalar in string.lowercased().unicodeScalars
            where CharacterSet.alphanumerics.contains(scalar) { scalars.append(scalar) }
            return String(scalars)
        }
        let joined = core(commands.joined(separator: " "))
        guard !joined.isEmpty else { return false }
        if core(line) == joined { return true }
        // Drop a single leading word (the verb, e.g. "Ran") and compare the remainder.
        let afterVerb = line.drop { $0 != " " }.dropFirst()
        return core(String(afterVerb)) == joined
    }

    /// Whether a model-written summary line is grounded in the material it was asked to
    /// condense — i.e. it shares at least one significant content word with the input.
    ///
    /// A small on-device model, when the input doesn't strongly match anything it knows,
    /// regurgitates one of the instruction's *examples* verbatim — the merge of a run of
    /// `ls`/`find` came back as "Built and tested the app" (the first example in the merge
    /// prompt) rather than a summary of those inspections. Such a line shares no content
    /// word with the actual per-command gists, so `mergeBashSummaries` rejects it and falls
    /// back to the deterministic "Ran a, b, c" line. Verbs and stopwords still count as
    /// content, so a genuine "Built and tested the app" stays grounded in gists that mention
    /// building or testing; light suffix-stemming keeps test/tests/tested aligned.
    public static func isGrounded(_ line: String, in inputs: [String]) -> Bool {
        let lineWords = contentWords(line)
        guard !lineWords.isEmpty else { return false }
        let inputWords = inputs.reduce(into: Set<String>()) { $0.formUnion(contentWords($1)) }
        // Nothing to ground against (empty/degenerate input) — don't reject the model.
        guard !inputWords.isEmpty else { return true }
        return !lineWords.isDisjoint(with: inputWords)
    }

    /// The significant words of a summary line: lowercased alphanumeric tokens, light
    /// suffix-stemmed (drop a trailing -ing/-ed/-es/-s so test/tests/tested align), with
    /// connective stopwords removed. Verbs are kept — "built"/"staged" are exactly the
    /// signal that tells a grounded summary apart from a parroted example.
    private static func contentWords(_ string: String) -> Set<String> {
        let stopwords: Set<String> = [
            "the", "and", "for", "with", "into", "from", "its", "was", "were",
            "are", "then", "just", "that", "this", "all", "out", "any",
        ]
        var words = Set<String>()
        for rawToken in string.lowercased().split(whereSeparator: { !CharacterSet.alphanumerics.contains($0.unicodeScalars.first!) }) {
            var token = String(rawToken)
            for suffix in ["ing", "ed", "es", "s"] where token.count > suffix.count + 2 && token.hasSuffix(suffix) {
                token = String(token.dropLast(suffix.count)); break
            }
            guard token.count >= 3, !stopwords.contains(token) else { continue }
            words.insert(token)
        }
        return words
    }

    /// Renders a Grep pattern for display: a top-level regex alternation reads as a
    /// natural list ("auth|login|session" → "auth, login, or session") so the headline
    /// isn't a wall of raw pipes. A pattern with a single top-level term is returned
    /// unchanged — only `|` that actually separate alternatives are rewritten.
    ///
    /// `limit` bounds the rendered width: the list is clipped on a *whole-term* boundary
    /// (with a trailing "…") rather than mid-term. Clipping the expanded comma list mid-term
    /// would strand a dangling "," that turns into doubled commas once `clipList` joins
    /// several pattern labels into one headline. A single-term pattern is clipped raw.
    static func naturalizeSearchPattern(_ pattern: String, limit: Int = .max) -> String {
        let parts = topLevelAlternatives(pattern).filter { !$0.isEmpty }
        guard parts.count > 1 else { return String(pattern.prefix(limit)) }
        // Keep whole alternatives until adding the next would exceed the budget.
        var kept: [String] = []
        for part in parts {
            if orList(kept + [part]).count > limit { break }
            kept.append(part)
        }
        if kept.count == parts.count { return orList(parts) }
        guard !kept.isEmpty else { return String(parts[0].prefix(limit)) }
        return kept.joined(separator: ", ") + ", …"
    }

    /// A glob extension token mapped to its human language/format name, so a "Listed
    /// *.swift" headline reads "Listed all Swift files". Extensions absent here fall back
    /// to the bare lowercased token ("proto" → "proto files"), which still reads naturally.
    private static let globExtensionNames: [String: String] = [
        "swift": "Swift",
        "ts": "TypeScript", "tsx": "TypeScript", "mts": "TypeScript", "cts": "TypeScript",
        "js": "JavaScript", "jsx": "JavaScript", "mjs": "JavaScript", "cjs": "JavaScript",
        "py": "Python", "rb": "Ruby", "go": "Go", "rs": "Rust", "php": "PHP",
        "java": "Java", "kt": "Kotlin", "kts": "Kotlin", "cs": "C#",
        "c": "C", "h": "C header", "hpp": "C++ header", "hh": "C++ header",
        "cpp": "C++", "cc": "C++", "cxx": "C++", "m": "Objective-C", "mm": "Objective-C++",
        "sh": "shell", "bash": "shell", "md": "Markdown", "markdown": "Markdown",
        "json": "JSON", "yaml": "YAML", "yml": "YAML", "toml": "TOML", "xml": "XML",
        "html": "HTML", "htm": "HTML", "css": "CSS", "scss": "Sass", "sass": "Sass",
        "sql": "SQL", "proto": "Protocol Buffer",
    ]

    /// Renders a glob pattern as a natural-language noun phrase so a "Listed" headline reads
    /// in plain language: `**/*.swift` → "all Swift files", `*.ts` → "TypeScript files",
    /// `Sources/**/*.py` → "all Python files in Sources", `**/*` → "all files". A recursive
    /// `**` segment reads as "all"; a trailing `*.ext` maps a known extension to its language
    /// name (unknown extensions keep the bare token); a `*.{a,b}` brace list naturalizes each
    /// extension and joins them ("all JavaScript or JSON files").
    ///
    /// Anything that doesn't fit this shape — a non-trivial filename glob like `test_*.py`, a
    /// glob other than `**` in the directory prefix, or no parseable `*` — returns the pattern
    /// clipped raw, so the caller still shows something faithful rather than a wrong guess.
    /// `limit` bounds the rendered width.
    static func naturalizeGlobPattern(_ pattern: String, limit: Int = .max) -> String {
        func raw() -> String { String(pattern.prefix(limit)) }
        let trimmed = pattern.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return raw() }

        // Split into the directory prefix and the final segment (the filename glob).
        let segments = trimmed.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
        guard let file = segments.last else { return raw() }

        // The directory prefix may only contain plain names and `**` (recursion). Any other
        // glob there ("src-*/") is too complex to phrase cleanly, so fall back to raw.
        var recursive = false
        var plainDirs: [String] = []
        for seg in segments.dropLast() {
            if seg == "**" { recursive = true; continue }
            if seg.isEmpty { continue }
            if seg.contains(where: { "*?{[".contains($0) }) { return raw() }
            plainDirs.append(seg)
        }

        // A bare trailing `**` recurses over everything; otherwise parse the filename glob.
        let noun: String
        if file == "**" {
            recursive = true
            noun = "files"
        } else if let parsed = globFileNoun(file) {
            noun = parsed
        } else {
            return raw()
        }

        let prefix = recursive ? "all " : ""
        let suffix = plainDirs.isEmpty ? "" : " in \(plainDirs.joined(separator: "/"))"
        return String("\(prefix)\(noun)\(suffix)".prefix(limit))
    }

    /// The noun phrase for a glob's final segment: `*` → "files", `*.swift` → "Swift files",
    /// `*.{ts,tsx}` → "TypeScript files". Returns nil for any other shape (a literal name, a
    /// compound glob like `test_*.py`, a dotted `*.d.ts`) so the caller falls back to the raw
    /// pattern rather than mis-describing it.
    private static func globFileNoun(_ file: String) -> String? {
        if file == "*" { return "files" }
        guard file.hasPrefix("*.") else { return nil }
        let extPart = String(file.dropFirst(2))

        // Either a single extension token or a `{a,b,c}` brace list of them.
        let tokens: [String]
        if extPart.hasPrefix("{") && extPart.hasSuffix("}") {
            tokens = extPart.dropFirst().dropLast().split(separator: ",").map(String.init)
        } else {
            tokens = [extPart]
        }
        // Every token must be a plain alphanumeric extension — anything else (a nested glob,
        // the second dot in `*.d.ts`) isn't a clean "<lang> files" phrase.
        guard !tokens.isEmpty,
              tokens.allSatisfy({ !$0.isEmpty && $0.allSatisfy { $0.isLetter || $0.isNumber } })
        else { return nil }

        var names: [String] = []
        for token in tokens {
            let name = globExtensionNames[token.lowercased()] ?? token.lowercased()
            if !names.contains(name) { names.append(name) }
        }
        return "\(orList(names)) files"
    }

    /// Splits a regex pattern on its *top-level* `|` alternations only. Pipes that aren't
    /// separators are kept inside their segment: escaped (`\|`), inside a group (`(a|b)`),
    /// or inside a character class (`[a|b]`). A pattern with no top-level `|` comes back as
    /// a single element — this is what keeps a naive `split("|")` from mangling `(foo|bar)`.
    static func topLevelAlternatives(_ pattern: String) -> [String] {
        var segments: [String] = []
        var current = ""
        var depth = 0          // unescaped `(` nesting outside a character class
        var inClass = false    // inside an unescaped `[ … ]`
        var escaped = false    // previous char was a backslash
        for ch in pattern {
            if escaped {
                current.append(ch); escaped = false; continue
            }
            switch ch {
            case "\\":
                current.append(ch); escaped = true
            case "[" where !inClass:
                inClass = true; current.append(ch)
            case "]" where inClass:
                inClass = false; current.append(ch)
            case "(" where !inClass:
                depth += 1; current.append(ch)
            case ")" where !inClass && depth > 0:
                depth -= 1; current.append(ch)
            case "|" where !inClass && depth == 0:
                segments.append(current); current = ""
            default:
                current.append(ch)
            }
        }
        segments.append(current)
        return segments
    }

    /// An Oxford-comma "or" list: "a", "a or b", "a, b, or c".
    static func orList(_ items: [String]) -> String {
        switch items.count {
        case 0: return ""
        case 1: return items[0]
        case 2: return "\(items[0]) or \(items[1])"
        default: return "\(items.dropLast().joined(separator: ", ")), or \(items.last!)"
        }
    }

    /// A plain "a, b, c +N more" list, capped at four items. A trailing period is trimmed
    /// from each item so the inter-item commas don't inherit stray "a., b." dots — the one
    /// terminating period is added once, at the end of the whole line, by `terminated`.
    private static func clipList(_ items: [String]) -> String {
        let more = items.count > 4 ? " +\(items.count - 4) more" : ""
        let trimmed = items.prefix(4).map(stripTrailingPeriod)
        return trimmed.joined(separator: ", ") + more
    }

    /// Drops trailing period(s) from one list item. Only `.` is stripped — a meaningful
    /// `?`, `!`, or ellipsis on an item is left intact.
    private static func stripTrailingPeriod(_ item: String) -> String {
        var out = item
        while out.hasSuffix(".") { out.removeLast() }
        return out
    }

    /// The deterministic merge of a Bash block's per-command gists into one "Ran a, b, c"
    /// line — deduped (order-preserving) and capped at four with "+N more". The model-free
    /// reduce step (`mergeBashSummaries`), and the live placeholder before the model's
    /// condensed line lands.
    public static func joinBashSummaries(_ summaries: [String]) -> String {
        var seen = Set<String>(); var unique: [String] = []
        for summary in summaries {
            let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue }
            unique.append(trimmed)
        }
        guard !unique.isEmpty else { return "Ran" }
        return "Ran " + clipList(unique)
    }

    /// Ensures a glanceable summary line reads as a finished sentence: appends a period
    /// unless the line already closes with terminal punctuation (`.`, `!`, `?`, or an
    /// ellipsis). Applied at display time so the deterministic merge and a model-written
    /// line — which may already punctuate — both end in a period without doubling one.
    public static func terminated(_ line: String) -> String {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        guard let last = trimmed.last else { return trimmed }
        return ".!?…".contains(last) ? trimmed : trimmed + "."
    }

    /// Splits a deterministic file-family summary line into its leading verb and the
    /// file-path body that follows ("Read a, b from src/" → ("Read", "a, b from src/"),
    /// "Read, Edited src/x.swift" → ("Read, Edited", "src/x.swift")), so the transcript
    /// can render the path portion in the same monospaced style the per-call rows use.
    /// Returns nil for lines that don't lead with a file verb — Ran/Searched/Fetched and
    /// any model-rewritten prose, whose body isn't a path to set apart.
    public static func filePathBody(of line: String) -> (verb: String, path: String)? {
        // Longest prefixes first so the "Read, Edited"/"Read, Wrote" folds win over "Read".
        for verb in ["Read, Edited", "Read, Wrote", "Read", "Wrote", "Edited"]
        where line.hasPrefix(verb + " ") {
            let path = String(line.dropFirst(verb.count + 1))
            return path.isEmpty ? nil : (verb, path)
        }
        return nil
    }

    /// Renders a run of relative file paths, factoring out a shared directory: a run of
    /// consecutive paths in the same directory collapses to "a, b, c from dir/" (directory
    /// shown once); a lone path in its own directory stays inline ("dir/a"); files at the
    /// worktree root list bare. Distinct path groups are separated by "; " so a trailing
    /// "from dir/" can't blur into the next group ("a, b from dir/; c, d from dir2/").
    /// Capped at four files, with "+N more" for the rest.
    private static func mergeFilePaths(_ paths: [String]) -> String {
        let shown = Array(paths.prefix(4))
        let more = paths.count > 4 ? " +\(paths.count - 4) more" : ""
        var groups: [(dir: String, files: [String])] = []
        for path in shown {
            let dir = (path as NSString).deletingLastPathComponent
            let file = (path as NSString).lastPathComponent
            if let last = groups.indices.last, groups[last].dir == dir {
                groups[last].files.append(file)
            } else {
                groups.append((dir, [file]))
            }
        }
        let parts = groups.map { group -> String in
            if group.dir.isEmpty { return group.files.joined(separator: ", ") }
            if group.files.count == 1 { return "\(group.dir)/\(group.files[0])" }
            return "\(group.files.joined(separator: ", ")) from \(group.dir)/"
        }
        return parts.joined(separator: "; ") + more
    }

    /// Merges a run of consecutive tool calls into a few glanceable action lines,
    /// collapsing redundant calls of the same kind into one ("Read x, y, z"). This is
    /// both the model-free fallback and the live placeholder a tool-call block shows
    /// before its AFM summary lands. `root`, when given, renders file paths relative to
    /// the project's working directory.
    ///
    /// A `Read` immediately followed by an edit of the *same* file is the one cross-tool
    /// fold: the read-then-edit pair reads as a single "Read, Edited <path>" line rather
    /// than a separate Read line and Edited line. Any other call — or a different path —
    /// between them breaks the pair; everything left over still buckets by family.
    public static func toolGroupLines(_ calls: [ToolCall], relativeTo root: String? = nil) -> [String] {
        var lines: [String] = []
        var pending: [ToolCall] = []  // unpaired calls awaiting family bucketing
        func flushPending() {
            guard !pending.isEmpty else { return }
            lines.append(contentsOf: families(pending).map { line(for: $0, relativeTo: root) })
            pending.removeAll(keepingCapacity: true)
        }
        var i = 0
        while i < calls.count {
            if i + 1 < calls.count, let merged = readEditPair(calls[i], calls[i + 1], relativeTo: root) {
                flushPending()
                lines.append(merged)
                i += 2
            } else {
                pending.append(calls[i])
                i += 1
            }
        }
        flushPending()
        return lines
    }

    /// One combined line for a `Read` of a file immediately followed by an edit of the
    /// *same* file (`Edit`/`MultiEdit`/`Write`/`NotebookEdit`) — "Read, Edited <path>"
    /// (or "Read, Wrote <path>" for `Write`). Returns nil unless `read` is a `Read` and
    /// `edit` modifies the identical path.
    private static func readEditPair(_ read: ToolCall, _ edit: ToolCall, relativeTo root: String?) -> String? {
        guard read.name == "Read", let readPath = read.input["file_path"]?.stringValue else { return nil }
        let verb: String
        switch edit.name {
        case "Edit", "MultiEdit", "NotebookEdit": verb = "Edited"
        case "Write": verb = "Wrote"
        default: return nil
        }
        let editPath = edit.input["file_path"]?.stringValue ?? edit.input["notebook_path"]?.stringValue
        guard let editPath, editPath == readPath else { return nil }
        return "Read, \(verb) \(displayPath(editPath, relativeTo: root))"
    }

    /// A file path trimmed to where it sits inside the project: the working-directory
    /// prefix removed ("/Users/me/proj/src/x.swift" → "src/x.swift"). Paths outside the
    /// worktree (or when no root is known) fall back to the bare filename.
    public static func displayPath(_ path: String, relativeTo root: String?) -> String {
        if let root, !root.isEmpty {
            for prefix in [root, GitWorktreeManager.canonical(root)] where path.hasPrefix(prefix) {
                let rel = String(path.dropFirst(prefix.count))
                let trimmed = rel.hasPrefix("/") ? String(rel.dropFirst()) : rel
                return trimmed.isEmpty ? fileName(path) : trimmed
            }
        }
        return fileName(path)
    }

    /// Trims the project working-directory prefix from every path embedded in a free-text
    /// string — a shell command, a search pattern — so "swift build /Users/me/proj/x.swift"
    /// reads "swift build x.swift". A no-op when no root is known or no path matches.
    public static func relativizePaths(_ text: String, relativeTo root: String?) -> String {
        guard let root, !root.isEmpty else { return text }
        var out = text
        for prefix in Set([root, GitWorktreeManager.canonical(root)]) {
            out = out.replacingOccurrences(of: prefix + "/", with: "")
        }
        return out
    }

    /// The index of the last user message (start of the most recent turn).
    private static func lastTurnStart(_ events: [AgentEvent]) -> Int {
        var start = events.startIndex
        for index in events.indices { if case .userText = events[index].kind { start = index } }
        return start
    }

    private static func fileName(_ path: String) -> String { (path as NSString).lastPathComponent }

    /// Model-free fold of one newly-landed turn's `digest` into the running nvrsion trunk summary
    /// (NVRSION §6). Keeps a short, deduped, "; "-joined list of clauses — one per contributing
    /// turn — capped so the promotion commit message stays glanceable. The on-device provider
    /// replaces this with a rewritten summary; this is the fallback and the seed it refines.
    public static func foldTrunkSummary(previous: String, digest: String) -> String {
        // Parse the prior summary back into its named clauses plus any "+N more" overflow count, so
        // the count survives folds rather than resetting each time the tail is dropped.
        var kept: [String] = []
        var overflow = 0
        for raw in previous.split(separator: ";") {
            let part = raw.trimmingCharacters(in: .whitespaces)
            if part.isEmpty { continue }
            if part.hasPrefix("+"), part.hasSuffix("more"),
               let n = Int(part.dropFirst().split(separator: " ").first ?? "") {
                overflow += n
            } else {
                kept.append(part)
            }
        }
        let clause = trunkClause(digest)
        if !clause.isEmpty, !kept.contains(where: { $0.caseInsensitiveCompare(clause) == .orderedSame }) {
            // Keep up to six named clauses inline; anything beyond folds into the overflow count.
            if kept.count < 6 { kept.append(clause) } else { overflow += 1 }
        }
        let extra = overflow + max(0, kept.count - 6)
        let more = extra > 0 ? "; +\(extra) more" : ""
        return kept.prefix(6).joined(separator: "; ") + more
    }

    /// The short clause a model-free fold derives from a turn `digest` (see `nvrsionTurnDigest`):
    /// its "Goal:" line (else "Chat:" line, else the first non-empty line), clipped to a handful of
    /// words with surrounding punctuation removed.
    private static func trunkClause(_ digest: String) -> String {
        let lines = digest.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }
        func value(_ prefix: String) -> String? {
            lines.first { $0.lowercased().hasPrefix(prefix) }
                .map { String($0.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) }
                .flatMap { $0.isEmpty ? nil : $0 }
        }
        let base = value("goal:") ?? value("chat:") ?? lines.first(where: { !$0.isEmpty }) ?? ""
        let words = base.split(separator: " ").prefix(10).joined(separator: " ")
        return words.trimmingCharacters(in: CharacterSet(charactersIn: " .?!,:;\"'`"))
    }

    /// A glanceable, model-free gist of a cluster of captured ideas: their count and
    /// the first few clipped to a clause each. The fallback for the to-do groups.
    public static func todoGist(_ items: [String]) -> String {
        let cleaned = items
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }
        guard !cleaned.isEmpty else { return "" }
        if cleaned.count == 1 { return clause(cleaned[0]) }
        let heads = cleaned.prefix(3).map(clause)
        let more = cleaned.count > 3 ? "; +\(cleaned.count - 3) more" : ""
        return "\(cleaned.count) ideas: " + heads.joined(separator: "; ") + more
    }

    /// A single-line clip of one idea (first line, first ~12 words, no trailing
    /// punctuation) — the model-free fallback for a per-item to-do summary. Short
    /// ideas come back unchanged; the caller treats "equals the original" as "no
    /// distinct summary".
    public static func todoLine(_ text: String) -> String {
        let firstLine = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? text
        let words = firstLine.split(separator: " ").prefix(12).joined(separator: " ")
        return words.trimmingCharacters(in: CharacterSet(charactersIn: " .?!,:;\"'`"))
    }

    /// First ~8 words of an idea, lower-cased lead, no trailing punctuation.
    private static func clause(_ text: String) -> String {
        let firstLine = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? text
        let words = firstLine.split(separator: " ").prefix(8).joined(separator: " ")
        return words.trimmingCharacters(in: CharacterSet(charactersIn: " .?!,:;\"'`"))
    }

    private static func capitalizingFirst(_ string: String) -> String {
        guard let first = string.first else { return string }
        return first.uppercased() + string.dropFirst()
    }

    /// A short suggested next step based purely on the session's current state.
    private static func suggestedNext(_ session: Session) -> String? {
        switch session.status {
        case .awaitingApproval: return "Approve or deny the pending action"
        case .awaitingInput, .finished: return "Send your next instruction"
        case .running, .provisioning: return "Wait for the current run to finish"
        case .interrupted: return "Resume or send a new message"
        case .error: return "Review the error and retry"
        case .idle: return nil
        }
    }

    /// A glanceable, sectioned (Markdown) heuristic summary: what's been done over
    /// the whole session, what the last run completed, and a suggested next step.
    public static func text(session: Session, events: [AgentEvent]) -> String {
        let userTurns = events.reduce(into: 0) { count, event in
            if case .userText = event.kind { count += 1 }
        }
        if userTurns == 0 {
            return "**New chat** — send a message to get started."
        }

        var lines: [String] = []
        // The whole-session "Done" overview only adds value once there's more than
        // one turn; on the first message it just duplicates "Just now", so skip it.
        let sessionPhrases = coarsePhrases(events[...])
        if userTurns > 1 && !sessionPhrases.isEmpty {
            lines.append("**Done:**")
            for phrase in sessionPhrases.prefix(4) { lines.append("- \(capitalizingFirst(phrase))") }
        }

        let lastPhrases = coarsePhrases(events[lastTurnStart(events)...])
        lines.append("**Just now:**")
        if lastPhrases.isEmpty {
            lines.append("- Replied to your message")
        } else {
            for phrase in lastPhrases.prefix(3) { lines.append("- \(capitalizingFirst(phrase))") }
        }
        if let diff = session.diffStat, diff.filesChanged > 0 {
            lines.append("- \(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed (+\(diff.added) −\(diff.removed))")
        }

        if let next = suggestedNext(session) {
            lines.append("**Next:**")
            lines.append("- \(next)")
        }
        return lines.joined(separator: "\n")
    }

    /// Collapses a sectioned status summary into a flat recap of accomplishments, for a
    /// finished session: merges the "Done" and "Just now" bullets (deduped), drops the
    /// forward-looking "Next" section, and strips the section headings (the card's own
    /// "Done" header replaces them). Deterministic — it never trusts the model to have
    /// produced the exact shape, so a stale cached summary reformats correctly too.
    public static func completedRecap(_ markdown: String) -> String {
        var kept: [String] = []
        var inNext = false
        for raw in markdown.split(separator: "\n", omittingEmptySubsequences: false) {
            let line = raw.trimmingCharacters(in: .whitespaces)
            if line.isEmpty { continue }
            // A bold section heading (**Done:**, **Just now:**, **Next:**) — drop it,
            // and skip the bullets under a "Next" heading until the next heading.
            if line.hasPrefix("**"), !line.hasPrefix("- ") {
                inNext = line.lowercased().contains("next")
                continue
            }
            if inNext { continue }
            if !kept.contains(line) { kept.append(line) }  // merge Done + Just now, deduped
        }
        let recap = kept.joined(separator: "\n")
        return recap.isEmpty ? markdown : recap
    }

    /// A model-facing digest of the latest turn that foregrounds the agent's own
    /// actions, so the model summarizes what the agent DID, not what it talked about.
    public static func lastTurnDigest(_ events: [AgentEvent]) -> String {
        let turn = lastTurn(events)
        var lines: [String] = []
        if let request = turn.request {
            lines.append("The user asked: \(request.replacingOccurrences(of: "\n", with: " ").prefix(300))")
        }
        if turn.actions.isEmpty {
            lines.append("The agent took no tool actions this turn (text-only reply).")
        } else {
            lines.append("The agent's actions this turn (tools it ran / files it changed):")
            for action in turn.actions.prefix(20) { lines.append("- \(action)") }
        }
        if let reply = turn.reply {
            lines.append("The agent's reply (for context only, do not summarize its contents as work done): \(reply.replacingOccurrences(of: "\n", with: " ").prefix(400))")
        }
        return lines.joined(separator: "\n")
    }

    /// A model-facing digest spanning the WHOLE session (for the "Done" overview)
    /// plus the most recent run (for "Just now"). Phrased at a task level so the
    /// model writes an overview, not a command log.
    public static func sessionDigest(_ events: [AgentEvent]) -> String {
        var lines: [String] = []

        let requests = events.compactMap { event -> String? in
            if case .userText(let chunk) = event.kind { return chunk.text } else { return nil }
        }
        if !requests.isEmpty {
            lines.append("The user's requests so far (each is a task to account for):")
            for request in requests.prefix(12) {
                lines.append("- \(request.replacingOccurrences(of: "\n", with: " ").prefix(160))")
            }
        }

        let overall = coarsePhrases(events[...])
        if !overall.isEmpty {
            lines.append("Across the whole session the agent has: \(overall.joined(separator: "; ")).")
        }

        lines.append("")
        let start = lastTurnStart(events)
        let turn = lastTurn(events)
        if let request = turn.request {
            lines.append("Most recent request: \(request.replacingOccurrences(of: "\n", with: " ").prefix(200))")
        }
        let recent = coarsePhrases(events[start...])
        if recent.isEmpty {
            lines.append("In the most recent run the agent only replied (no tool actions).")
        } else {
            lines.append("In the most recent run the agent: \(recent.joined(separator: "; ")).")
        }
        if let reply = turn.reply {
            lines.append("Agent's latest reply (context only — describe what it DID, not the topic it discussed): \(reply.replacingOccurrences(of: "\n", with: " ").prefix(400))")
        }
        return lines.joined(separator: "\n")
    }
}
