// PRE Web GUI — Tool dispatcher + execution loop
// Mirrors the CLI's execute_tool() + tool loop from pre.m

const fs = require('fs');
const path = require('path');
const { streamChat, parseTextToolCalls, stripToolCallText, repairToolArgsStructured, runWithProviderOverride, getActiveProvider } = require('./ollama');
const { buildSystemPrompt, buildSystemPromptAsync } = require('./context');
const { buildToolDefs } = require('./tools-defs');
const { appendMessage, getSessionMessages, renameSession } = require('./sessions');
const { MODEL_CTX, MAX_TOOL_TURNS } = require('./constants');
const { autoExtract } = require('./memory');
const mcp = require('./mcp');
const customTools = require('./custom-tools');
const hooks = require('./hooks');
const experience = require('./experience');
const chronos = require('./chronos');
const argus = require('./argus');
const tiers = require('./tool-tiers');
const compression = require('./compression');
const { getProvider } = require('./connections');
const router = require('./router');
const telemetry = require('./telemetry');

// Track last tier used per session (for routing continuity)
const _sessionTiers = new Map();

/**
 * Generate a short session title from the first user message.
 * Runs in the background — does not block the main response.
 */
function generateSessionTitle(sessionId, userMessage, send) {
  // Truncate very long messages — we only need enough to understand the topic
  const truncated = userMessage.length > 300 ? userMessage.slice(0, 300) : userMessage;

  const titleMessages = [
    {
      role: 'system',
      content: 'You are a title generator. Your ENTIRE response must be exactly one short title (3-6 words). Output NOTHING else — no preamble, no options, no list, no explanation. Just the title.\n\nExamples of correct output:\nWeather in Boulder Creek\nPython Prime Number Function\nDocusign Certificate Email Search',
    },
    { role: 'user', content: truncated },
  ];

  // Preamble/meta phrases that are not actual titles
  const junkPattern = /^(possible titles?|here are|some options|options?:|sure|okay|certainly|of course|title:|titles:)/i;

  function extractTitle(result) {
    let title = '';

    // Prefer the actual response text — skip preamble lines
    if (result.response && result.response.trim()) {
      const lines = result.response.trim().split('\n')
        .map(l => l.replace(/^[\d.)\-*]+\s*/, '').trim())  // strip list markers
        .filter(l => l.length >= 3 && l.length < 60 && !junkPattern.test(l));
      if (lines.length > 0) title = lines[0];
    }

    // Fallback: extract from thinking if response is empty (Gemma 4 thinking mode)
    if (!title && result.thinking) {
      // Strip bullet/list prefixes and common labels, then filter
      const lines = result.thinking.split('\n')
        .map(l => l.trim()
          .replace(/^[*\-#>]+\s*/, '')          // strip bullet/heading markers
          .replace(/^(topic|title|subject)\s*[:=]\s*/i, '')  // strip label prefixes
          .trim()
        )
        .filter(l =>
          l.length >= 3 && l.length < 60
          && !junkPattern.test(l)
          && !l.toLowerCase().includes('user message') && !l.toLowerCase().includes('the user')
          && !l.toLowerCase().includes('generate') && !l.toLowerCase().includes('should be')
          && !l.toLowerCase().includes('let me') && !l.toLowerCase().includes("let's")
          && !l.toLowerCase().includes('i need') && !l.toLowerCase().includes('i think')
          && !l.toLowerCase().includes('how about') && !l.toLowerCase().includes('go with')
          && !l.toLowerCase().includes('→')
          && !l.toLowerCase().includes('possible') && !l.toLowerCase().includes('option')
          && !/^(ok|so|now|here|this|that)\b/i.test(l)
        );
      if (lines.length > 0) {
        title = lines[lines.length - 1];
      }
    }

    // Clean up: strip quotes, trailing punctuation, "Title:" prefix
    return title
      .replace(/^(title\s*[:=]\s*)/i, '')
      .replace(/^["'`"""'']+|["'`"""'']+$/g, '')
      .replace(/[.!:;,]+$/, '')
      .trim();
  }

  return streamChat({
    messages: titleMessages,
    maxTokens: 128,
    think: false,
  }).then(async (result) => {
    console.log(`[title-gen] response: "${result.response}" thinking: "${(result.thinking || '').slice(0, 200)}"`);
    let title = extractTitle(result);

    // Retry with thinking enabled if the first attempt returned empty —
    // some models (Gemma 4) occasionally produce empty output with think:false
    if (!title) {
      console.log('[title-gen] Empty result with think:false, retrying with thinking enabled');
      try {
        const retry = await streamChat({ messages: titleMessages, maxTokens: 256 });
        console.log(`[title-gen] retry response: "${retry.response}" thinking: "${(retry.thinking || '').slice(0, 200)}"`);
        title = extractTitle(retry);
      } catch (retryErr) {
        console.log(`[title-gen] Retry error: ${retryErr.message}`);
      }
    }

    if (title && title.length >= 3 && title.length < 80) {
      console.log(`[title-gen] Saving title: "${title}" for session ${sessionId}`);
      renameSession(sessionId, title);
      send({ type: 'session_renamed', sessionId, name: title });
    } else {
      console.log(`[title-gen] Title rejected (len=${title.length}): "${title}"`);
    }
  }).catch((err) => {
    console.log(`[title-gen] Error: ${err.message}`);
  });
}

// Tool implementations
const bashTool = require('./tools/bash');
const filesTool = require('./tools/files');
const webTool = require('./tools/web');
const memoryTool = require('./tools/memory');
const systemTool = require('./tools/system');
const artifactTool = require('./tools/artifact');
const documentTool = require('./tools/document');
const googleTool = require('./tools/google');
const telegramTool = require('./tools/telegram');
const githubTool = require('./tools/github');
const jiraTool = require('./tools/jira');
const confluenceTool = require('./tools/confluence');
const servicenowTool = require('./tools/servicenow');
const smartsheetTool = require('./tools/smartsheet');
const slackTool = require('./tools/slack');
const sharepointTool = require('./tools/sharepoint');
const imageTool = require('./tools/image');
const exportTool = require('./tools/export');
const mailTool = require('./tools/mail');
const calendarTool = require('./tools/calendar');
const contactsTool = require('./tools/contacts');
const spotlightTool = require('./tools/spotlight');
const remindersTool = require('./tools/reminders');
const notesTool = require('./tools/notes');
const cronTool = require('./tools/cron');
const agentsTool = require('./tools/agents');
const monitorTool = require('./tools/monitor');
const browserTool = require('./tools/browser');
const computerTool = require('./tools/computer');
const linearTool = require('./tools/linear');
const zoomTool = require('./tools/zoom');
const figmaTool = require('./tools/figma');
const asanaTool = require('./tools/asana');
const dynamics365Tool = require('./tools/dynamics365');
const ragTool = require('./tools/rag');
const voiceTool = require('./tools/voice');
const workflowTool = require('./tools/workflow');
const triggerSystem = require('./triggers');

// Tool name aliases — models hallucinate wrong names frequently
const ALIASES = {
  shell: 'bash', run: 'bash', terminal: 'bash', sh: 'bash',
  cmd: 'bash', execute: 'bash', exec: 'bash', command: 'bash',
  run_command: 'bash', shell_exec: 'bash',
  file_read: 'read_file', read: 'read_file', cat: 'read_file',
  view_file: 'read_file', get_file: 'read_file',
  write_file: 'file_write', write: 'file_write', create_file: 'file_write',
  save_file: 'file_write', save: 'file_write',
  edit_file: 'file_edit', edit: 'file_edit', replace: 'file_edit', patch: 'file_edit',
  ls: 'list_dir', list: 'list_dir', dir: 'list_dir', list_directory: 'list_dir',
  search: 'grep', find: 'glob', rg: 'grep', ripgrep: 'grep',
  find_files: 'glob', search_files: 'grep',
  fetch: 'web_fetch', curl: 'web_fetch', http: 'web_fetch',
  browse: 'web_fetch', web: 'web_fetch',
  create_artifact: 'artifact', html: 'artifact', render: 'artifact', display: 'artifact',
  create_document: 'document', doc: 'document', make_document: 'document',
  generate_document: 'document', export: 'document', write_document: 'document',
  copy: 'clipboard_write', paste: 'clipboard_read',
  remember: 'memory_save', recall: 'memory_search', forget: 'memory_delete',
  email: 'gmail', mail: 'gmail', send_email: 'gmail',
  google_drive: 'gdrive', drive: 'gdrive',
  google_docs: 'gdocs', docs: 'gdocs',
  tg: 'telegram', send_telegram: 'telegram', telegram_send: 'telegram',
  jira_search: 'jira', jira_issue: 'jira', jira_create: 'jira',
  wiki: 'confluence', confluence_search: 'confluence', confluence_page: 'confluence',
  snow: 'servicenow', service_now: 'servicenow', sn: 'servicenow', servicenow_incident: 'servicenow',
  itsm: 'servicenow', incident: 'servicenow', incidents: 'servicenow', kb_article: 'servicenow',
  spreadsheet: 'smartsheet', ss: 'smartsheet', smartsheets: 'smartsheet',
  sharepoint_search: 'sharepoint', sp: 'sharepoint', microsoft: 'sharepoint',
  slack_send: 'slack', slack_message: 'slack', send_slack: 'slack',
  generate_image: 'image_generate', create_image: 'image_generate', img: 'image_generate',
  image: 'image_generate', image_gen: 'image_generate', gen_image: 'image_generate',
  draw: 'image_generate', paint: 'image_generate', dalle: 'image_generate',
  schedule: 'cron', cron_job: 'cron', recurring: 'cron', timer: 'cron',
  agent: 'spawn_agent', sub_agent: 'spawn_agent', research: 'spawn_agent',
  spawn_parallel: 'spawn_multi', parallel: 'spawn_multi', agents: 'list_agents',
  lessons: 'experience_list', experiences: 'experience_list',
  health: 'memory_health', staleness: 'memory_health',
  browse_web: 'browser', web_browser: 'browser', chrome: 'browser',
  open_browser: 'browser', puppeteer: 'browser', navigate: 'browser',
  desktop: 'computer', computer_use: 'computer', screen: 'computer',
  mouse: 'computer', keyboard: 'computer', click: 'computer',
  linear_issue: 'linear', linear_search: 'linear', issues: 'linear',
  zoom_meeting: 'zoom', meeting: 'zoom', zoom_create: 'zoom',
  figma_file: 'figma', design: 'figma', figma_comments: 'figma',
  asana_task: 'asana', task_manager: 'asana', asana_search: 'asana',
  d365: 'dynamics365', dynamics: 'dynamics365', dataverse: 'dynamics365', crm: 'dynamics365',
  dynamics_365: 'dynamics365', d365_search: 'dynamics365', d365_record: 'dynamics365',
  rag_search: 'rag', rag_index: 'rag', vector_search: 'rag', semantic_search: 'rag',
  document_search: 'rag', knowledge_base: 'rag', kb: 'rag',
  triggers: 'trigger', watch: 'trigger', file_watch: 'trigger', webhook: 'trigger',
  event_trigger: 'trigger', watcher: 'trigger',
  speak: 'voice', say: 'voice', tts: 'voice', stt: 'voice',
  transcribe: 'voice', speech: 'voice', dictate: 'voice',
  record_workflow: 'workflow', replay_workflow: 'workflow', macro: 'workflow',
  automation: 'workflow', playback: 'workflow',
  bg_agent: 'background_agent', background: 'background_agent', bg_task: 'background_agent',
  background_task: 'background_agent', async_agent: 'background_agent',
  export_pdf: 'pdf_export', share_pdf: 'pdf_export', artifact_pdf: 'pdf_export',
  export_artifact: 'pdf_export', share: 'pdf_export',
  send_mail: 'apple_mail', email_send: 'apple_mail', read_mail: 'apple_mail',
  inbox: 'apple_mail', mailbox: 'apple_mail',
  events: 'apple_calendar', schedule_event: 'apple_calendar', create_event: 'apple_calendar',
  my_calendar: 'apple_calendar', today_events: 'apple_calendar', agenda: 'apple_calendar',
  find_contact: 'apple_contacts', lookup: 'apple_contacts', address_book: 'apple_contacts',
  people: 'apple_contacts', phone: 'apple_contacts',
  mdfind: 'spotlight', find_file: 'spotlight', search_files_spotlight: 'spotlight',
  file_search: 'spotlight', locate: 'spotlight',
  reminder: 'apple_reminders', reminders: 'apple_reminders', todo: 'apple_reminders',
  todos: 'apple_reminders', add_reminder: 'apple_reminders', tasks: 'apple_reminders',
  note: 'apple_notes', notes: 'apple_notes', create_note: 'apple_notes',
  search_notes: 'apple_notes', apple_note: 'apple_notes',
  training: 'training_export', export_training: 'training_export',
  training_data: 'training_export',
  lora: 'finetune', fine_tune: 'finetune', lora_train: 'finetune',
  confidence: 'calibration', self_knowledge: 'calibration', accuracy: 'calibration',
};

// Tools that require user confirmation before execution
const CONFIRM_TOOLS = new Set([
  'process_kill', 'memory_delete',
]);

// Tool+action combinations that require confirmation (for multi-action tools)
const CONFIRM_ACTIONS = {
  sharepoint: new Set(['delete_file']),
  zoom: new Set(['delete_meeting']),
  apple_mail: new Set(['send']),
  apple_calendar: new Set(['delete_event']),
  apple_reminders: new Set(['delete']),
  rag: new Set(['delete']),
  trigger: new Set(['delete']),
  workflow: new Set(['delete']),
  dynamics365: new Set(['delete_record']),
};

// Dispatch a tool call to its handler
// opts.onStatus — callback for streaming status events (used by sub-agents)
async function executeTool(name, args, cwd, opts) {
  // Strip domain prefix (e.g. "media:image_generate" → "image_generate")
  // Models hallucinate this pattern after seeing request_tools domain activation.
  if (name.includes(':')) {
    name = name.split(':').pop();
  }
  // Resolve aliases
  name = ALIASES[name] || name;

  switch (name) {
    // Shell
    case 'bash': return bashTool.bash(args, cwd);

    // Files
    case 'read_file': return filesTool.readFile(args, cwd);
    case 'list_dir': return filesTool.listDir(args, cwd);
    case 'glob': return filesTool.glob(args, cwd);
    case 'grep': return filesTool.grep(args, cwd);
    case 'file_write': return filesTool.fileWrite(args, cwd);
    case 'file_edit': return filesTool.fileEdit(args, cwd);

    // Web
    case 'web_fetch': return webTool.webFetch(args);
    case 'web_search': return webTool.webSearch(args);

    // Memory
    case 'memory_save': return memoryTool.save(args);
    case 'memory_search': return memoryTool.search(args);
    case 'memory_list': return memoryTool.list();
    case 'memory_delete': return memoryTool.del(args);

    // System
    case 'system_info': return systemTool.systemInfo();
    case 'process_list': return systemTool.processList(args);
    case 'process_kill': return systemTool.processKill(args);
    case 'hardware_info': return systemTool.hardwareInfo();
    case 'disk_usage': return systemTool.diskUsage(args);
    case 'net_info': return systemTool.netInfo();
    case 'net_connections': return systemTool.netConnections(args);
    case 'service_status': return systemTool.serviceStatus(args);
    case 'display_info': return systemTool.displayInfo();
    case 'clipboard_read': return systemTool.clipboardRead();
    case 'clipboard_write': return systemTool.clipboardWrite(args);
    case 'open_app': {
      // Track target app for computer use auto-focus
      if (args?.target) computerTool.setTargetApp(args.target);
      const openResult = systemTool.openApp(args);
      // Maximize the window so the model gets a clear, full-size view
      if (args?.target && computerTool.isAvailable()) {
        try {
          // Wait for the app to finish opening, then maximize
          await new Promise(r => setTimeout(r, 1000));
          computerTool.maximizeTargetWindow();
          // Second attempt after brief delay — some apps need time to fully render
          await new Promise(r => setTimeout(r, 500));
          computerTool.maximizeTargetWindow();
        } catch {}
      }
      return openResult;
    }
    case 'notify': return systemTool.notify(args);
    case 'screenshot':
      // When computer use is available, redirect to the computer tool's vision pipeline
      // so the model actually sees the screenshot (base64 for vision) instead of just saving a file
      if (computerTool.isAvailable()) {
        return computerTool.computerUse({ action: 'screenshot' });
      }
      return systemTool.screenshot(args);
    case 'window_list': return systemTool.windowList();
    case 'window_focus':
      // Track target app for computer use auto-focus
      if (args?.app) computerTool.setTargetApp(args.app);
      return systemTool.windowFocus(args);
    case 'applescript': return systemTool.applescript(args);
    case 'powershell_script': return systemTool.powershellScript(args);

    // Artifacts
    case 'artifact': return artifactTool.createArtifact(args);

    // GitHub
    case 'github': return githubTool.github(args);

    // Jira
    case 'jira': return jiraTool.jira(args);

    // Confluence
    case 'confluence': return confluenceTool.confluence(args);

    // ServiceNow
    case 'servicenow': return servicenowTool.servicenow(args);

    // Smartsheet
    case 'smartsheet': return smartsheetTool.smartsheet(args);

    // Slack
    case 'slack': return slackTool.slack(args);

    // SharePoint
    case 'sharepoint': return sharepointTool.sharepoint(args);

    // Google
    case 'gmail': return googleTool.gmail(args);
    case 'gdrive': return googleTool.gdrive(args);
    case 'gdocs': return googleTool.gdocs(args);

    // Telegram
    case 'telegram': return telegramTool.telegram(args);

    // Linear
    case 'linear': return linearTool.linear(args);

    // Zoom
    case 'zoom': return zoomTool.zoom(args);

    // Figma
    case 'figma': return figmaTool.figma(args);

    // Asana
    case 'asana': return asanaTool.asana(args);

    // Dynamics 365
    case 'dynamics365': return dynamics365Tool.dynamics365(args);

    // Image generation
    case 'image_generate': return imageTool.imageGenerate(args);

    // Computer Use (desktop automation)
    case 'computer': {
      const result = await computerTool.computerUse(args);
      // Record step for workflow capture
      if (workflowTool.isRecording()) {
        workflowTool.recordStep(args.action, args);
      }
      return result;
    }

    // Browser
    case 'browser': return browserTool.browserAction(args);

    // Cron / scheduling
    case 'cron': return cronTool.cron(args);

    // Sub-agents — onStatus passed through from runToolLoop for WS streaming
    case 'spawn_agent': return agentsTool.spawnAgent(args, cwd, opts?.onStatus);
    case 'spawn_multi': {
      // Parse tasks if passed as JSON string
      if (args.tasks && typeof args.tasks === 'string') {
        try { args.tasks = JSON.parse(args.tasks); } catch {}
      }
      return agentsTool.spawnMulti(args, cwd, opts?.onStatus);
    }
    case 'list_agents': return agentsTool.listAgents();

    // Background agents (fire-and-forget)
    case 'background_agent': {
      const bgAgents = require('./background-agents');
      const action = (args.action || '').toLowerCase();
      switch (action) {
        case 'spawn': {
          const result = bgAgents.spawn({
            task: args.task,
            cwd,
            name: args.name,
            timeoutMinutes: args.timeout_minutes,
          });
          return `Background agent ${result.id} queued (timeout ${result.timeoutMinutes}m). It will run autonomously and notify you when complete.\nSession: ${result.sessionId}`;
        }
        case 'status': {
          const agent = bgAgents.getAgent(args.id);
          if (!agent) return `Agent ${args.id} not found`;
          const dur = agent.duration ? `${(agent.duration / 1000).toFixed(1)}s` : 'pending';
          const tmo = agent.timeoutMinutes ? ` · timeout ${agent.timeoutMinutes}m` : '';
          return `[${agent.id}] ${agent.status} (${dur}${tmo}) — ${agent.name}\nTools used: ${agent.toolCalls}${agent.currentTool ? ` (running: ${agent.currentTool})` : ''}${agent.error ? `\nError: ${agent.error}` : ''}`;
        }
        case 'list': {
          const agents = bgAgents.listAgents();
          if (agents.length === 0) return 'No background agents have been launched.';
          return agents.map(a => {
            const dur = a.duration ? `${(a.duration / 1000).toFixed(1)}s` : 'pending';
            const tmo = a.timeoutMinutes ? ` · ${a.timeoutMinutes}m` : '';
            return `[${a.id}] ${a.status} (${dur}${tmo}) — ${a.name} (${a.toolCalls} tools)`;
          }).join('\n');
        }
        case 'result': {
          const fullResult = bgAgents.getResult(args.id);
          if (fullResult === null) return `Agent ${args.id} not found`;
          return fullResult || '(No result yet — agent may still be running)';
        }
        case 'cancel': {
          const cancelResult = bgAgents.cancelAgent(args.id);
          return cancelResult.success ? `Agent ${args.id} cancelled.` : `Cannot cancel: ${cancelResult.error}`;
        }
        default:
          return 'Unknown action. Use: spawn, status, list, result, cancel';
      }
    }

    // Background process monitor
    case 'monitor': return monitorTool.monitor(args);

    // Experience ledger
    case 'experience_search': {
      const results = await experience.searchExperiences(args.query || '');
      if (results.length === 0) return 'No relevant past experience found.';
      return results.map(r => `**${r.name}** (similarity: ${r.similarity?.toFixed(2) || 'keyword'})\n${r.body}`).join('\n\n---\n\n');
    }
    case 'experience_list': {
      const entries = experience.listExperiences();
      if (entries.length === 0) return 'No experience entries yet. The ledger builds automatically as you complete tasks.';
      return entries.map(e => `[${e.created}] **${e.name}**: ${e.description}`).join('\n');
    }

    // RAG (local document intelligence)
    case 'rag': return ragTool.rag(args, cwd);

    // Event-driven triggers
    case 'trigger': return triggerSystem.trigger(args);

    // Voice (STT/TTS)
    case 'voice': return voiceTool.voice(args);

    // Workflow capture and replay
    case 'workflow': return workflowTool.workflow(args);

    // Custom tools (self-architected)
    case 'custom_tool': return customTools.customTool(args);

    // Progressive disclosure: request_tools is handled in runToolLoop (needs session context)
    case 'request_tools': return 'Error: request_tools must be handled by the tool loop';

    // Session search (full-text search across past conversations)
    case 'session_search': {
      const fts = require('./fts');
      return fts.searchSessions(args.query, { maxResults: args.count || 20, project: args.project });
    }

    // Chronos
    case 'memory_health': {
      const summary = chronos.maintenanceSummary();
      return `Memory Health: ${summary.healthPct}% fresh\n`
        + `Total: ${summary.total} | Fresh: ${summary.fresh} | Aging: ${summary.aging} | Stale: ${summary.stale} | Unverified: ${summary.unverified}`
        + (summary.oldestStale ? `\nOldest stale: "${summary.oldestStale.name}" (${summary.oldestStale.verifiedAge}d)` : '');
    }

    // Training data export
    case 'training_export': {
      const trainingMod = require('./training');
      if (args.action === 'preview') {
        const preview = trainingMod.previewExport({
          minToolCalls: parseInt(args.min_tool_calls) || 2,
          project: args.project || null,
        });
        return `Training Data Preview:\n` +
          `Sessions scanned: ${preview.sessions}\n` +
          `Total conversation rounds: ${preview.totalRounds}\n` +
          `Quality rounds (meeting filters): ${preview.qualityRounds}\n` +
          `User corrections (DPO pairs): ${preview.correctionRounds}\n` +
          `Experience ledger entries: ${preview.experienceEntries}\n` +
          `Estimated export total: ${preview.estimatedTotal}\n\n` +
          `Top tools used:\n${preview.topTools.map(t => `  ${t.name}: ${t.count}`).join('\n')}`;
      }
      if (args.action === 'export') {
        const result = trainingMod.exportTrainingData({
          format: args.format || 'chatml',
          project: args.project || null,
          minToolCalls: parseInt(args.min_tool_calls) || 2,
          includeExperience: args.include_experience !== 'false',
        });
        return `Exported ${result.count} samples in ${result.format} format.\n` +
          `File: ${result.path}\n` +
          `Sessions scanned: ${result.stats.sessionsScanned}\n` +
          `Experience entries included: ${result.stats.experienceEntries}\n` +
          (result.stats.dpoPairs > 0 ? `DPO pairs: ${result.stats.dpoPairs}` : '');
      }
      if (args.action === 'list') {
        const exports = trainingMod.listExports();
        if (exports.length === 0) return 'No training exports yet. Use action=export to generate one.';
        return exports.map(e =>
          `${e.filename} (${(e.size / 1024).toFixed(1)}KB, ${e.created})`
        ).join('\n');
      }
      return 'Unknown action. Use: preview, export, or list';
    }

    // Calibration self-knowledge
    case 'calibration': {
      const calMod = require('./calibration');
      if (args.action === 'report') {
        const report = calMod.getConfidenceReport();
        if (Object.keys(report.categories).length === 0) {
          return 'No calibration data yet. The system builds this automatically as you use tools. Minimum 5 observations per category are needed.';
        }
        let result = 'Calibration Report:\n\n';
        for (const [_key, cat] of Object.entries(report.categories)) {
          const bar = '█'.repeat(Math.round(cat.confidence / 10)) + '░'.repeat(10 - Math.round(cat.confidence / 10));
          result += `${cat.label}: ${bar} ${cat.confidence}% (${cat.successes} ok, ${cat.failures} fail, ${cat.corrections} corrections)\n`;
        }
        if (report.overall.confidence !== null) {
          result += `\nOverall: ${Math.round(report.overall.confidence * 100)}% (${report.overall.total} total observations)`;
        }
        return result;
      }
      if (args.action === 'reset') {
        calMod.saveProfile(calMod.defaultProfile());
        return 'Calibration data has been reset.';
      }
      return 'Unknown action. Use: report or reset';
    }

    // LoRA fine-tuning on eGPU via tinygrad
    case 'finetune': {
      const tinygradTrain = require('./tinygrad-train');
      if (args.action === 'start') {
        try {
          const result = tinygradTrain.startTraining({
            name: args.name,
            base_model: args.base_model,
            training_data: args.training_data,
            format: args.format,
            lora_rank: args.lora_rank,
            lora_alpha: args.lora_alpha,
            learning_rate: args.learning_rate,
            epochs: args.epochs,
            batch_size: args.batch_size,
            max_seq_len: args.max_seq_len,
            quantization: args.quantization,
          });
          return `Training job started.\nJob ID: ${result.id}\nStatus: ${result.status}\n\nUse finetune action=status id=${result.id} to check progress.`;
        } catch (e) {
          return `Failed to start training: ${e.message}`;
        }
      }
      if (args.action === 'status') {
        if (!args.id) return 'Please provide a job ID. Use finetune action=list to see all jobs.';
        const job = tinygradTrain.getJob(args.id);
        if (!job) return `Job not found: ${args.id}`;
        let result = `Job: ${job.name} (${job.id})\nStatus: ${job.status}\n`;
        if (job.progress.epoch > 0) {
          result += `Progress: Epoch ${job.progress.epoch}, Step ${job.progress.step}\n`;
          if (job.progress.loss !== null) result += `Loss: ${job.progress.loss}\n`;
          result += `Elapsed: ${(job.progress.elapsed_ms / 1000).toFixed(1)}s\n`;
        }
        if (job.error) result += `Error: ${job.error}\n`;
        if (job.adapter_path) result += `Adapter: ${job.adapter_path}\n`;
        return result;
      }
      if (args.action === 'list') {
        const jobs = tinygradTrain.listJobs();
        if (jobs.length === 0) return 'No training jobs. Use finetune action=start to begin a LoRA fine-tuning run.';
        return jobs.map(j => {
          let line = `${j.id} | ${j.name} | ${j.status}`;
          if (j.progress.loss !== null) line += ` | loss: ${j.progress.loss}`;
          if (j.adapter_path) line += ` | adapter: ${j.adapter_path}`;
          return line;
        }).join('\n');
      }
      if (args.action === 'cancel') {
        if (!args.id) return 'Please provide a job ID to cancel.';
        const result = tinygradTrain.cancelJob(args.id);
        return result.success ? `Job ${args.id} cancelled.` : `Cancel failed: ${result.error}`;
      }
      if (args.action === 'adapters') {
        const adapters = tinygradTrain.listAdapters();
        if (adapters.length === 0) return 'No saved LoRA adapters. Complete a training job to generate one.';
        return adapters.map(a => {
          let line = `${a.name} | ${a.path}`;
          if (a.base_model) line += ` | base: ${a.base_model}`;
          if (a.final_loss !== null) line += ` | loss: ${a.final_loss}`;
          if (a.training_samples) line += ` | samples: ${a.training_samples}`;
          return line;
        }).join('\n');
      }
      return 'Unknown action. Use: start, status, list, cancel, or adapters';
    }

    // Knowledge graph
    case 'knowledge_graph': {
      const kg = require('./knowledge-graph');
      if (args.action === 'query') {
        if (!args.query) return 'Please provide a search query.';
        const result = kg.queryGraph(args.query);
        if (result.matches.length === 0) return `No entities matching '${args.query}'. Use knowledge_graph action=stats to see graph overview.`;
        let out = result.matches.map(e => {
          const neighbors = kg.getNeighbors(e.id);
          const rels = [
            ...neighbors.outgoing.slice(0, 5).map(n => `  →[${n.relation}] ${n.entity.name} (${n.entity.type})`),
            ...neighbors.incoming.slice(0, 5).map(n => `  ←[${n.relation}] ${n.entity.name} (${n.entity.type})`),
          ];
          return `${e.name} (${e.type})${e.aliases.length ? ' aka: ' + e.aliases.join(', ') : ''}\n${rels.length ? rels.join('\n') : '  (no connections)'}`;
        }).join('\n\n');
        return out;
      }
      if (args.action === 'add_entity') {
        if (!args.name || !args.type) return 'Provide name and type for the entity.';
        const entity = kg.addEntity(args.name, args.type);
        return `Entity added: ${entity.name} (${entity.type}) [id: ${entity.id}]`;
      }
      if (args.action === 'add_edge') {
        if (!args.name || !args.target || !args.relation) return 'Provide name (source), target, and relation.';
        const result = kg.addEdge(args.name, args.target, args.relation);
        if (result.error) return result.error;
        if (result.exists) return 'Edge already exists.';
        return `Edge added: ${args.name} —[${args.relation}]→ ${args.target}`;
      }
      if (args.action === 'remove') {
        if (!args.name) return 'Provide entity name to remove.';
        const result = kg.removeEntity(args.name);
        return result.error || `Removed entity: ${result.removed}`;
      }
      if (args.action === 'stats') {
        const stats = kg.getStats();
        let out = `Knowledge Graph: ${stats.entityCount} entities, ${stats.edgeCount} edges\n`;
        if (Object.keys(stats.typeCounts).length > 0) {
          out += '\nEntity types:\n' + Object.entries(stats.typeCounts).map(([t, c]) => `  ${t}: ${c}`).join('\n');
        }
        if (Object.keys(stats.relationCounts).length > 0) {
          out += '\n\nRelation types:\n' + Object.entries(stats.relationCounts).map(([r, c]) => `  ${r}: ${c}`).join('\n');
        }
        return out;
      }
      if (args.action === 'build') {
        return (async () => {
          const result = await kg.buildFromMemories();
          return `Graph built from memories+experiences.\nExtracted: ${result.extracted} entities\nTotal: ${result.entities} entities, ${result.edges} edges`;
        })();
      }
      if (args.action === 'subgraph') {
        const name = args.name || args.query;
        if (!name) return 'Provide entity name or query for subgraph.';
        const hops = parseInt(args.hops) || 2;
        const sub = kg.getSubgraph(name, hops);
        if (sub.entities.length === 0) return `No entity found matching '${name}'.`;
        let out = `Subgraph around "${name}" (${hops} hops):\n\nEntities (${sub.entities.length}):\n`;
        out += sub.entities.map(e => `  ${e.name} (${e.type})`).join('\n');
        if (sub.edges.length > 0) {
          out += `\n\nEdges (${sub.edges.length}):\n`;
          const graph = kg.loadGraph();
          out += sub.edges.map(e => {
            const src = graph.entities[e.source]?.name || e.source;
            const tgt = graph.entities[e.target]?.name || e.target;
            return `  ${src} —[${e.relation}]→ ${tgt}`;
          }).join('\n');
        }
        return out;
      }
      return 'Unknown action. Use: query, add_entity, add_edge, remove, stats, build, or subgraph';
    }

    // macOS native Mail, Calendar, Contacts (zero-config)
    case 'apple_mail': return mailTool.mail(args);
    case 'apple_calendar': return calendarTool.calendar(args);
    case 'apple_contacts': return contactsTool.contacts(args);
    case 'spotlight': return spotlightTool.spotlight(args);
    case 'apple_reminders': return remindersTool.reminders(args);
    case 'apple_notes': return notesTool.notes(args);

    // PDF Export (artifact → PDF via Puppeteer)
    case 'pdf_export': {
      // Find the artifact to export — by title search or explicit path
      const artTitle = args.title || 'latest';
      const artPath = args.path; // optional explicit web path
      let webPath = artPath;

      if (!webPath) {
        // Search artifacts directory for a matching HTML file
        const artDir = require('./constants').ARTIFACTS_DIR;
        const files = fs.readdirSync(artDir)
          .filter(f => f.endsWith('.html'))
          .map(f => ({ name: f, mtime: fs.statSync(path.join(artDir, f)).mtimeMs }))
          .sort((a, b) => b.mtime - a.mtime);

        if (artTitle.toLowerCase() === 'latest') {
          if (files.length === 0) return 'No HTML artifacts found to export.';
          webPath = `/artifacts/${files[0].name}`;
        } else {
          const slug = artTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-');
          const match = files.find(f => f.name.includes(slug));
          if (match) {
            webPath = `/artifacts/${match.name}`;
          } else if (files.length > 0) {
            webPath = `/artifacts/${files[0].name}`;
          } else {
            return `No artifact found matching "${artTitle}".`;
          }
        }
      }

      const result = await exportTool.exportPdf(webPath, artTitle);
      return `PDF exported: ${result.filename}\nDownload: ${result.webPath}`;
    }

    // Documents
    case 'document': {
      // Parse sheets if passed as JSON string
      if (args.sheets && typeof args.sheets === 'string') {
        try { args.sheets = JSON.parse(args.sheets); } catch {}
      }
      return documentTool.createDocument(args);
    }

    default:
      // Check if this is a custom (self-architected) tool
      if (customTools.isCustomTool(name)) {
        return customTools.executeCustomTool(name.slice(7), args); // strip 'custom_' prefix
      }
      // Check if this is an MCP tool
      if (mcp.isMCPTool(name)) {
        return mcp.callTool(name, args);
      }
      return `Error: unknown tool '${name}'. Available tools: bash, read_file, list_dir, glob, grep, file_write, file_edit, web_fetch, web_search, memory_save, memory_search, memory_list, memory_delete, system_info, image_generate, cron, spawn_agent, spawn_multi, list_agents, rag, github, jira, confluence, servicenow, smartsheet, slack, sharepoint, gmail, gdrive, gdocs, telegram, artifact, document, pdf_export, apple_mail, apple_calendar, apple_contacts, spotlight, apple_reminders, apple_notes`;
  }
}

/**
 * Run the full tool execution loop for a chat turn.
 * Streams the response, executes tool calls, sends follow-ups.
 *
 * @param {Object} opts
 * @param {string} opts.sessionId
 * @param {string} opts.cwd - Working directory
 * @param {Function} opts.send - Send WS message to client
 * @param {AbortSignal} opts.signal
 * @param {Function} opts.onConfirmRequest - Ask client to confirm dangerous tool
 * @returns {Promise<void>}
 */
// Slot-based inference queue — replaces the old binary waitForIdle() gate.
// User requests get priority slot 0; background work runs in slot 1+ when
// VRAM permits (2 slots on 64GB+ systems, 1 slot otherwise).
const { slotManager } = require('./inference-queue');

// Backward-compat exports for callers that still use the old API
function isUserLoopActive() { return slotManager.isUserActive(); }
function waitForIdle() { return slotManager.waitForIdle(); }

async function runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage, needsTitle, maxTurns, _forceLocal, _escalate, onCheckpoint, skipTurns, _slotHandle }) {
  // Acquire an inference slot unless the caller already holds one (background-agents, cron-runner)
  const isUserLoop = !sessionId.startsWith('cron:') && !sessionId.startsWith('mcp:');
  let slotHandle;
  if (_slotHandle) {
    slotHandle = _slotHandle;
  } else {
    const priority = isUserLoop ? 'user' : sessionId.startsWith('cron:') ? 'cron' : 'background';
    slotHandle = await slotManager.acquireSlot(priority, signal);
  }

  // Wrap send to let Argus observe all events (fire-and-forget, never blocks)
  const originalSend = send;
  send = (event) => {
    originalSend(event);
    argus.observeEvent(event);
  };
  // Give Argus the user's prompt for conversational context
  if (userMessage) {
    argus.observeEvent({ type: 'user_message', content: userMessage });
  }

  // Signal Argus that the tool loop is active — suppresses deferred reactions
  // that would compete with Ollama inference during the loop.
  argus.setToolLoopActive(true);

  const turnLimit = maxTurns || MAX_TOOL_TURNS;
  let tokensIn = 0;
  let tokensOut = 0;
  let pendingScreenshots = []; // Screenshots from computer/browser tools, injected transiently
  let usedComputerUse = false; // Track if computer use was invoked for completion notification

  // Progressive tool disclosure: auto-activate domains from user message keywords
  const activeDomains = tiers.getActiveDomains(sessionId);
  if (userMessage) {
    tiers.resolveKeywords(sessionId, userMessage);
  }
  let pendingClickWarning = null; // Click-loop warning to inject with next screenshot
  let emptyResponseNudged = false; // One-shot: nudge model if thinking consumed entire budget

  // Build system prompt once — it doesn't change between tool-loop turns.
  // Turn 0 uses async version (relevance-ranked memory/experience), cached for reuse.
  let cachedSystemPrompt = null;
  // Tool definitions are rebuilt only when domains change (via request_tools).
  let cachedTools = null;
  let toolDefsStale = true; // Start stale so first turn builds

  try { // eslint-disable-line -- try/finally ensures slot release on abort
  for (let turn = 0; turn < turnLimit; turn++) {
    if (signal?.aborted) break;

    // Smart Interrupt: skip already-completed turns when resuming from checkpoint.
    // Session JSONL already has the messages from prior turns, so we just need
    // the model to see them and continue from where it left off.
    if (skipTurns && turn < skipTurns) {
      console.log(`[tool-loop] Skipping turn ${turn} (checkpoint resume, ${skipTurns} turns already completed)`);
      continue;
    }

    if (!cachedSystemPrompt) {
      cachedSystemPrompt = userMessage
        ? await buildSystemPromptAsync(cwd, userMessage)
        : buildSystemPrompt(cwd);
    }
    const systemPrompt = cachedSystemPrompt;
    const history = getSessionMessages(sessionId);
    // Compress old turns if approaching context limit (runs only when needed)
    const compressedHistory = await compression.compressIfNeeded(sessionId, history, activeDomains.size);
    const messages = [
      { role: 'system', content: systemPrompt },
      ...compressedHistory,
    ];

    // Inject pending screenshots as a transient user message (not persisted to session).
    // Ollama only processes images on user role messages. This message is ephemeral —
    // it gets the model to see the screenshot without polluting the session history.
    if (pendingScreenshots.length > 0) {
      let screenshotPrompt = '[Screenshot captured by the computer tool is attached. Analyze the image and continue completing the task by calling tools. Do not just describe what you see — take action.';
      if (pendingClickWarning) {
        screenshotPrompt += `\n\n${pendingClickWarning}`;
        pendingClickWarning = null;
      }
      screenshotPrompt += ']';
      messages.push({
        role: 'user',
        content: screenshotPrompt,
        images: pendingScreenshots,
      });
      pendingScreenshots = [];
    }

    if (toolDefsStale || !cachedTools) {
      cachedTools = buildToolDefs({ activeDomains });
      toolDefsStale = false;
    }
    const tools = cachedTools;

    // Budget must be generous — Gemma 4 extended thinking consumes num_predict
    // before producing visible response tokens. With 128K context and free local
    // inference, we maximize output capacity so reports and research are thorough.
    // Remote providers get capped to their configured max_tokens to control costs.
    const provider = getActiveProvider();
    // Doubled from 32768/49152 so research-style replies have headroom to be
    // detailed instead of pithy. Capped at 65536 on subsequent turns to leave
    // room in the 128K context for accumulated tool results + thinking.
    const baseMax = turn === 0 ? 65536 : 65536;
    // Escalation overrides provider routing — treat its remote-shaped (OpenAI-compat)
    // dispatcher as local for budgeting purposes, and skip thinking (most escalation
    // models aren't Gemma and don't speak its think-tag protocol).
    const isRemote = !_forceLocal && !_escalate && (provider.type === 'openai' || provider.type === 'azure' || provider.type === 'anthropic' || provider.type === 'gemini');
    const maxTokens = isRemote
      ? Math.min(baseMax, provider.max_tokens || 4096)
      : baseMax;

    const result = await streamChat({
      messages,
      tools,
      maxTokens,
      signal,
      forceLocal: _forceLocal || false,
      escalate: _escalate || false,
      onToken: (event) => send(event),
      // Lower temperature for tool-calling turns reduces hallucinated tool names
      // and inconsistent parameter formatting. Modelfile default is 1.0 (creative),
      // but agent mode benefits from deterministic tool selection.
      extraOptions: { temperature: 0.4 },
      // Disable thinking for remote providers and escalation models (most aren't
      // Gemma and don't speak its think-tag protocol)
      ...(isRemote || _escalate ? { think: false } : {}),
    });

    // Update token counts
    tokensIn += result.stats.prompt_eval_count || 0;
    tokensOut += result.stats.eval_count || 0;

    // Calibrate token estimator with actual Ollama counts
    compression.calibrate(messages, result.stats.prompt_eval_count);

    // Title generation deferred to after tool loop (see background tasks below)
    // to avoid GPU contention with main inference.

    // Fallback: parse <tool_call> tags from text if no native tool calls
    const hasNative = result.toolCalls && result.toolCalls.length > 0;
    const hasTag = result.response && result.response.includes('<tool_call>');
    console.log(`[tool-loop] turn=${turn} hasNative=${hasNative} hasTag=${hasTag} responseLen=${(result.response||'').length}`);
    if (!hasNative && result.response) {
      const textCalls = parseTextToolCalls(result.response);
      if (textCalls) {
        console.log(`[tool-loop] Parsed ${textCalls.length} text tool call(s):`, textCalls.map(c => c.function?.name));
        result.toolCalls = textCalls;
        // Strip tool call text from displayed response
        result.response = stripToolCallText(result.response);
        // Notify client about parsed tool calls
        send({ type: 'tool_calls', calls: textCalls });
      }
    }

    // Save assistant message
    if (result.response || result.toolCalls) {
      const assistantMsg = { role: 'assistant', content: result.response || '' };
      if (result.toolCalls) {
        assistantMsg.tool_calls = result.toolCalls;
      }
      appendMessage(sessionId, assistantMsg);
    }

    // No tool calls — check for cases where the model needs a nudge before finishing.
    if (!result.toolCalls || result.toolCalls.length === 0) {
      const responseText = (result.response || '').trim();
      const thinkingLen = (result.thinking || '').length;

      // (#A) Empty response after substantial thinking — the model put the entire
      // analysis in the thinking block and produced no visible output. One-shot nudge
      // to get it to write the actual response. Only fires once per conversation.
      if (!responseText && thinkingLen > 500 && !emptyResponseNudged) {
        emptyResponseNudged = true;
        console.log(`[tool-loop] Empty response with ${thinkingLen} chars of thinking — nudging for visible output`);
        messages.push({ role: 'assistant', content: '(thinking completed)' });
        messages.push({ role: 'user', content: 'Please write your full response now. Your analysis should be visible to me, not just in your reasoning.' });
        send({ type: 'done_partial', stats: result.stats });
        continue;
      }

      // (#B) Unfulfilled artifact intent — model plans to create an artifact but stops.
      if (turn === 1 && responseText) {
        const resp = responseText.toLowerCase();
        const mentionsArtifact = /\b(artifact|dashboard|html|webpage|visualization)\b/.test(resp);
        const mentionsIntent = /\b(will (write|create|build|generate|produce|make)|let me (create|build|write)|now (create|write|build)|here'?s the)\b/.test(resp);
        if (mentionsArtifact && mentionsIntent) {
          console.log('[tool-loop] Detected unfulfilled artifact intent — sending continuation nudge');
          messages.push({ role: 'assistant', content: responseText });
          messages.push({ role: 'user', content: 'Go ahead — call the artifact tool now with the full HTML content.' });
          send({ type: 'done_partial', stats: result.stats });
          continue;
        }
      }

      send({
        type: 'done',
        stats: {
          ...result.stats,
          // Override with accumulated totals across all tool loop turns
          eval_count: tokensOut,
          prompt_eval_count: tokensIn,
        },
        context: {
          used: tokensIn + tokensOut,
          max: MODEL_CTX,
          pct: Math.round((tokensIn + tokensOut) * 100 / MODEL_CTX),
        },
      });
      // When computer use session completes: notify and restore focus
      if (usedComputerUse) {
        try {
          const { execSync } = require('child_process');
          // Sanitize for AppleScript — strip all quotes and special chars
          const preview = (result.response || '')
            .replace(/["""''`]/g, "'")
            .replace(/\\/g, '')
            .replace(/[\n\r]/g, ' ')
            .slice(0, 120);
          execSync(`osascript -e 'display notification "${preview}" with title "PRE — Task Complete" sound name "Glass"'`, { timeout: 3000 });
        } catch (err) {
          console.log(`[computer] notification failed: ${err.message}`);
        }
        // Restore focus to the app that was active before computer use (browser/terminal)
        try {
          computerTool.restoreFocus();
        } catch {}
      }
      // Background intelligence tasks — serialized to avoid GPU contention.
      // Each calls Ollama; running them concurrently halves throughput on a single GPU.
      // Chained sequentially: title → memory → experience → skills. Non-blocking.
      const historyForExtract = getSessionMessages(sessionId);
      const skills = require('./skills');
      (async () => {
        // Session title generation (deferred from turn 0 to avoid GPU contention)
        if (needsTitle && userMessage) {
          try {
            await generateSessionTitle(sessionId, userMessage, send);
          } catch (err) {
            console.log(`[title-gen] Deferred error: ${err.message}`);
          }
        }
        try {
          const saved = await autoExtract(historyForExtract);
          if (saved.length > 0) {
            console.log(`[memory-extract] Auto-saved ${saved.length} memory(ies)`);
            send({ type: 'memory_saved', memories: saved.map(m => ({ name: m.name, type: m.type })) });
          }
        } catch (err) {
          console.log(`[memory-extract] Background error: ${err.message}`);
        }
        try {
          const saved = await experience.reflect(historyForExtract, { sessionId, cwd });
          if (saved.length > 0) {
            console.log(`[experience] Saved ${saved.length} lesson(s)`);
            send({ type: 'experience_saved', lessons: saved.map(l => ({ name: l.name, lesson: l.lesson })) });
          }
        } catch (err) {
          console.log(`[experience] Background error: ${err.message}`);
        }
        try {
          const created = await skills.analyzeForSkills(historyForExtract, { sessionId, cwd });
          if (created.length > 0) {
            console.log(`[skills] Auto-created ${created.length} skill(s)`);
            send({ type: 'skill_created', skills: created.map(s => ({ name: s.name, description: s.description })) });
          }
        } catch (err) {
          console.log(`[skills] Background error: ${err.message}`);
        }
        try {
          const { autoExtractGraph } = require('./knowledge-graph');
          await autoExtractGraph(historyForExtract);
        } catch (err) {
          console.log(`[knowledge-graph] Background error: ${err.message}`);
        }
      })();
      argus.setToolLoopActive(false);
      slotHandle.release();
      return;
    }

    // Execute tool calls
    send({ type: 'done_partial', stats: result.stats });

    // Normalize tool names: strip domain prefix, resolve aliases
    for (const tc of result.toolCalls) {
      if (tc.function?.name) {
        let n = tc.function.name;
        if (n.includes(':')) n = n.split(':').pop(); // strip "media:image_generate" → "image_generate"
        n = ALIASES[n] || n;
        tc.function.name = n;
      }
    }

    // Structured repair: if any tool call has empty/missing required arguments,
    // attempt format-constrained re-parse via Ollama's structured output.
    for (const tc of result.toolCalls) {
      const args = tc.function?.arguments;
      const name = tc.function?.name;
      if (!name || !args) continue;
      // Check if arguments are empty but the tool has required params
      const toolDef = cachedTools?.find(t => t.function?.name === name);
      const required = toolDef?.function?.parameters?.required;
      if (required && required.length > 0) {
        const hasRequired = required.some(k => args[k] !== undefined);
        if (!hasRequired && result.response) {
          // Arguments are missing required fields — attempt structured repair
          const rawText = result.response;
          const repaired = await repairToolArgsStructured(name, rawText, cachedTools);
          if (repaired) {
            tc.function.arguments = repaired;
          }
        }
      }
    }

    // Deduplicate tool calls — model sometimes emits the same call twice in one turn.
    // Compare by name + JSON-serialized args; keep the first occurrence.
    // Also cap at 2 calls to the same tool per turn to prevent retry-spam loops.
    const seenCalls = new Set();
    const toolNameCounts = {};
    const MAX_SAME_TOOL_PER_TURN = 2;
    const dedupedCalls = result.toolCalls.filter(tc => {
      const name = tc.function?.name || '';
      const args = JSON.stringify(tc.function?.arguments || {});
      const key = `${name}::${args}`;
      if (seenCalls.has(key)) {
        console.log(`[tool-loop] Skipping duplicate tool call: ${name}`);
        return false;
      }
      seenCalls.add(key);
      // Cap same-tool calls per turn (e.g. model retrying image_generate 5x)
      toolNameCounts[name] = (toolNameCounts[name] || 0) + 1;
      if (toolNameCounts[name] > MAX_SAME_TOOL_PER_TURN) {
        console.log(`[tool-loop] Capping ${name} at ${MAX_SAME_TOOL_PER_TURN} calls per turn`);
        return false;
      }
      return true;
    });

    // Tools that must run sequentially (confirmation, vision loop, state mutation)
    const SEQUENTIAL_TOOLS = new Set([
      'request_tools', 'computer', 'browser', 'screenshot',
      'process_kill', 'memory_delete',
    ]);

    // Determine if all tools in this batch can run in parallel.
    // Parallel requires: 2+ tools, none sequential, none needing confirmation.
    const resolvedCalls = dedupedCalls.map(tc => ({
      tc,
      name: ALIASES[tc.function?.name] || tc.function?.name || 'unknown',
      args: tc.function?.arguments || {},
    }));

    const canParallelize = resolvedCalls.length >= 2 && resolvedCalls.every(({ name, args }) =>
      !SEQUENTIAL_TOOLS.has(name)
      && !CONFIRM_TOOLS.has(name)
      && !(CONFIRM_ACTIONS[name] && CONFIRM_ACTIONS[name].has(args.action))
    );

    // Execute a single tool call and return its result object.
    // Shared between parallel and sequential paths.
    async function executeOne({ name: toolName, args: toolArgs }, toolId) {
      // Run pre_tool hooks (can block)
      const preHook = hooks.runHooks('pre_tool', { tool: toolName, args: toolArgs, sessionId, cwd });
      if (preHook.blocked) {
        send({ type: 'tool_result', id: toolId, name: toolName, output: `Blocked by hook: ${preHook.reason}`, status: 'blocked' });
        telemetry.record({ session: sessionId, tool: toolName, args: toolArgs, outcome: 'blocked', latency_ms: 0, tier: _sessionTiers.get(sessionId) });
        return { name: toolName, output: `Blocked by hook: ${preHook.reason}` };
      }

      // Execute the tool (with abort-race so Stop cancels hung tools)
      const t0 = Date.now();
      let output;
      let outcome = 'ok';
      let errorClass = null;
      try {
        const toolPromise = executeTool(toolName, toolArgs, cwd, {
          onStatus: (event) => send({ type: 'agent_status', ...event }),
        });
        if (signal) {
          const abortPromise = new Promise((_, reject) => {
            if (signal.aborted) return reject(new Error('Cancelled by user'));
            signal.addEventListener('abort', () => reject(new Error('Cancelled by user')), { once: true });
          });
          output = await Promise.race([toolPromise, abortPromise]);
        } else {
          output = await toolPromise;
        }
        if (typeof output === 'string' && output.startsWith('Error: ')) {
          outcome = 'error';
          errorClass = output.slice(7, 80).split('\n')[0];
        }
      } catch (err) {
        output = `Error: ${err.message}`;
        outcome = err.message === 'Cancelled by user' ? 'cancelled' : 'error';
        errorClass = outcome === 'cancelled' ? null : err.constructor.name;
      }
      telemetry.record({ session: sessionId, tool: toolName, args: toolArgs, outcome, latency_ms: Date.now() - t0, result: output, tier: _sessionTiers.get(sessionId), error_class: errorClass });

      // Run post_tool hooks (non-blocking, for logging/auditing)
      hooks.runHooks('post_tool', { tool: toolName, args: toolArgs, output: output?.slice(0, 4000), sessionId, cwd });

      if (signal?.aborted) {
        send({ type: 'tool_result', id: toolId, name: toolName, output: 'Cancelled by user', status: 'cancelled' });
        return null; // signals abort
      }

      // Post-processing: images, artifacts, PDFs, screenshots
      let browserScreenshot = null;

      if (toolName === 'image_generate' && output && output.includes('/artifacts/')) {
        const urlMatch = output.match(/View at: (\/artifacts\/[^\s]+)/);
        if (urlMatch) {
          const event = { type: 'image_generated', prompt: toolArgs.prompt || '', path: urlMatch[1] };
          send(event);
          appendMessage(sessionId, { role: 'display', display: 'image', prompt: event.prompt, path: event.path });
        }
      }

      if ((toolName === 'artifact' || toolName === 'document') && output && output.includes('/artifacts/')) {
        const urlMatch = output.match(/\/artifacts\/[^\s]+/);
        if (urlMatch) {
          const isDoc = toolName === 'document';
          const event = {
            type: isDoc ? 'document' : 'artifact',
            title: toolArgs.title || (isDoc ? 'Document' : 'Artifact'),
            path: urlMatch[0],
            artifactType: isDoc ? (toolArgs.format || 'txt') : (toolArgs.type || 'html'),
          };
          send(event);
          appendMessage(sessionId, { role: 'display', display: isDoc ? 'document' : 'artifact', title: event.title, path: event.path, artifactType: event.artifactType });
        }
      }

      if (toolName === 'pdf_export' && output && output.includes('/artifacts/')) {
        const urlMatch = output.match(/Download: (\/artifacts\/[^\s]+)/);
        if (urlMatch) {
          const event = {
            type: 'document',
            title: `${toolArgs.title || 'Export'} (PDF)`,
            path: urlMatch[1],
            artifactType: 'pdf',
          };
          send(event);
          appendMessage(sessionId, { role: 'display', display: 'document', title: event.title, path: event.path, artifactType: 'pdf' });
        }
      }

      if (toolName === 'computer' || toolName === 'screenshot') {
        usedComputerUse = true;
      }

      if ((toolName === 'browser' || toolName === 'computer' || toolName === 'screenshot') && output) {
        try {
          const parsed = JSON.parse(output);
          if (parsed.screenshot) {
            browserScreenshot = parsed.screenshot;
            send({ type: 'browser_screenshot', id: toolId, screenshot: parsed.screenshot, message: parsed.message });
            delete parsed.screenshot;
            output = JSON.stringify(parsed);
          }
        } catch {}
      }

      // Truncate very large outputs
      const MAX_OUTPUT = 32000;
      if (output.length > MAX_OUTPUT) {
        output = output.slice(0, MAX_OUTPUT) + `\n\n[...truncated ${output.length - MAX_OUTPUT} bytes]`;
      }

      send({
        type: 'tool_result',
        id: toolId,
        name: toolName,
        output: output.slice(0, 2000),
        status: 'done',
      });

      return { name: toolName, output, image: browserScreenshot };
    }

    const toolResults = [];

    if (canParallelize) {
      // ── Parallel execution: all tools run concurrently ──
      console.log(`[tool-loop] Executing ${resolvedCalls.length} tools in parallel`);
      const ids = resolvedCalls.map((_, i) =>
        `tc_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`
      );
      // Notify client about all calls upfront
      for (let i = 0; i < resolvedCalls.length; i++) {
        send({ type: 'tool_call', name: resolvedCalls[i].name, args: resolvedCalls[i].args, id: ids[i], status: 'running' });
      }
      const promises = resolvedCalls.map((rc, i) => executeOne(rc, ids[i]));
      const results = await Promise.all(promises);
      for (const r of results) {
        if (r === null) break; // aborted
        toolResults.push(r);
      }
    } else {
      // ── Sequential execution: handles confirmation, vision, request_tools ──
      for (const rc of resolvedCalls) {
        if (signal?.aborted) break;

        const { name: toolName, args: toolArgs } = rc;
        const toolId = `tc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;

        send({ type: 'tool_call', name: toolName, args: toolArgs, id: toolId, status: 'running' });

        // Check if confirmation is needed
        const needsConfirm = CONFIRM_TOOLS.has(toolName)
          || (CONFIRM_ACTIONS[toolName] && CONFIRM_ACTIONS[toolName].has(toolArgs.action));
        if (needsConfirm) {
          if (onConfirmRequest) {
            const approved = await onConfirmRequest(toolId, toolName, toolArgs);
            if (!approved) {
              send({ type: 'tool_result', id: toolId, name: toolName, output: 'Skipped by user', status: 'skipped' });
              toolResults.push({ name: toolName, output: 'Tool execution skipped by user.' });
              continue;
            }
          }
        }

        // Progressive disclosure: handle request_tools inline
        if (toolName === 'request_tools') {
          const domain = (toolArgs.domain || '').toLowerCase();
          const activated = tiers.activateDomain(sessionId, domain);
          if (!activated) {
            const available = tiers.listDomains().map(d => d.name).join(', ');
            const output = `Error: unknown domain "${domain}". Available: ${available}, all`;
            send({ type: 'tool_result', id: toolId, name: toolName, output, status: 'done' });
            toolResults.push({ name: toolName, output });
            continue;
          }
          const newTools = [];
          for (const d of activated) {
            newTools.push(...tiers.domainToolList(d));
          }
          const output = `Activated domain(s): ${activated.join(', ')}\nNewly available tools: ${newTools.join(', ')}\n\nYou can now use these tools in this conversation.`;
          send({ type: 'tool_result', id: toolId, name: toolName, output, status: 'done' });
          toolResults.push({ name: toolName, output });
          toolDefsStale = true; // Rebuild tool defs on next turn to include new domain
          continue;
        }

        const r = await executeOne(rc, toolId);
        if (r === null) break; // aborted
        toolResults.push(r);

        // Vision tools: break so the model sees the screenshot before next action
        if (r.image && result.toolCalls.length > 1) {
          break;
        }
      }
    }

    // Save tool results to session (combined format matching CLI)
    const combinedResult = toolResults
      .map(r => `<tool_response name="${r.name}">\n${r.output}</tool_response>`)
      .join('\n');
    // Build a mapping from tool name to call ID for remote providers that need it
    // (Anthropic requires tool_result.tool_use_id to match tool_use.id exactly)
    const callIdMap = {};
    for (const tc of dedupedCalls) {
      const name = tc.tc?.function?.name || tc.name;
      const id = tc.tc?.id;
      if (name && id) callIdMap[name] = id;
    }
    const toolMsg = {
      role: 'tool',
      content: combinedResult,
      // Store individual results with IDs for Anthropic message conversion
      ...(Object.keys(callIdMap).length > 0 ? {
        _tool_results: toolResults.map(r => ({
          name: r.name,
          output: r.output,
          tool_call_id: callIdMap[r.name] || null,
        })),
      } : {}),
    };
    appendMessage(sessionId, toolMsg);

    // Calibration: record tool success/failure for self-knowledge
    try {
      const calibration = require('./calibration');
      for (const r of toolResults) {
        const isError = r.output && (r.output.startsWith('Error:') || r.output.includes('Error:'));
        calibration.recordToolOutcome(r.name, !isError);
      }
    } catch (_e) {}

    // Smart Interrupt: checkpoint after each tool turn completes.
    // Background agents use this to record progress for resume after sleep/crash.
    if (onCheckpoint) {
      try { onCheckpoint(turn + 1); } catch {}
    }

    // Collect screenshots for transient injection on the next loop iteration.
    // NOT persisted to session — injected into the messages array at call time only.
    const screenshots = toolResults.filter(r => r.image).map(r => r.image);
    if (screenshots.length > 0) {
      pendingScreenshots = screenshots;
      // Check for click-loop warnings/blocks in computer tool results
      for (const r of toolResults) {
        if (r.name === 'computer' && r.output) {
          try {
            const parsed = JSON.parse(r.output);
            if (parsed.blocked) {
              // Hard block — force this into the transient user message
              pendingClickWarning = parsed.message;
            } else if (parsed.message && (parsed.message.includes('WARNING:') || parsed.message.includes('CLICK BLOCKED'))) {
              pendingClickWarning = parsed.message.slice(parsed.message.indexOf('⚠️'));
            }
          } catch {}
        }
      }
    }

    // Start next iteration (model sees tool results and may call more tools)
  }

  // Loop ended without a natural `done` — either max turns or signal abort
  if (signal?.aborted) {
    send({ type: 'done', stats: {}, context: { used: tokensIn + tokensOut, max: MODEL_CTX, pct: Math.round((tokensIn + tokensOut) * 100 / MODEL_CTX) }, aborted: true });
  } else {
    send({ type: 'error', message: `Tool loop reached maximum of ${turnLimit} turns` });
  }
  } finally {
    // Always release resources — even if streamChat/executeTool threw (e.g. abort signal)
    argus.setToolLoopActive(false);
    slotHandle.release();
  }
}

/**
 * Route a user message through the multi-model router, then dispatch.
 *
 * For fast tier: calls streamChat directly with the small model, no tools, minimal prompt.
 * For standard/frontier: delegates to runToolLoop (full agent).
 *
 * This function is the primary entry point from server.js for user messages.
 * It wraps runToolLoop and is a drop-in replacement.
 */
async function routeAndRespond({ sessionId, cwd, send, signal, onConfirmRequest, userMessage, needsTitle, _escalate }) {
  // Per-message escalation: explicit override of tier routing. The WS handler
  // sets _escalate from the UI toggle and strips a leading /escalate prefix
  // before persisting the message, but we still re-check here in case a caller
  // passed the raw message (MCP, REST, slash-command path).
  let escalateRequested = !!_escalate;
  if (typeof userMessage === 'string') {
    const m = userMessage.match(/^\/escalate\b\s*([\s\S]*)$/);
    if (m) {
      escalateRequested = true;
      userMessage = m[1].trim();
    }
  }
  if (escalateRequested) {
    const escalation = require('./escalation');
    const status = await escalation.getStatus();
    if (!status.available) {
      // Surface the reason and fall through to normal routing so the user
      // still gets a response from the default model.
      send({ type: 'route', tier: 'standard', reason: `escalation unavailable: ${status.reason}` });
      escalateRequested = false;
    } else {
      send({
        type: 'route',
        tier: 'standard',
        reason: `escalated to ${status.label}`,
        model: status.label,
        escalated: true,
      });
      _sessionTiers.set(sessionId, 'standard');
      return runToolLoop({
        sessionId, cwd, send, signal, onConfirmRequest,
        userMessage, needsTitle,
        _escalate: true,
      });
    }
  }

  const history = getSessionMessages(sessionId);
  const hasHistory = history.length > 2; // system + at least one exchange
  const lastTier = _sessionTiers.get(sessionId) || 'standard';

  // Calibration: detect user feedback on previous turn (corrections/praise)
  if (userMessage && hasHistory) {
    try {
      const calibration = require('./calibration');
      // Find the most recent assistant turn's tool calls
      const recentTools = [];
      for (let i = history.length - 1; i >= 0; i--) {
        if (history[i].role === 'assistant' && history[i].tool_calls) {
          for (const tc of history[i].tool_calls) {
            const name = tc.function?.name || '';
            if (name) recentTools.push(name);
          }
          break;
        }
      }
      if (recentTools.length > 0) {
        calibration.detectFeedback(userMessage, recentTools);
      }
    } catch (_e) {}
  }

  const classification = router.classify(userMessage || '', { hasHistory, lastTier });
  const tier = router.getTier(classification.tier);

  // Track tier for this session
  _sessionTiers.set(sessionId, classification.tier);

  // Use the stripped message (tier override prefix removed) if applicable
  const effectiveMessage = classification.strippedMessage || userMessage;

  // Notify client of routing decision (include model name for badge display).
  // For frontier, prefer the configured override's model so the badge reflects
  // what will actually serve the request (e.g. "gemini-3.5-flash") rather than
  // the active provider's model.
  const routeModel = classification.tier === 'fast'
    ? (router.getModelForTier('fast') || 'pre-gemma4')
    : classification.tier === 'frontier'
      ? ((router.getFrontierProvider() || getProvider()).model || 'remote')
      : 'pre-gemma4';
  send({ type: 'route', tier: classification.tier, reason: classification.reason, model: routeModel });
  console.log(`[router] session=${sessionId} tier=${classification.tier} reason="${classification.reason}"`);

  // ── Fast tier: direct response, no tools ──
  if (classification.tier === 'fast') {
    const fastModel = router.getModelForTier('fast');
    if (!fastModel) {
      // Fallback to standard if no fast model configured
      console.log('[router] No fast model configured, falling back to standard');
      return runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
    }

    try {
      // Minimal system prompt for fast tier
      const now = new Date();
      const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
      const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
      const dateStr = `${days[now.getDay()]}, ${months[now.getMonth()]} ${now.getDate()}, ${now.getFullYear()}`;

      const fastSystemPrompt = `You are PRE (Personal Reasoning Engine), a helpful local AI assistant. `
        + `Be concise and direct. Today is ${dateStr}.\n`
        + `If the user asks you to do something that requires file access, code execution, web search, `
        + `or other tool use, say: "Let me switch to full mode for that." and nothing else.`;

      // Include recent history for context continuity
      const recentHistory = history.slice(-6); // last 3 exchanges
      const messages = [
        { role: 'system', content: fastSystemPrompt },
        ...recentHistory,
      ];

      // Append user message if not already in history
      const lastMsg = messages[messages.length - 1];
      if (!lastMsg || lastMsg.role !== 'user' || lastMsg.content !== effectiveMessage) {
        messages.push({ role: 'user', content: effectiveMessage });
      }

      const result = await streamChat({
        messages,
        maxTokens: 2048,
        think: false,
        model: fastModel,
        signal,
        forceLocal: true, // Always use Ollama for fast tier, even if remote provider is active
        onToken: (event) => send(event),
      });

      // Save to session history
      if (result.response) {
        appendMessage(sessionId, { role: 'assistant', content: result.response });
      }

      // Check if the fast model is asking to escalate, or if it refused a task
      // that likely needs tools (the 2B model often gives generic refusals instead
      // of the instructed "switch to full mode" phrase)
      const needsEscalation = result.response && (
        /let me switch to full mode|need.*(tools|full mode)|can't do that in fast/i.test(result.response) ||
        /\b(cannot|can't|unable to|don't have (the )?ability|not able to|I('m| am) (a |an )?(language model|AI|text))\b.*\b(open|launch|access|browse|search|execute|run|file|app|screen|click|download|install|send|email|read|write|create|delete)\b/i.test(result.response) ||
        /\b(open|launch|access|browse|search|execute|run|send|read|write|create|delete)\b.*\b(cannot|can't|unable|not (able|possible|capable)|don't have|no access)\b/i.test(result.response)
      );

      if (needsEscalation && router.getConfig().auto_escalate) {
        console.log('[router] Fast tier auto-escalating to standard');
        _sessionTiers.set(sessionId, 'standard');
        send({ type: 'route', tier: 'standard', reason: 'auto-escalated from fast' });
        return runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
      }

      send({
        type: 'done',
        stats: result.stats,
        context: {
          used: (result.stats.prompt_eval_count || 0) + (result.stats.eval_count || 0),
          max: MODEL_CTX,
          pct: Math.round(((result.stats.prompt_eval_count || 0) + (result.stats.eval_count || 0)) * 100 / MODEL_CTX),
        },
      });

      // Title generation for fast tier (same background approach)
      if (needsTitle && effectiveMessage) {
        generateSessionTitle(sessionId, effectiveMessage, send).catch(() => {});
      }
    } catch (err) {
      // On fast tier error, fall back to standard
      console.log(`[router] Fast tier error: ${err.message}, falling back to standard`);
      _sessionTiers.set(sessionId, 'standard');
      return runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
    }
    return;
  }

  // ── Frontier tier: use remote provider via standard tool loop ──
  // Provider precedence for frontier requests:
  //   1. router.frontier_provider (e.g. 'gemini') — saved sub-config, lets the
  //      user keep Ollama as default while frontier goes to Gemini Flash
  //   2. active provider (legacy: frontier "inherits" whatever's selected)
  //   3. fall back to standard if neither path yields a remote provider
  if (classification.tier === 'frontier') {
    const frontierOverride = router.getFrontierProvider();
    const provider = frontierOverride || getProvider();
    if (provider.type === 'ollama') {
      console.log('[router] Frontier requested but no remote provider configured, using standard');
      _sessionTiers.set(sessionId, 'standard');
      send({ type: 'route', tier: 'standard', reason: 'no remote provider — using local model' });
      return runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
    }

    // Frontier with remote provider — wrap with graceful degradation.
    // If the remote call fails (network, billing, auth, rate limit), fall back to local Ollama.
    const runFrontier = () => runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
    try {
      return await (frontierOverride
        ? runWithProviderOverride(frontierOverride, runFrontier)
        : runFrontier());
    } catch (err) {
      // Don't degrade if the user manually cancelled
      if (err.message === 'Request aborted') throw err;

      const isRemoteError = /Remote API error|Anthropic API error|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|socket hang up|fetch failed|HTTP [45]\d\d/i.test(err.message);
      if (isRemoteError) {
        console.log(`[router] Frontier failed (${err.message}), degrading to local Ollama`);
        _sessionTiers.set(sessionId, 'standard');
        send({ type: 'route', tier: 'standard', reason: `frontier unavailable — ${err.message.slice(0, 80)}` });
        // Retry with forceLocal by temporarily clearing the provider's effect.
        // We achieve this by passing the request through runToolLoop which calls
        // streamChat. We set a flag on the opts to force local dispatch.
        return runToolLoop({
          sessionId, cwd, send, signal, onConfirmRequest,
          userMessage: effectiveMessage, needsTitle,
          _forceLocal: true,
        });
      }
      // Non-remote error — propagate normally
      throw err;
    }
  }

  // ── Standard tier: full tool loop ──
  return runToolLoop({ sessionId, cwd, send, signal, onConfirmRequest, userMessage: effectiveMessage, needsTitle });
}

/**
 * Clean up session tier tracking when a session is deleted.
 */
function clearSessionTier(sessionId) {
  _sessionTiers.delete(sessionId);
}

module.exports = { executeTool, runToolLoop, routeAndRespond, clearSessionTier, isUserLoopActive, waitForIdle, ALIASES, CONFIRM_TOOLS };
