/**
 * Agent loop — runs one turn of the chat: stream the LLM, execute any
 * tool calls (via tool-dispatcher), feed results back, repeat until
 * the model returns text without tool calls (end_turn) or we hit the
 * iteration cap.
 *
 * Ported from the extension's runTurn. Differences vs the extension:
 *   - Persistence: messages persist to lib/chat/session-store (sqlite)
 *   - Tools: browser tools dispatch via lib/chat/tool-dispatcher → bridge
 *   - Memory: Temper integration deferred to Phase 2b
 *   - Narrowing / destructive confirm: deferred — agent is simpler v1
 */

import { streamLlm, type LlmTool } from './llm';
import { loadSettings } from '../settings';
import {
  appendMessage,
  getSession,
  listMessagesCapped,
} from './session-store';
import {
  dispatchTool,
  BUILTIN_TOOL_DEFS,
  type BuiltinHandler,
} from './tool-dispatcher';
import { getMemoryStore } from './memory-store';
import { buildMemoryContext } from './build-memory-context';
import { buildReferencePromptSection } from './reference-prompt';
import { buildMemoryTools } from './memory-tools';
import { buildStartWatchTool } from '../watch/start-watch-tool';
import { beginTurn, endTurn, isAborted, consumeNotes } from './turn-control';
import { estimateTokens } from '../memory/token-estimate';
import {
  listInstalledConnectors,
  getConnector,
  getConnectorEntries,
} from '../connectors/registry';
import type {
  ContentBlock,
  Message,
  TextBlock,
  ToolResultBlock,
  ToolUseBlock,
} from './types';

// Set to 24: enough headroom for typical multi-step tool chains
// (search → paginate → drill → summarise can be 8-15; complex audits
// 15-20) without inviting runaway loops. Hitting it surfaces the
// sentinel message below so the user knows why the turn stopped.
const MAX_ITERATIONS = 24;
const MAX_TOKENS = 16000;
// Working-window message-count cap. Token cap is dynamic per-profile
// (see DEFAULT_MAX_INPUT_TOKENS + ApiProfile.maxInputTokens). Older raw
// is summarized by the memory-standalone Temper Summary sub-task and
// recalled via buildMemoryContext as compact blocks.
const HISTORY_MSG_BUDGET = 60;
// Default total input ceiling when the profile doesn't set one. Matches
// Claude Code's 200k working window — modern Claude / DeepSeek-V3 /
// Qwen-Max all expose ≥ 200k context. Anything beyond this gets strictly
// compressed (memory tail trimmed first, then oldest history evicted).
// Lower it per-profile (e.g. 12000) for older 16k-context models —
// Forge auto-trims memory then history to fit.
const DEFAULT_MAX_INPUT_TOKENS = 200_000;
// Reserved for the model's reply so we don't have to fight Anthropic
// hard limits where max_tokens is part of the context budget.
const OUTPUT_RESERVE_TOKENS = MAX_TOKENS;
// Absolute floor — if even after trimming memory we can't fit this
// many tokens of history, the profile's ceiling is unworkable and we
// surface a clear error instead of sending a useless request.
const MIN_HISTORY_TOKENS = 2_000;
// Hard cap on a single tool_result stored into the conversation (chars).
// A giant result (e.g. a connector returning a full test tree) would
// otherwise blow the whole HISTORY_TOKEN_BUDGET, push its paired
// assistant tool_use out of the window, and leave an orphan tool_result
// that trimOrphanToolResults strips — yielding an empty history and an
// "messages must not be empty" provider error. ~16k chars ≈ 4k tokens,
// half the budget, so a complete tool_use+result pair always survives.
const MAX_TOOL_RESULT_CHARS = 16000;

// After clipping to last N, the first kept message may be a tool_result
// whose tool_use was cut. Anthropic/OpenAI both reject that, so drop
// leading tool_result-bearing user messages until the slice starts clean.
function truncateToolResult(s: string): string {
  if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
  return s.slice(0, MAX_TOOL_RESULT_CHARS) +
    `\n\n[… tool result truncated: ${s.length} chars total, showing first ${MAX_TOOL_RESULT_CHARS}. ` +
    `Refine the call (filter / paginate / flatten) to get a smaller, complete result.]`;
}

function trimOrphanToolResults(history: Message[]): Message[] {
  let i = 0;
  while (i < history.length) {
    const m = history[i];
    const hasToolResult = m.role === 'user'
      && Array.isArray(m.blocks)
      && m.blocks.some((b) => (b as any).type === 'tool_result');
    if (!hasToolResult) break;
    i += 1;
  }
  return i === 0 ? history : history.slice(i);
}

/**
 * Heal sessions where an assistant `tool_use` lacks a matching follow-up
 * `tool_result` (e.g. user hit Stop mid-execution, stream crashed, tool
 * runner threw without persisting). Both Anthropic and OpenAI reject
 * requests with orphan tool calls — AI SDK surfaces this as
 * `AI_MissingToolResultsError`.
 *
 * Strategy: for every assistant message containing tool_use blocks,
 * collect the ids that are NOT covered by the immediately-next message's
 * tool_results, then synthesize stub `tool_result` blocks for the
 * missing ones and prepend them to that next user message (creating one
 * if needed). Synthesized result text says the call was interrupted so
 * the model can recover sensibly on retry.
 */
function healOrphanToolUses(history: Message[]): Message[] {
  if (history.length === 0) return history;
  const out: Message[] = [];
  for (let i = 0; i < history.length; i += 1) {
    const m = history[i];
    out.push(m);
    if (m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;

    const toolUses = m.blocks.filter(
      (b): b is ToolUseBlock => (b as any)?.type === 'tool_use',
    );
    if (toolUses.length === 0) continue;

    // Examine the next message (if any) for matching tool_results.
    const next = history[i + 1];
    const coveredIds = new Set<string>();
    if (next?.role === 'user' && Array.isArray(next.blocks)) {
      for (const b of next.blocks) {
        if ((b as any).type === 'tool_result' && (b as ToolResultBlock).tool_use_id) {
          coveredIds.add((b as ToolResultBlock).tool_use_id);
        }
      }
    }

    const missing = toolUses.filter((tu) => !coveredIds.has(tu.id));
    if (missing.length === 0) continue;

    console.warn(
      `[agent-loop] healed ${missing.length} orphan tool_use(s) in session history ` +
      `(ids: ${missing.map((t) => t.id).join(', ')}). Likely cause: cancelled / crashed prior turn.`,
    );

    const stubBlocks: ToolResultBlock[] = missing.map((tu) => ({
      type: 'tool_result',
      tool_use_id: tu.id,
      content: JSON.stringify({
        ok: false,
        error: 'previous tool call did not finish (interrupted or crashed); retry the request',
      }),
      is_error: true,
    }));

    if (next?.role === 'user' && Array.isArray(next.blocks)
        && next.blocks.some((b) => (b as any).type === 'tool_result')) {
      // Existing pairing message — splice stubs in front of its blocks
      // so the surviving real results still apply in their original order.
      history[i + 1] = { ...next, blocks: [...stubBlocks, ...next.blocks] };
    } else {
      // No follow-up at all — inject a fresh user message right after this
      // assistant turn. We mutate `history` so the loop sees it next iter,
      // but push to `out` here so order is preserved.
      const injected: Message = {
        id: `synthetic-${m.id}`,
        session_id: m.session_id,
        role: 'user',
        blocks: stubBlocks,
        ts: m.ts + 1,
      };
      out.push(injected);
    }
  }
  return out;
}

export interface AgentEvent {
  type:
    | 'text_delta'      // assistant text streaming
    | 'tool_use'        // model called a tool
    | 'tool_result'     // tool returned
    | 'message_saved'   // a full message persisted (assistant or tool-results carrier)
    | 'memory_status'   // pinned/blocks/hits snapshot from Temper for the UI strip
    | 'turn_done'       // loop finished
    | 'watch_status'    // ambient background-watch progress (status chip, NOT a message)
    | 'error';          // unrecoverable
  message_id?: string;
  data?: any;
}

export type AgentCallbacks = {
  onEvent: (event: AgentEvent) => void;
};

export interface ProviderResolution {
  name: string;
  type: 'anthropic' | 'openai';
  apiKey: string;
  baseUrl: string;
  model: string;
  /** Total input-token ceiling for chat (system + memory + history + tools).
   *  Falls back to DEFAULT_MAX_INPUT_TOKENS when the profile leaves it unset. */
  maxInputTokens?: number;
}

/**
 * Map the user-facing provider string to a chat adapter protocol.
 * Anthropic's API is the only first-class non-OpenAI shape we ship; LiteLLM /
 * Azure / OpenAI proxies / Grok / Google-via-LiteLLM all speak OpenAI.
 */
export function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
  const p = (provider || '').toLowerCase();
  if (p === 'anthropic' || p === 'claude') return 'anthropic';
  return 'openai';
}

/**
 * Provider → default public API URL. Saves the user from having to know
 * the right host for first-party providers; only LiteLLM (which is by
 * definition a self-hosted proxy) requires an explicit URL.
 *
 * Resolution priority: profile.baseUrl > env.*_BASE_URL > provider default.
 */
export function defaultBaseUrl(provider: string | undefined): string {
  switch ((provider || '').toLowerCase()) {
    case 'anthropic':
    case 'claude':
      return 'https://api.anthropic.com';
    case 'openai':
      return 'https://api.openai.com/v1';
    case 'grok':
    case 'xai':
      return 'https://api.x.ai/v1';
    case 'google':
    case 'gemini':
      // Google's Gemini exposes an OpenAI-compatible shim at this path.
      return 'https://generativelanguage.googleapis.com/v1beta/openai';
    case 'deepseek':
      return 'https://api.deepseek.com';
    default:
      return '';
  }
}

export function pickBaseUrl(
  profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
  adapter: 'anthropic' | 'openai',
): string {
  if (profile.baseUrl) return profile.baseUrl;
  const env = profile.env || {};
  const envOverride = adapter === 'anthropic'
    ? env.ANTHROPIC_BASE_URL
    : (env.OPENAI_BASE_URL || env.OPENAI_API_BASE);
  if (envOverride) return envOverride;
  return defaultBaseUrl(profile.provider);
}

export function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
  if (profile.apiKey) return profile.apiKey;
  const env = profile.env || {};
  if (adapter === 'anthropic') return env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || '';
  return env.OPENAI_API_KEY || '';
}

export function resolveProvider(sessionProvider: string | null, sessionModel: string | null): ProviderResolution | { error: string } {
  const settings = loadSettings();
  const profiles = settings.apiProfiles || {};

  // Candidate API profiles: enabled + has apiKey.
  const candidates = Object.entries(profiles).filter(([_, p]) => {
    if (!p || p.enabled === false) return false;
    return (p.apiKey || '').length > 0;
  });
  if (candidates.length === 0) {
    return { error: 'No API profile with an API key. Add one under Settings → API Profiles and pick it as the chat profile.' };
  }

  // Preferred: session.provider (an apiProfile id), then settings.chatAgent, else first candidate.
  const preferredId = sessionProvider || settings.chatAgent || candidates[0]![0];
  const profile = profiles[preferredId] && profiles[preferredId].enabled !== false
    ? profiles[preferredId]
    : candidates[0]![1];
  const name = (profiles[preferredId] && profiles[preferredId].enabled !== false) ? preferredId : candidates[0]![0];

  const adapter: 'anthropic' | 'openai' = profile.provider === 'anthropic' ? 'anthropic' : 'openai';
  if (!profile.apiKey) return { error: `API profile "${name}" has no apiKey.` };

  const model = sessionModel || profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');

  return {
    name,
    type: adapter,
    apiKey: profile.apiKey,
    baseUrl: profile.baseUrl || defaultBaseUrl(profile.provider),
    model,
    maxInputTokens: profile.maxInputTokens,
  };
}

/**
 * Build the connector catalog block — one entry per installed connector,
 * showing what it can do. The LLM reads this to decide whether to call
 * connector_open(<id>) before reaching for a tool.
 *
 * Prefers `catalog_summary` from the manifest (curated 2-4 line English
 * blurb). Falls back to first 5 tool names if absent, so older manifests
 * still produce something usable.
 */
function buildConnectorCatalog(openSet: Set<string>): string[] {
  const lines: string[] = [];
  for (const inst of listInstalledConnectors()) {
    if (!inst.enabled) continue;
    const def = inst.definition;
    let toolCount = 0;
    const sampleNames: string[] = [];
    for (const entry of getConnectorEntries(def)) {
      for (const tname of Object.keys(entry.tools || {})) {
        toolCount += 1;
        if (sampleNames.length < 5) sampleNames.push(tname);
      }
    }
    const status = openSet.has(def.id) ? ' [OPEN]' : '';
    const summary = (def.catalog_summary || '').trim();
    if (summary) {
      lines.push(`▸ ${def.id}${status} (${toolCount} tools):`);
      for (const ln of summary.split('\n')) {
        const trimmed = ln.trim();
        if (trimmed) lines.push(`    ${trimmed}`);
      }
    } else {
      const sample = sampleNames.join(', ');
      lines.push(`▸ ${def.id}${status}: ${toolCount} tools (e.g. ${sample}${toolCount > 5 ? ', …' : ''})`);
    }
    // User defaults — surface so the LLM doesn't ask "which project?" /
    // "which job?" when the wizard already baked one. Looks at both the
    // connector root and the first row of any `instances` array (Jenkins).
    const defaultEntries: string[] = [];
    const seen = new Set<string>();
    const collect = (obj: any) => {
      if (!obj || typeof obj !== 'object') return;
      for (const [k, v] of Object.entries(obj)) {
        if (!k.startsWith('default_')) continue;
        if (typeof v !== 'string' || !v.trim()) continue;
        if (seen.has(k)) continue;
        seen.add(k);
        defaultEntries.push(`${k.slice('default_'.length)}=${v}`);
      }
    };
    collect(inst.config);
    const instances = (inst.config as any)?.instances;
    if (Array.isArray(instances)) for (const row of instances) collect(row);
    if (defaultEntries.length > 0) {
      lines.push(`    defaults: ${defaultEntries.join(', ')} — use when the user omits the matching arg`);
    }
  }
  return lines;
}

function buildSystemPrompt(
  openConnectorTools: LlmTool[],
  openSet: Set<string>,
  builtinDefs: typeof BUILTIN_TOOL_DEFS,
  sessionSystemPrompt: string | null,
): string {
  const now = new Date().toISOString();

  // Inject a brief Forge context block (project names only) so the LLM can
  // validate names the user mentions ("FortiNAC" → real project? → yes) and
  // pass them to trigger_pipeline / dispatch_task without guessing. Full
  // details (paths, agents, skills) are behind list_forge_context — only
  // names are cheap enough to ship every turn.
  let projectNames: string[] = [];
  try {
    const { scanProjects } = require('../projects') as typeof import('../projects');
    projectNames = scanProjects().map((p) => p.name);
  } catch { /* projects roots not configured / read failed — omit */ }

  const lines: string[] = [
    "You are Forge, the user's personal AI assistant.",
    '',
    `Current time: ${now}`,
    '',
    'Tool usage — IMPORTANT:',
    '- Connector tools (mantis.*, gitlab.*, nac.*, tp.*, etc.) are NOT in your active tool list by default. The "Connector catalog" below shows what each connector can do.',
    '  Call connector_open({name: "<id>"}) FIRST to load a connector — its tools become callable on your next turn. The open set resets at every new user message, so re-open as the user pivots topics.',
    '- If the user mentions a system name (e.g. "teams", "mantis", "gitlab", "pmdb") — even casually like "在 teams 中..." / "from mantis" — open that connector and use its tools.',
    '  Don\'t explain how to do something manually before trying the tool. The connector tools run inside the user\'s actual logged-in browser session — they CAN do things you might think only the user can do manually.',
    '- For Teams in particular: send_message can target any chat by name; if the chat doesn\'t exist yet, the tool will return a specific error and THEN you can advise. Don\'t pre-judge.',
    '- If a tool call fails, read its error carefully — it usually tells you what to fix (wrong arg, missing setting, login required). Retry with the fix. Only give up after the tool explicitly says it cannot do the task.',
    '- For trigger_pipeline / dispatch_task: `input.project` refers to a FORGE project — names from the "Forge projects" list below (typically just `scratch` unless the user added directories). It is NOT a GitLab / Mantis project name. When in doubt OMIT `input.project` entirely — Forge defaults to `scratch` (an internal scratch dir Forge fully manages). Pass repo coordinates via workflow-specific fields like `mr_url` / `project_path` / `bug_id`.',
    '',
    'trigger_pipeline specifics — these are easy to get wrong, READ CAREFULLY:',
    '- FIRST call this session: call trigger_pipeline() with NO arguments. The response lists every workflow + which input fields are required (*) vs have defaults. Field names are EXACT, snake_case (e.g. bug_id), declared by the workflow yaml. They are NOT the same as bash variable names inside pipeline scripts (BUG_ID / BASE / PROJECT_PATH are wrong). DO NOT pass uppercase / made-up names.',
    '- For optional fields with defaults (mr_body_template / user_prompt / teams_message_template / etc.), OMIT them — let the default apply. NEVER pass empty strings or invented placeholder values.',
    '- If the response says "Unknown input fields", "Missing required", or "0 iterations" — the pipeline did NOT do what the user asked. Fix the input and retry. Optionally save a pinned memory rule via memory_remember_block({pinned: true, ...}) so the lesson sticks for future sessions.',
    '- DO NOT trust earlier assistant messages in this conversation that claim a pipeline "already ran" — those may be wrong. If the user re-asks, fire fresh; verify only by re-checking the actual target system (e.g. mantis.get_bug for status).',
    '- AMBIGUOUS IDs (e.g. user says "review mr 14904" or "fix bug 1234567" — just an iid/number, no project / no URL): DO NOT silently fill in a project from the gitlab connector\'s `default_project_path` or guess. An enterprise tenant typically has many repos and the iid is only unique within one repo. Either (a) ask the user "which project? — fortinac/FortiNAC? fortios? something else?", or (b) state the assumption explicitly in your reply ("Assuming fortinac/FortiNAC since that\'s your gitlab default — say otherwise to redirect.") BEFORE calling trigger_pipeline. The wrong choice fires a doomed run that the user then has to cancel + retry.',
    '',
    '- Reply without tools ONLY when no system + no time question is involved.',
    '',
    'Other:',
    '- Call get_current_time when asked about "now" or "today".',
    'Keep replies short and direct.',
  ];

  if (projectNames.length > 0) {
    lines.push('', `Forge projects (valid input.project values): ${projectNames.join(', ')}`);
  }

  const catalog = buildConnectorCatalog(openSet);
  if (catalog.length > 0) {
    lines.push('', 'Connector catalog — call connector_open({name}) to load tools:');
    lines.push(...catalog);
    if (openConnectorTools.length > 0) {
      lines.push('', `Currently open connectors (${[...openSet].join(', ')}) — their tools ARE in your active tool list this turn.`);
    }
  }
  if (builtinDefs.length > 0) {
    // Builtins are always-active (no connector_open gate) — list their
    // FULL description so per-tool rules like "save_tmp_file: output
    // user_message verbatim" / "dispatch_task: ASK before classifying
    // a save as a task" reach the LLM here, not just buried in the
    // tools schema. The connector catalog above can be terse because
    // those tools only become live after connector_open; builtins
    // never go through that gate, so terseness here loses real guidance.
    lines.push('', 'Builtin tools (always available):');
    for (const t of builtinDefs) {
      lines.push(`- ${t.name}: ${t.description}`);
    }
  }

  // Reference format guide — encourages the LLM to emit markdown links
  // for bug ids / MR ids / CVE etc., using each connector's real base_url.
  // Front-end remark-linkify (lib/chat/remark-linkify.ts) is the safety
  // net when the LLM forgets and writes plain text.
  // Wrap in try/catch — system prompt assembly is hot path; an unread
  // connector-configs.json must not break the entire chat turn.
  try {
    const refSection = buildReferencePromptSection();
    if (refSection) lines.push('', refSection);
  } catch (e) {
    console.warn('[agent-loop] buildReferencePromptSection failed:', (e as Error).message);
  }

  if (sessionSystemPrompt) {
    lines.push('', sessionSystemPrompt);
  }

  return lines.join('\n');
}

/**
 * Detect connector names the user mentioned in their message so we can narrow
 * the tool list before handing it to the LLM. Three signal strengths:
 *
 *   1. STRONG — slash-prefixed: "/teams" / "/mantis" → user explicitly
 *      commanded that connector, narrow to it AND emit a directive line in
 *      the system prompt. Multiple slashes ok.
 *   2. MEDIUM — bare alias mention surrounded by non-alphanumeric ("从 teams
 *      读消息" / "in mantis"). Narrow the tool list to those plugins so the
 *      LLM can't accidentally wander to a different connector, but no extra
 *      directive.
 *   3. NONE — no mention → keep the full tool list.
 *
 * Strong signals subsume medium ones (only the strong set is kept if any
 * exist). Aliases include the plugin id and its name first-word (>=4 chars).
 */
function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

function detectMentionedConnectors(
  userText: string,
  pluginIds: { id: string; name?: string }[],
): { strong: Set<string>; medium: Set<string> } {
  const strong = new Set<string>();
  const medium = new Set<string>();
  const text = userText || '';
  if (!text) return { strong, medium };

  for (const p of pluginIds) {
    const aliases = new Set<string>();
    aliases.add(p.id.toLowerCase());
    if (p.name) aliases.add(p.name.toLowerCase());
    if (p.name?.includes(' ')) {
      for (const word of p.name.split(/\s+/)) {
        if (word.length >= 4) aliases.add(word.toLowerCase());
      }
    }
    for (const alias of aliases) {
      // STRONG — slash-prefix form like "/teams"
      if (new RegExp(`(^|\\s)/${escapeRegExp(alias)}\\b`, 'i').test(text)) {
        strong.add(p.id);
        break;
      }
      // MEDIUM — bare alias, non-alphanumeric boundaries (Chinese chars count
      // as non-alphanumeric, so "从 teams 读消息" matches "teams" but not
      // "teamspeak")
      if (new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegExp(alias)}([^a-zA-Z0-9_]|$)`, 'i').test(text)) {
        medium.add(p.id);
        break;
      }
    }
  }
  return { strong, medium };
}

function buildConnectorTools(): LlmTool[] {
  const out: LlmTool[] = [];
  for (const inst of listInstalledConnectors()) {
    if (!inst.enabled) continue;
    const def = inst.definition;
    for (const entry of getConnectorEntries(def)) {
      for (const [toolName, tool] of Object.entries(entry.tools || {})) {
        // Executable if it has a script (browser protocol) OR a non-browser
        // protocol that runs server-side (http / shell / ssh).
        const protocol = (tool as any).protocol;
        const isServerSide = protocol === 'http' || protocol === 'shell' || protocol === 'ssh';
        if (!tool.script && !isServerSide) continue;
        const properties: Record<string, unknown> = {};
        const required: string[] = [];
        for (const [pname, pdef] of Object.entries(tool.parameters || {})) {
          properties[pname] = fieldToJsonSchema(pdef as any);
          if ((pdef as any)?.required) required.push(pname);
        }
        out.push({
          name: `${def.id}.${toolName}`,
          description: (tool.description || `${def.name} · ${toolName}`).trim(),
          input_schema: {
            type: 'object',
            properties,
            ...(required.length > 0 ? { required } : {}),
          },
        });
      }
    }
  }
  return out;
}

function fieldToJsonSchema(field: { type?: string; description?: string; label?: string; default?: unknown; options?: string[] }): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  switch (field?.type) {
    case 'number': out.type = 'number'; break;
    case 'boolean': out.type = 'boolean'; break;
    case 'select':
      out.type = 'string';
      if (field.options) out.enum = field.options;
      break;
    default: out.type = 'string';
  }
  if (field?.description) out.description = field.description;
  else if (field?.label) out.description = field.label;
  if (field?.default !== undefined) out.default = field.default;
  return out;
}

// ─── Main entry ───────────────────────────────────────────

export interface RunTurnArgs {
  sessionId: string;
  userText: string;
  callbacks?: AgentCallbacks;
}

export async function runTurn(args: RunTurnArgs): Promise<{ ok: boolean; error?: string }> {
  const cb = args.callbacks?.onEvent ?? (() => {});
  const session = getSession(args.sessionId);
  if (!session) {
    cb({ type: 'error', data: { error: `session not found: ${args.sessionId}` } });
    return { ok: false, error: 'session not found' };
  }

  const provider = resolveProvider(session.provider, session.model);
  if ('error' in provider) {
    cb({ type: 'error', data: { error: provider.error } });
    return { ok: false, error: provider.error };
  }

  // Persist user message
  const userMsg = appendMessage({
    session_id: args.sessionId,
    role: 'user',
    blocks: [{ type: 'text', text: args.userText } as TextBlock],
  });
  cb({ type: 'message_saved', message_id: userMsg.id, data: userMsg });

  // ── Memory (Temper or local SQLite fallback) ───────────────────
  // When Temper is configured we use it; otherwise LocalMemoryStore
  // gives the same block API backed by workflow.db. Either way we
  // pre-fetch pinned + all blocks + a search on the user's message
  // and inline them into the system prompt. memory_* tools are always
  // registered so the LLM can read/write on demand.
  const memStore = getMemoryStore();
  let memContext = '';
  const memTools = buildMemoryTools(memStore);
  const memHandlers: Record<string, BuiltinHandler> = {};
  for (const t of memTools) memHandlers[t.def.name] = t.handle;

  // start_watch — LLM-driven background watch (always available). Bound
  // to this session so completion reports back here.
  const watchTool = buildStartWatchTool(args.sessionId);
  memHandlers[watchTool.def.name] = watchTool.handle;

  if (memStore.enabled) {
    // Inspector strip (memory_status event) wants the full inventory —
    // keep its own listBlocks call. The prompt-injection text comes
    // from buildMemoryContext which excludes internal bookkeeping
    // (cursor / health) and combines pinned + query-driven retrieval
    // hits in one pass.
    const [bp, ba, sp, ctx] = await Promise.allSettled([
      memStore.listBlocks({ pinned: true, scope: 'both' }),
      memStore.listBlocks({ scope: 'both' }),
      memStore.search(args.userText, 8),
      buildMemoryContext({ store: memStore, currentUserMessage: args.userText, currentSessionId: args.sessionId }),
    ]);
    const pinnedBlocks = bp.status === 'fulfilled' ? bp.value : [];
    const allBlocks = ba.status === 'fulfilled' ? ba.value : [];
    const searchHits = sp.status === 'fulfilled' ? sp.value : [];
    const firstErr = [bp, ba, sp, ctx].find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
    const memError = firstErr ? (firstErr.reason instanceof Error ? firstErr.reason.message : String(firstErr.reason)) : undefined;

    memContext = ctx.status === 'fulfilled' ? ctx.value.text : '';

    cb({
      type: 'memory_status',
      data: {
        ts: Date.now(),
        backend: memStore.kind,
        pinnedCount: pinnedBlocks.length,
        blocksCount: allBlocks.length,
        hitsCount: searchHits.filter((h) => h.fact && h.invalid_at == null).length,
        pinnedKeys: pinnedBlocks.map((b) => b.key),
        blockKeys: allBlocks.map((b) => b.key),
        hits: searchHits
          .filter((h) => h.fact && h.invalid_at == null)
          .slice(0, 8)
          .map((h) => (h.fact ?? '').slice(0, 120)),
        error: memError,
      },
    });
  }

  // ── Full connector tool inventory ────────────────────────────────
  // We build the full set ONCE. The active `tools` field sent to the LLM
  // is computed per iteration by filtering to the "open set" (connectors
  // the LLM has opened via connector_open OR the user explicitly named).
  const allConnectorTools = buildConnectorTools();
  const allConnectorIds = [...new Set(allConnectorTools.map((t) => t.name.split('.')[0]!))];
  const pluginCatalog = allConnectorIds.map((id) => {
    const def = getConnector(id);
    return { id, name: def?.name };
  });

  // User-mention auto-open: if the user's message names a connector (slash
  // or bare), seed the open set so the LLM doesn't need an extra
  // connector_open round-trip for the obvious cases. Strong signals
  // (/connector) also emit a directive so the model treats it as a command.
  const mentioned = detectMentionedConnectors(args.userText, pluginCatalog);
  const autoOpenFromUserText: Set<string> = mentioned.strong.size > 0 ? mentioned.strong
                                          : mentioned.medium.size > 0 ? mentioned.medium
                                          : new Set();
  let narrowDirective = '';
  if (mentioned.strong.size > 0) {
    const list = [...mentioned.strong].join(', ');
    narrowDirective = `\n\nUSER MENTIONED CONNECTOR(S) EXPLICITLY (slash-prefix): ${list}. These are already open for this turn — use their tools directly, do NOT call connector_open for them.`;
    console.log(`[chat] auto-open STRONG → ${list}`);
  } else if (mentioned.medium.size > 0) {
    console.log(`[chat] auto-open MEDIUM → ${[...mentioned.medium].join(', ')}`);
  }

  console.log(
    `[chat] total connector tools=${allConnectorTools.length} across ${allConnectorIds.length} connectors`,
  );

  const builtinDefsAll = [
    ...BUILTIN_TOOL_DEFS,
    ...memTools.map((m) => m.def),
    watchTool.def,
  ];
  const builtinToolDefs: LlmTool[] = builtinDefsAll.map((t) => ({
    name: t.name,
    description: t.description,
    input_schema: t.input_schema,
  }));

  // ── Open set computation ─────────────────────────────────────────
  // A connector is "open" for this user-task if the LLM has called
  // connector_open(name=<id>) OR has called <id>.<tool> in any assistant
  // turn since the most recent user TEXT message. User text messages mark
  // task boundaries — on the next user message, the open set resets to
  // just what user-text auto-opens seed.
  //
  // assistantBlocksAccum captures the in-progress turn's blocks (not yet
  // in history during the iteration). We OR them with the history scan.
  function computeOpenSet(history: Message[], currentBlocks: ContentBlock[]): Set<string> {
    const ns = new Set<string>(autoOpenFromUserText);
    // Find the most recent user message that has text content. Tool-result
    // user messages don't count as task boundaries.
    let lastUserIdx = -1;
    for (let i = history.length - 1; i >= 0; i--) {
      const m = history[i]!;
      if (m.role !== 'user') continue;
      if (m.blocks.some((b) => b.type === 'text')) { lastUserIdx = i; break; }
    }
    const start = lastUserIdx + 1;
    for (let i = start; i < history.length; i++) {
      const m = history[i]!;
      if (m.role !== 'assistant') continue;
      for (const b of m.blocks) {
        if (b.type !== 'tool_use') continue;
        if (b.name === 'connector_open') {
          const opened = (b.input as { name?: string } | undefined)?.name;
          if (opened) ns.add(String(opened));
        } else if (b.name.includes('.')) {
          ns.add(b.name.split('.')[0]!);
        }
      }
    }
    for (const b of currentBlocks) {
      if (b.type !== 'tool_use') continue;
      if (b.name === 'connector_open') {
        const opened = (b.input as { name?: string } | undefined)?.name;
        if (opened) ns.add(String(opened));
      } else if (b.name.includes('.')) {
        ns.add(b.name.split('.')[0]!);
      }
    }
    return ns;
  }

  const sessionSystemPrompt = session.system_prompt;
  function buildSystem(openTools: LlmTool[], openSet: Set<string>): string {
    let s = buildSystemPrompt(openTools, openSet, builtinDefsAll, sessionSystemPrompt);
    if (narrowDirective) s += narrowDirective;
    return s;
  }

  // Initial open set (before any iteration): just user-text auto-open seeds
  let openSet: Set<string> = new Set(autoOpenFromUserText);
  let openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
  let allTools: LlmTool[] = [...builtinToolDefs, ...openConnectorTools];

  // Keep memContext separate from `system` — we may need to trim it
  // per-iteration when the profile's maxInputTokens is tight. The
  // assembled string is recomputed each iter (after open-set + memory
  // trim). `system` here holds the base (no memory section).
  let system = buildSystem(openConnectorTools, openSet);
  if (memStore.enabled) {
    const searchHint = memStore.kind === 'local'
      ? '• memory_search is keyword LIKE over local blocks + episodes — useful for finding past notes; prefer memory_get_block / memory_list_blocks for first-person facts.'
      : '• memory_search is for third-party graph search — DO NOT use for first-person questions; those are already in the Known facts block.';
    system += `\n\nMEMORY (${memStore.kind === 'temper' ? 'Temper' : 'local SQLite'}):\n` +
      '• When the user states a new preference / identity / role about THEMSELVES → call memory_remember_block in the same turn. After saving, tell the user one short line: "Saved: <key>=<value>".\n' +
      '• For NAMED third-party facts ("Alice prefers Postgres") → call memory_remember_event (one fact per call, name the subject).\n' +
      searchHint;
  }

  let iter = 0;
  let lastStop = '';
  let assistantBlocksAccum: ContentBlock[] = [];

  // Mark this turn live so the user can abort it / inject notes mid-flight
  // (see turn-control.ts). Cleared in the finally below.
  beginTurn(args.sessionId);

  try {
    while (iter < MAX_ITERATIONS) {
      iter += 1;

      // ── User intervention (turn-control) ────────────────────────
      // Abort: stop the loop cleanly at this boundary with a sentinel so
      // the turn doesn't just vanish. (The current in-flight LLM call /
      // tool batch from the previous iteration has already settled here.)
      if (isAborted(args.sessionId)) {
        const stopMsg = appendMessage({
          session_id: args.sessionId,
          role: 'assistant',
          blocks: [{ type: 'text', text: '⏹ Stopped by user.' } as TextBlock],
        });
        cb({ type: 'message_saved', message_id: stopMsg.id, data: stopMsg });
        lastStop = 'aborted';
        break;
      }
      // Supplementary notes: splice any queued user notes in as a user
      // message so THIS iteration's LLM call sees them. Safe after a
      // tool_result message — the adapter emits that as a `tool` message,
      // so a following `user` message doesn't collide on role.
      // Drain notes — each becomes its own user message so the thread
      // shows them in arrival order (matches the optimistic messages the
      // client already rendered when the user hit Send). The first note
      // carries a flag for the model so it knows this is a mid-task
      // redirect, not ambient chat; subsequent notes go raw to avoid
      // cluttering the visible thread.
      const notes = consumeNotes(args.sessionId);
      if (notes.length > 0) {
        const FLAG = '[mid-task interjection — sent WHILE you were running tools. Treat as an authoritative redirect that overrides any plan you announced earlier (count, target, scope). Adjust on the very next step.]';
        for (let i = 0; i < notes.length; i++) {
          const text = i === 0 ? `${FLAG}\n\n${notes[i]}` : notes[i]!;
          const noteMsg = appendMessage({
            session_id: args.sessionId,
            role: 'user',
            blocks: [{ type: 'text', text } as TextBlock],
          });
          cb({ type: 'message_saved', message_id: noteMsg.id, data: noteMsg });
        }
      }

      // ── Recompute open set every iteration ──────────────────────
      // Scan history (since last user text msg) + this turn's accumulated
      // blocks → which connectors are open right now. Then filter tools.
      // First iteration: only user-text auto-opens seed the set. After
      // the LLM calls connector_open, subsequent iterations pick that up.
      // (Computed off a preview slice of history — refined below once
      // we have the real history under budget.)
      const previewHistory = healOrphanToolUses(
        trimOrphanToolResults(
          listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, 8_000, estimateTokens),
        ),
      );
      const newOpenSet = computeOpenSet(previewHistory, assistantBlocksAccum);
      const setChanged = newOpenSet.size !== openSet.size ||
        [...newOpenSet].some((n) => !openSet.has(n));
      if (setChanged) {
        openSet = newOpenSet;
        openConnectorTools = allConnectorTools.filter((t) => openSet.has(t.name.split('.')[0]!));
        allTools = [...builtinToolDefs, ...openConnectorTools];
        system = buildSystem(openConnectorTools, openSet);
        console.log(`[chat] open set → {${[...openSet].join(',')}} (${openConnectorTools.length} connector tools active)`);
      }

      // ── Dynamic context budget ──────────────────────────────────
      // Total profile cap (default 60k, override per profile). Subtract
      // fixed contributors (system, tools schema) and reservation for
      // the model's reply → what's left splits between memory + history.
      // Memory gets trimmed (from tail = least-pinned recall hits) when
      // history would dip below the floor.
      const maxInputTokens = provider.maxInputTokens || DEFAULT_MAX_INPUT_TOKENS;
      const systemTok = Math.ceil(system.length / 4);
      const toolsTok = Math.ceil(JSON.stringify(allTools).length / 4);
      const fixedTok = systemTok + toolsTok + OUTPUT_RESERVE_TOKENS;

      let memCtxTrimmed = memContext;
      let memCtxTok = Math.ceil(memCtxTrimmed.length / 4);
      let historyBudget = maxInputTokens - fixedTok - memCtxTok;

      if (historyBudget < MIN_HISTORY_TOKENS) {
        // Need to recover from memory. Each token = ~4 chars; cut from the
        // END of memContext (renderMemoryContext emits pinned blocks first,
        // recall hits last → tail is the least-pinned, easiest to drop).
        const shortBy = MIN_HISTORY_TOKENS - historyBudget;
        const charsToDrop = Math.min(memCtxTrimmed.length, shortBy * 4);
        if (charsToDrop > 0) {
          memCtxTrimmed = memCtxTrimmed.slice(0, memCtxTrimmed.length - charsToDrop)
            + (memCtxTrimmed.length - charsToDrop > 0 ? '\n[… memory context trimmed to fit profile.maxInputTokens]' : '');
          memCtxTok = Math.ceil(memCtxTrimmed.length / 4);
          historyBudget = maxInputTokens - fixedTok - memCtxTok;
        }
      }

      if (historyBudget < 500) {
        cb({ type: 'error', data: { error:
          `API profile maxInputTokens=${maxInputTokens} too small: ` +
          `baseline (system=${systemTok} + tools=${toolsTok} + memory=${memCtxTok} + reserve=${OUTPUT_RESERVE_TOKENS}) ` +
          `already takes ${fixedTok + memCtxTok} tokens. ` +
          `Raise maxInputTokens in Settings → API Profiles, or pick a model with a larger context window.`
        } });
        return { ok: false, error: 'profile context budget exhausted' };
      }

      const history = healOrphanToolUses(
        trimOrphanToolResults(
          listMessagesCapped(args.sessionId, HISTORY_MSG_BUDGET, historyBudget, estimateTokens),
        ),
      );
      if (history.length === 0) {
        cb({ type: 'error', data: { error: 'Conversation context is empty after trimming an oversized result. Clear the chat or retry with a narrower query.' } });
        return { ok: false, error: 'empty history' };
      }

      // Stitch trimmed memContext onto base system for this call only.
      const systemForCall = memCtxTrimmed
        ? `${system}\n\n─── Memory context (auto-loaded) ───\n${memCtxTrimmed}`
        : system;

      assistantBlocksAccum = [];
      let currentTextBuf = '';

      // ── Token composition log (input side, BEFORE the call) ──
      const _historyTok = history.reduce((s, m) => s + estimateTokens(m), 0);
      const _historyMsgs = history.length;
      const _est_in = systemTok + memCtxTok + toolsTok + _historyTok;
      console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} cap=${maxInputTokens} est_in=${_est_in} system=${systemTok} memory=${memCtxTok}${memCtxTrimmed.length < memContext.length ? '(trimmed)' : ''} tools=${toolsTok} history=${_historyTok}(${_historyMsgs}msgs) reserve=${OUTPUT_RESERVE_TOKENS}`);

      const result = await streamLlm(
        {
          provider: provider.type,
          apiKey: provider.apiKey,
          baseUrl: provider.baseUrl,
          model: provider.model,
          system: systemForCall,
          history,
          tools: allTools,
          maxTokens: MAX_TOKENS,
        },
        {
          onTextDelta: (delta) => {
            currentTextBuf += delta;
            cb({ type: 'text_delta', data: { delta } });
          },
          onToolUse: (block) => {
            cb({ type: 'tool_use', data: block });
          },
        },
      );

      // ── Real usage from the provider (when reported) ──
      if (result.usage) {
        const u = result.usage;
        console.log(`[chat-tokens] session=${args.sessionId} turn=${iter} REAL in=${u.inputTokens ?? '?'} out=${u.outputTokens ?? '?'} cache_read=${u.cacheReadTokens ?? 0} cache_create=${u.cacheCreationTokens ?? 0} stop=${result.stopReason}`);
      }

      lastStop = result.stopReason;
      assistantBlocksAccum = result.content;

      // Persist assistant message
      const assistantMsg = appendMessage({
        session_id: args.sessionId,
        role: 'assistant',
        blocks: assistantBlocksAccum,
      });
      cb({ type: 'message_saved', message_id: assistantMsg.id, data: assistantMsg });

      if (result.stopReason !== 'tool_use') break;

      // Execute tool calls. The LLM can emit several tool_use blocks per
      // iteration (parallel batch). Without an in-batch abort check, a
      // user who clicks Stop after the batch starts has to wait for ALL
      // tools to finish before the loop top-check fires next iter — feels
      // like Stop did nothing. So: between tools, if abort was requested,
      // skip the remaining ones with synthetic "aborted" tool_results
      // (the tool_use/tool_result pairing invariant must hold for the
      // Anthropic API; an orphan tool_use rejects the next call).
      const toolUses = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
      const toolResults: ToolResultBlock[] = [];
      for (const t of toolUses) {
        if (isAborted(args.sessionId)) {
          const block: ToolResultBlock = {
            type: 'tool_result',
            tool_use_id: t.id,
            content: '⏹ Skipped — user requested stop.',
            is_error: true,
          };
          toolResults.push(block);
          cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: { content: block.content, is_error: true } } });
          continue;
        }
        const r = await dispatchTool({ id: t.id, name: t.name, input: t.input }, { extraBuiltins: memHandlers, sessionId: args.sessionId });
        const block: ToolResultBlock = {
          type: 'tool_result',
          tool_use_id: t.id,
          content: truncateToolResult(r.content),
          is_error: r.is_error,
        };
        toolResults.push(block);
        cb({ type: 'tool_result', data: { tool_use_id: t.id, name: t.name, result: r } });
      }

      // Per Anthropic API: tool_result blocks live on a USER message
      const toolResultMsg = appendMessage({
        session_id: args.sessionId,
        role: 'user',
        blocks: toolResults,
      });
      cb({ type: 'message_saved', message_id: toolResultMsg.id, data: toolResultMsg });
    }

    // If we fell out of the while because we hit MAX_ITERATIONS (vs the
    // model emitting a non-tool_use stopReason), the turn ends with no
    // closing assistant text and the user sees a "no response" hang.
    // Surface a sentinel assistant message so the cause is visible.
    if (iter >= MAX_ITERATIONS && lastStop === 'tool_use') {
      const sentinel = appendMessage({
        session_id: args.sessionId,
        role: 'assistant',
        blocks: [{
          type: 'text',
          text: `⚠️ Hit the tool-call iteration limit (${MAX_ITERATIONS}) without finishing. Likely causes: a tool kept returning truncated/incomplete results so I kept retrying, or the task was too broad. Try narrowing the query or splitting it into smaller asks.`,
        }],
      });
      cb({ type: 'message_saved', message_id: sentinel.id, data: sentinel });
      cb({ type: 'error', data: { error: `iteration limit (${MAX_ITERATIONS}) exceeded` } });
    }

    cb({ type: 'turn_done', data: { iterations: iter, stop_reason: lastStop } });
    return { ok: true };
  } catch (err) {
    let msg = err instanceof Error ? err.message : String(err);
    // Node's fetch throws "fetch failed" with the real reason hidden in
    // err.cause. Unwrap it + add concrete remediation so the user doesn't
    // have to guess between DNS / proxy / corp SSL / wrong baseUrl / VPN.
    if (/^fetch failed$/i.test(msg) || /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|UNABLE_TO_VERIFY|self.signed|EAI_AGAIN/i.test(msg)) {
      const cause: any = (err as any)?.cause;
      const causeCode = cause?.code || '';
      const causeMsg = cause?.message || '';
      const baseUrl = provider.baseUrl || `(adapter default for ${provider.type})`;
      const hints: string[] = [];
      if (/ENOTFOUND|EAI_AGAIN/i.test(causeCode + causeMsg))   hints.push('DNS — host unresolvable. Corp VPN connected? Hostname typo in baseUrl?');
      if (/ECONNREFUSED/i.test(causeCode + causeMsg))           hints.push('Connection refused — endpoint down, or wrong port.');
      if (/ETIMEDOUT/i.test(causeCode + causeMsg))              hints.push('Timed out — firewall / proxy blocked, or VPN required for this endpoint.');
      if (/UNABLE_TO_VERIFY|self.signed/i.test(causeCode + causeMsg))
        hints.push('TLS cert untrusted — corp SSL inspection (Zscaler / firewall). Set NODE_EXTRA_CA_CERTS=<path/to/corp-ca.pem> in the env when starting forge.');
      if (hints.length === 0) hints.push('Verify: agent baseUrl reachable from this machine + apiKey valid + (if corp network) VPN/proxy/SSL inspection.');
      msg = [
        `LLM fetch failed → ${baseUrl}`,
        causeCode || causeMsg ? `cause: ${causeCode}${causeCode && causeMsg ? ' — ' : ''}${causeMsg}` : '',
        ...hints.map(h => '• ' + h),
        'Quick test:  curl -v ' + baseUrl + '   (from a terminal in the same shell that started forge)',
      ].filter(Boolean).join('\n');
    }
    appendMessage({
      session_id: args.sessionId,
      role: 'assistant',
      blocks: [{ type: 'text', text: '⚠ ' + msg } as TextBlock],
      error: msg,
    });
    cb({ type: 'error', data: { error: msg } });
    return { ok: false, error: msg };
  } finally {
    endTurn(args.sessionId);
  }
}
