#!/usr/bin/env node

/**
 * Minutes MCP Server
 *
 * MCP tools for Claude Desktop / Cowork / Dispatch:
 *   - start_recording: Start recording audio from the default input device
 *   - stop_recording: Stop recording and process through the pipeline
 *   - get_status: Check if a recording is in progress
 *   - list_meetings: List recent meetings and voice memos
 *   - search_meetings: Search meeting transcripts
 *   - get_meeting: Get full transcript of a specific meeting
 *   - process_audio: Process an audio file through the pipeline
 *   - add_note: Add a timestamped note to a recording or meeting
 *   - activity_summary: Summarize meeting-adjacent desktop context for a session/path/window
 *   - search_context: Search app and captured window-title desktop context
 *   - get_moment: Show the local rewind around a linked artifact, session, or timestamp
 *   - consistency_report: Flag conflicting decisions and stale commitments
 *   - get_person_profile: Rich relationship profile for a person (graph index)
 *   - track_commitments: List open/stale commitments, filter by person
 *   - relationship_map: All contacts with scores and losing-touch alerts
 *   - research_topic: Cross-meeting topic research
 *   - qmd_collection_status: Check QMD collection registration
 *   - register_qmd_collection: Register Minutes output as QMD collection
 *   - list_voices: List enrolled voice profiles for speaker identification
 *   - confirm_speaker: Confirm/correct speaker attribution in a meeting
 *   - get_meeting_insights: Query structured insights (decisions, commitments, etc.) with confidence filtering
 *
 * All tools use execFile (not exec) to shell out to the `minutes` CLI binary.
 * No shell interpolation — safe from injection.
 */

// ── Crash tracer must load before any other import (see ./crashTracer.ts) ──
// Issue #149 — Claude Desktop 1.3109.0 with MCP protocol 2025-11-25 kills
// the extension server with no stderr visible in the host log. The tracer
// writes synchronously to ~/.minutes/logs/mcp-crash.log so a reinstall
// produces a real trace instead of a silent exit.
import { crashTrace, CRASH_LOG_PATH } from "./crashTracer.js";

import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ErrorCode,
  McpError,
  SubscribeRequestSchema,
  UnsubscribeRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
  EXTENSION_ID,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync } from "fs";
import { mkdir, readFile, rm, writeFile } from "fs/promises";
import { delimiter, dirname, isAbsolute, join, relative, resolve } from "path";
import { fileURLToPath } from "url";
import { homedir } from "os";

import * as reader from "minutes-sdk";
import {
  canonicalizeRoot,
  expandHomeLikePath,
  validatePathInDirectories,
  validatePathInDirectory,
} from "./paths.js";
import { isCliCompatible } from "./version.js";
import {
  hasFeature,
  probeCapabilitiesSync,
  type CapabilityProbeResult,
} from "./capabilities.js";
import { downloadReleaseBinaryWithChecksum } from "./autoInstall.js";

crashTrace("imports-complete");

// ── Demo mode (--demo flag) ────────────────────────────────
// `npx minutes-mcp --demo` is a one-shot setup: copies bundled fixture
// meetings to ~/.minutes/demo/, prints the MCP config snippet with an explicit
// MEETINGS_DIR env override, prints suggested questions, and exits 0.
//
// The printed config uses env:{ MEETINGS_DIR } pointing at the demo dir. No
// separate --demo flag at runtime. The MCP host just launches standard
// `minutes-mcp`; the env override is what routes it at the demo corpus. This
// avoids the TTY-detection ambiguity that an earlier dual-mode design had.
//
// Guarded on `--demo` AND on being the actual entry point so importers don't
// trigger disk side effects by mistake. Use the same realpath-aware guard as
// `main()` so npm/.bin shims and symlinked entrypoints still execute demo mode.
if (process.argv.includes("--demo") && shouldRunMainEntry(process.argv[1], fileURLToPath(import.meta.url))) {
  handleDemoSetup();
}

function handleDemoSetup(): void {
  const demoDir = join(homedir(), ".minutes", "demo");
  const here = dirname(fileURLToPath(import.meta.url));
  // Package layout after build: dist/index.js; fixtures live at
  // <pkg>/fixtures/demo/ next to dist/.
  const fixturesSrc = resolve(here, "..", "fixtures", "demo");

  if (!existsSync(fixturesSrc)) {
    console.error(
      `[minutes-mcp --demo] bundled fixtures not found at ${fixturesSrc}. ` +
        `This build of minutes-mcp is missing the demo corpus. ` +
        `Try upgrading with: npm install -g minutes-mcp@latest`
    );
    process.exit(1);
  }

  mkdirSync(demoDir, { recursive: true });
  for (const entry of readdirSync(fixturesSrc)) {
    if (!entry.endsWith(".md")) continue;
    copyFileSync(join(fixturesSrc, entry), join(demoDir, entry));
  }

  // The config snippet embeds the fully-resolved demoDir so users don't have
  // to fill it in manually. MCP hosts inject this env when launching the
  // server; the server's existing MEETINGS_DIR logic (line ~800) picks it up.
  const configSnippet = JSON.stringify(
    {
      mcpServers: {
        "minutes-demo": {
          command: "npx",
          args: ["minutes-mcp"],
          env: {
            MEETINGS_DIR: demoDir,
          },
        },
      },
    },
    null,
    2
  );

  console.log("");
  console.log("Demo corpus ready at: " + demoDir);
  console.log("5 fixture meetings with a pricing reversal, a customer commitment that slips, and a feature cut.");
  console.log("");
  console.log("═══ MCP config (paste into Claude Desktop, Cursor, Claude Code, or any MCP client) ═══");
  console.log(configSnippet);
  console.log("");
  console.log("═══ Try asking your agent ═══");
  console.log("  • List the meetings in this corpus.");
  console.log("  • What did we decide about pricing? Which decision is current?");
  console.log("  • What got killed in the last product prioritization meeting?");
  console.log("  • What action items are still open, and who owns each?");
  console.log("  • Summarize the Northwind customer thread.");
  console.log("");
  console.log("Note: some structured tools (consistency report, person profile) auto-install the Minutes CLI on first use.");
  console.log("Full setup (real audio capture, transcription, real meetings): https://useminutes.app");
  console.log("");
  process.exit(0);
}

const UI_RESOURCE_URI = "ui://minutes/dashboard";
const MCP_TOOLS_DOCS_BASE_URL = "https://useminutes.app/docs/mcp/tools";
export const MEETING_INSIGHT_KINDS = ["decision", "commitment", "question"] as const;
export type KnowledgeConfigStatus = {
  enabled: boolean;
  path?: string;
  adapter: string;
  engine: string;
};

type MeetingLike = {
  path: string;
  frontmatter: {
    date?: string;
    title?: string;
    type?: string;
    duration?: string;
    recording_health?: unknown;
  };
};

export function meetingListItem(meeting: MeetingLike) {
  return {
    date: meeting.frontmatter.date,
    title: meeting.frontmatter.title,
    content_type: meeting.frontmatter.type,
    path: meeting.path,
    duration: meeting.frontmatter.duration,
  };
}

export function meetingSearchItem(meeting: MeetingLike) {
  return {
    date: meeting.frontmatter.date,
    title: meeting.frontmatter.title,
    content_type: meeting.frontmatter.type,
    path: meeting.path,
  };
}

/**
 * Pull the text of a top-level markdown section (e.g. `## Summary`) out of a
 * meeting body, stopping at the next `## ` heading. Returns undefined when the
 * section is absent or empty. Used to surface the synthesized summary in
 * get_meeting's structuredContent without re-parsing the whole transcript.
 */
export function extractMarkdownSection(
  body: string | undefined,
  heading: string
): string | undefined {
  if (!body) return undefined;
  const lines = body.split(/\r?\n/);
  const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
  if (start === -1) return undefined;

  const collected: string[] = [];
  for (let i = start + 1; i < lines.length; i++) {
    if (/^##\s/.test(lines[i])) break;
    collected.push(lines[i]);
  }

  const text = collected.join("\n").trim();
  return text.length > 0 ? text : undefined;
}

export function meetingDetailPayload(input: {
  path: string;
  speaker_map?: unknown;
  recording_health?: unknown;
  overlay_applied?: boolean;
  title?: unknown;
  summary?: string;
  action_items?: unknown;
  decisions?: unknown;
  intents?: unknown;
  body?: string;
}) {
  const payload: {
    path: string;
    view: "detail";
    title?: unknown;
    summary?: string;
    action_items?: unknown;
    decisions?: unknown;
    intents?: unknown;
    speaker_map?: unknown;
    recording_health?: unknown;
    overlay_applied?: boolean;
    body?: string;
  } = {
    path: input.path,
    view: "detail",
  };

  if (input.title !== undefined) {
    payload.title = input.title;
  }
  if (input.summary !== undefined) {
    payload.summary = input.summary;
  }
  if (input.action_items !== undefined) {
    payload.action_items = input.action_items;
  }
  if (input.decisions !== undefined) {
    payload.decisions = input.decisions;
  }
  if (input.intents !== undefined) {
    payload.intents = input.intents;
  }
  if (input.speaker_map !== undefined) {
    payload.speaker_map = input.speaker_map;
  }
  if (input.recording_health !== undefined) {
    payload.recording_health = input.recording_health;
  }
  if (input.overlay_applied !== undefined) {
    payload.overlay_applied = input.overlay_applied;
  }
  if (input.body !== undefined) {
    payload.body = input.body;
  }

  return payload;
}

function toolDocsUrl(name: string): string {
  return `${MCP_TOOLS_DOCS_BASE_URL}#tool-${name}`;
}

function withToolDocs(name: string, description: string): string {
  return `${description} Docs: ${toolDocsUrl(name)}`;
}

function registerTool(
  name: string,
  description: string,
  inputSchema: Record<string, unknown>,
  annotations: Record<string, unknown>,
  handler: (...args: any[]) => any
) {
  return server.tool(
    name,
    withToolDocs(name, description),
    inputSchema as any,
    annotations as any,
    handler as any
  );
}

function registerDocsAppTool(
  serverArg: McpServer,
  name: string,
  config: Record<string, unknown>,
  handler: (...args: any[]) => any
) {
  const description =
    typeof config.description === "string" ? config.description : "";

  return registerAppTool(
    serverArg,
    name,
    {
      ...config,
      description: withToolDocs(name, description),
    } as any,
    handler as any
  );
}

const execFileAsync = promisify(execFile);

// ── QMD semantic search (optional — falls back to CLI) ──────

let qmdAvailable: boolean | null = null;

async function runQmd(
  args: string[],
  timeoutMs: number = 15000
): Promise<{ stdout: string; stderr: string } | null> {
  try {
    const { stdout, stderr } = await execFileAsync("qmd", args, {
      timeout: timeoutMs,
      env: { ...process.env },
    });
    return { stdout: stdout.trim(), stderr: stderr.trim() };
  } catch {
    return null;
  }
}

async function isQmdAvailable(): Promise<boolean> {
  if (qmdAvailable !== null) return qmdAvailable;
  const result = await runQmd(["collection", "show", "minutes"]);
  qmdAvailable = result !== null && !result.stderr.includes("not found") && !result.stderr.includes("No collection");
  if (qmdAvailable) {
    console.error("[Minutes] QMD available — semantic search enabled for minutes collection");
  }
  return qmdAvailable;
}

async function enrichWithFrontmatter(qmdResults: any[]): Promise<any[]> {
  return Promise.all(
    qmdResults.map(async (r: any) => {
      const filePath = r.source_path || r.path;
      try {
        const meeting = await reader.getMeeting(filePath);
        return {
          date: meeting?.frontmatter.date || "",
          title: meeting?.frontmatter.title || "",
          content_type: meeting?.frontmatter.type || "meeting",
          path: filePath,
          snippet: r.snippet || "",
        };
      } catch {
        return {
          date: "",
          title: "",
          content_type: "meeting",
          path: filePath,
          snippet: r.snippet || "",
        };
      }
    })
  );
}

async function searchViaQmd(
  query: string,
  limit: number,
  contentType?: string
): Promise<any[] | null> {
  if (!(await isQmdAvailable())) return null;

  const args = ["search", query, "-c", "minutes", "-n", String(limit), "--json"];
  const result = await runQmd(args);
  if (!result) return null;

  try {
    const parsed = JSON.parse(result.stdout);
    const results = Array.isArray(parsed) ? parsed : parsed.results || [];
    if (results.length === 0) return null;

    const enriched = await enrichWithFrontmatter(results);

    // Apply content type filter if specified
    if (contentType) {
      const filtered = enriched.filter((r: any) => r.content_type === contentType);
      return filtered.length > 0 ? filtered : null;
    }

    return enriched;
  } catch {
    return null;
  }
}

async function triggerQmdIndex(): Promise<void> {
  if (!(await isQmdAvailable())) return;
  // Fire-and-forget — don't block the response
  execFileAsync("qmd", ["update", "-c", "minutes"]).catch(() => {});
}

// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

function canonicalEntrypointPath(filePath: string | null | undefined): string | null {
  if (!filePath) return null;

  const resolved = resolve(filePath);

  try {
    return realpathSync(resolved);
  } catch {
    return resolved;
  }
}

export function shouldRunMainEntry(argv1: string | null | undefined, moduleFilename: string): boolean {
  const entryPath = canonicalEntrypointPath(argv1);
  const modulePath = canonicalEntrypointPath(moduleFilename);

  return !!entryPath && !!modulePath && entryPath === modulePath;
}

// ── Extension runtime detection ───────────────────────────────
// When running as a Claude Desktop extension (.mcpb), Claude uses its built-in
// Node.js runtime.  Child processes spawned from that runtime land in a
// different macOS audit session and do NOT inherit the host app's TCC
// microphone grant — CoreAudio delivers all-zero samples (silence).
//
// Manual MCP configs (`claude_desktop_config.json`) spawn the user's own
// `node` binary, which typically has an independent TCC mic entry, so child
// processes work fine.
//
// Detection: the .mcpb unpacks into "Claude Extensions" inside Application
// Support, and Claude Desktop sets MCP_EXTENSION_ID for extension servers.
// This is macOS-specific — Windows/Linux don't have TCC, so their extension
// runtimes can spawn child processes with mic access normally.
const isExtensionRuntime: boolean =
  process.platform === "darwin" &&
  (!!process.env.MCP_EXTENSION_ID ||
   __dirname.includes("Claude Extensions") ||
   __dirname.includes("claude-extensions"));

if (isExtensionRuntime) {
  console.error(
    "[Minutes] Running as Claude Desktop extension — audio capture will " +
    "delegate to the Minutes desktop app (TCC mic grants don't propagate " +
    "through the extension runtime). Launch Minutes.app for recording."
  );
} else {
  console.error(
    `[Minutes] Extension runtime detection: false ` +
    `(MCP_EXTENSION_ID=${!!process.env.MCP_EXTENSION_ID}, dirname=${__dirname})`
  );
}

// ── Find the minutes binary ─────────────────────────────────

function findMinutesBinary(): string {
  const isWindows = process.platform === "win32";
  const ext = isWindows ? ".exe" : "";
  const candidates = [
    join(__dirname, "..", "..", "..", "target", "release", `minutes${ext}`),
    join(__dirname, "..", "..", "..", "target", "debug", `minutes${ext}`),
    join(homedir(), ".cargo", "bin", `minutes${ext}`),
    ...(isWindows
      ? []
      : [
          join(homedir(), ".local", "bin", "minutes"),
          "/opt/homebrew/bin/minutes",
          "/usr/local/bin/minutes",
        ]),
  ];

  for (const candidate of candidates) {
    if (existsSync(candidate)) {
      return candidate;
    }
  }

  // Fall back to PATH lookup
  return "minutes";
}

let MINUTES_BIN = findMinutesBinary();

// ── Capability probe (Phase 2 of #183) ────────────────────────
// Ask the CLI what it supports instead of inferring from version strings.
// Synchronous so it can run before tool registrations at module load.
// Distinguish a truly missing CLI (first-run auto-install can still recover
// later in the session) from an already-installed CLI that does not support
// the capabilities contract and should stay fail-closed.
const CLI_CAPABILITIES: CapabilityProbeResult =
  probeCapabilitiesSync(MINUTES_BIN);
if (CLI_CAPABILITIES.kind === "report") {
  crashTrace("cli-capabilities-probed", {
    cliVersion: CLI_CAPABILITIES.report.version,
    apiVersion: CLI_CAPABILITIES.report.api_version,
    featureCount: Object.keys(CLI_CAPABILITIES.report.features).length,
  });
} else if (CLI_CAPABILITIES.kind === "missing-cli") {
  crashTrace("cli-capabilities-cli-missing");
} else {
  crashTrace("cli-capabilities-unsupported");
}
const LIVE_EVENTS_SUPPORTED = hasFeature(CLI_CAPABILITIES, "events_since_seq");

// ── MCP server version ────────────────────────────────────────
// Kept for capabilities handshake and user-facing log messages.
// The compatibility decision against the installed CLI lives in
// `./version.ts` (see issue #183). Hosted `.mcpb` bundles will run
// against CLIs with different minor/patch numbers within the same
// major; that is explicitly supported.
const MCP_SERVER_VERSION = "0.18.2";

export function parseKnowledgeConfig(configContent: string): KnowledgeConfigStatus | null {
  const knowledgeMatch = configContent.match(/\[knowledge\][\s\S]*?(?=\n\[|$)/);
  if (!knowledgeMatch) {
    return null;
  }

  const section = knowledgeMatch[0];
  const enabled = /^\s*enabled\s*=\s*true(?:\s*#.*)?$/m.test(section);
  const pathMatch = section.match(/^\s*path\s*=\s*"([^"]+)"/m);
  const adapterMatch = section.match(/^\s*adapter\s*=\s*"([^"]+)"/m);
  const engineMatch = section.match(/^\s*engine\s*=\s*"([^"]+)"/m);

  return {
    enabled,
    path: pathMatch?.[1],
    adapter: adapterMatch?.[1] || "wiki",
    engine: engineMatch?.[1] || "none",
  };
}
// ── CLI auto-install ────────────────────────────────────────
// Auto-install fetches from the GitHub `releases/latest/download/` redirect,
// not a pinned tag, so hosted `.mcpb` bundles self-heal across our release
// cadence. See issue #183 for context.
// When installed via MCPB or `npx minutes-mcp`, the Rust CLI binary
// may not be present. We attempt to install it automatically so
// non-technical users don't hit a "binary not found" dead end.

let installAttempted = false;

function getReleaseBinaryName(): string | null {
  const platform = process.platform;
  const arch = process.arch;
  if (platform === "darwin" && arch === "arm64") return "minutes-macos-arm64";
  if (platform === "darwin" && arch === "x64") return "minutes-macos-arm64"; // Rosetta handles it
  if (platform === "linux" && arch === "x64") return "minutes-linux-x64";
  if (platform === "win32" && arch === "x64") return "minutes-windows-x64.exe";
  return null;
}

function getInstallDir(): string {
  const localBin = join(homedir(), ".local", "bin");
  if (process.platform === "win32") {
    return join(homedir(), ".cargo", "bin"); // common writable dir on Windows
  }
  return localBin;
}

async function tryAutoInstall(): Promise<boolean> {
  if (installAttempted) return false;
  installAttempted = true;

  console.error("[Minutes] CLI not found — attempting automatic install...");

  // Strategy 1: Download pre-built binary from GitHub release (fastest, no deps)
  const binaryName = getReleaseBinaryName();
  if (binaryName) {
    try {
      const installDir = getInstallDir();
      const isWindows = process.platform === "win32";
      const targetName = isWindows ? "minutes.exe" : "minutes";
      const targetPath = join(installDir, targetName);

      console.error(`[Minutes] Downloading ${binaryName} from latest release...`);

      // Ensure install directory exists
      await mkdir(installDir, { recursive: true });

      // Download with curl (available on macOS, Linux, and modern Windows),
      // verify SHA256SUMS.txt, then move the verified binary into place.
      await downloadReleaseBinaryWithChecksum({
        binaryName,
        targetPath,
        execFileAsync,
      });

      // Make executable (not needed on Windows)
      if (!isWindows) {
        await execFileAsync("chmod", ["+x", targetPath], { timeout: 5000 });
      }

      console.error(`[Minutes] ✓ Installed to ${targetPath}`);
      MINUTES_BIN = targetPath;
      return true;
    } catch (e: any) {
      console.error(`[Minutes] Binary download failed: ${e.message || e}`);
    }
  }

  // Strategy 2: Homebrew (macOS only)
  if (process.platform === "darwin") {
    try {
      console.error("[Minutes] Trying: brew tap silverstein/tap && brew install minutes");
      await execFileAsync("brew", ["tap", "silverstein/tap"], { timeout: 120000 });
      await execFileAsync("brew", ["install", "minutes"], { timeout: 300000 });
      console.error("[Minutes] ✓ Installed via Homebrew");
      MINUTES_BIN = findMinutesBinary();
      return true;
    } catch (e: any) {
      console.error(`[Minutes] Homebrew install failed: ${e.message || e}`);
    }
  }

  // Strategy 3: Cargo (if Rust is installed)
  try {
    console.error("[Minutes] Trying: cargo install minutes-cli");
    await execFileAsync("cargo", ["install", "minutes-cli"], { timeout: 600000 });
    console.error("[Minutes] ✓ Installed via cargo");
    MINUTES_BIN = findMinutesBinary();
    return true;
  } catch (e: any) {
    console.error(`[Minutes] cargo install failed: ${e.message || e}`);
  }

  console.error(
    "[Minutes] Auto-install failed. Install manually:\n" +
    "  macOS:   brew tap silverstein/tap && brew install minutes\n" +
    "  Any:     cargo install minutes-cli\n" +
    "  Source:  https://github.com/silverstein/minutes"
  );
  return false;
}

// ── CLI version check ───────────────────────────────────────

async function checkCliVersion(): Promise<void> {
  try {
    const { stdout } = await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
    // Output is like "minutes 0.8.0" or just "0.8.0".
    const match = stdout.trim().match(/(\d+\.\d+\.\d+)/);
    if (!match) return;

    const installedVersion = match[1];
    const result = isCliCompatible(installedVersion, MCP_SERVER_VERSION);

    // Only surface logs the user should see. Same-major skew is silent-
    // compatible, which is the whole point of issue #183 fix: hosted `.mcpb`
    // bundles frequently run against a CLI with a different minor/patch
    // and that is fine.
    if (result.severity === "error") {
      console.error(`[Minutes] ${result.message}`);
    } else if (result.severity === "ok") {
      console.error(`[Minutes] ${result.message}`);
    }
    // "info" severity (compatible skew, unparseable version) stays silent.
  } catch {
    // Version check is best-effort. Don't block on failure.
  }
}

// ── Auto-setup: download whisper model if missing ───────────
// Recording needs a whisper model (~75MB for tiny). If the CLI is
// available but the model isn't downloaded, trigger setup automatically
// in the background so the first "start recording" just works.

let modelCheckDone = false;

async function ensureWhisperModel(): Promise<void> {
  if (modelCheckDone) return;
  modelCheckDone = true;

  try {
    // health --json returns an array of { label, state, detail, optional } items.
    // The "Speech model" item has state "ready" when downloaded.
    const { stdout } = await execFileAsync(MINUTES_BIN, ["health", "--json"], { timeout: 10000, env: augmentedEnv() });
    const items = JSON.parse(stdout);
    const modelItem = Array.isArray(items) && items.find((i: any) => i.label === "Speech model");
    if (modelItem && modelItem.state === "ready") {
      console.error("[Minutes] Whisper model ready");
      return;
    }
  } catch {
    // health command may not exist in older CLI versions — fall through to setup
  }

  // Model not found — download tiny model in background
  console.error("[Minutes] Whisper model not found — downloading tiny model (~75MB)...");
  try {
    await execFileAsync(MINUTES_BIN, ["setup", "--model", "tiny"], { timeout: 300000, env: augmentedEnv() });
    console.error("[Minutes] ✓ Whisper tiny model downloaded — recording is ready");
  } catch (e: any) {
    console.error(
      `[Minutes] Model download failed: ${e.message || e}. ` +
      `Run manually: minutes setup --model tiny`
    );
  }
}

// ── CLI availability detection ──────────────────────────────
// When installed via `npx minutes-mcp`, the Rust CLI may not be present.
// In that case, read-only tools use the pure-TS reader module.

let cliAvailable: boolean | null = null;
let cliCheckedAt = 0;
const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // re-check every 5 minutes

async function isCliAvailable(): Promise<boolean> {
  // Cache hit: return true permanently (CLI won't disappear mid-session)
  // Cache miss (false): re-probe after TTL so installing CLI mid-session works
  if (cliAvailable === true) return true;
  if (cliAvailable === false && Date.now() - cliCheckedAt < CLI_CACHE_TTL_MS) return false;

  try {
    await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
    cliAvailable = true;
    cliCheckedAt = Date.now();
    console.error("[Minutes] CLI found — full mode (all tools enabled)");
    // Check version and ensure whisper model in background (non-blocking)
    checkCliVersion();
    ensureWhisperModel();
  } catch {
    // CLI not found — try to install it automatically
    if (!installAttempted) {
      const installed = await tryAutoInstall();
      if (installed) {
        try {
          await execFileAsync(MINUTES_BIN, ["--version"], { timeout: 5000, env: augmentedEnv() });
          cliAvailable = true;
          cliCheckedAt = Date.now();
          console.error("[Minutes] CLI now available after auto-install — full mode");
          checkCliVersion();
          ensureWhisperModel();
          return true;
        } catch {
          // Install succeeded but binary still not found — path issue
        }
      }
    }
    cliAvailable = false;
    cliCheckedAt = Date.now();
    console.error(
      "[Minutes] CLI not available — read-only mode (search and browse only)"
    );
  }
  return cliAvailable;
}

type DesktopAppStatus = {
  pid: number;
  updated_at: string;
  platform: string;
};

type DesktopControlResponse = {
  id: string;
  handled_at: string;
  accepted: boolean;
  detail: string;
};

function desktopControlDir(): string {
  return join(homedir(), ".minutes", "desktop-control");
}

function desktopAppStatusPath(): string {
  return join(desktopControlDir(), "desktop-app.json");
}

function desktopRequestPath(id: string): string {
  return join(desktopControlDir(), "requests", `${id}.json`);
}

function desktopResponsePath(id: string): string {
  return join(desktopControlDir(), "responses", `${id}.json`);
}

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (e: any) {
    // EPERM means the process exists but is owned by a different user — still alive.
    if (e.code === "EPERM") return true;
    return false;
  }
}

async function readRunningDesktopAppStatus(): Promise<DesktopAppStatus | null> {
  let raw: string;
  try {
    raw = await readFile(desktopAppStatusPath(), "utf8");
  } catch (e: any) {
    if (e.code === "ENOENT") return null; // File doesn't exist — app not running
    console.error(`[Minutes] Failed to read desktop status file: ${e.message}`);
    return null;
  }

  try {
    const status = JSON.parse(raw) as DesktopAppStatus;
    const updatedAt = Date.parse(status.updated_at);
    if (!Number.isFinite(updatedAt)) {
      console.error(`[Minutes] Desktop status file has invalid updated_at: ${status.updated_at}`);
      return null;
    }
    const ageMs = Date.now() - updatedAt;
    if (ageMs > 10000) {
      console.error(`[Minutes] Desktop app status stale (${Math.round(ageMs / 1000)}s old, pid=${status.pid})`);
      return null;
    }
    if (!status.pid || !isProcessAlive(status.pid)) return null;
    return status;
  } catch (e: any) {
    console.error(`[Minutes] Failed to parse desktop status file: ${e.message}`);
    return null;
  }
}

async function delegateRecordingToDesktop(args: {
  title?: string;
  mode: "meeting" | "quick-thought";
  intent?: string;
  allow_degraded: boolean;
  language?: string;
}): Promise<DesktopControlResponse | null> {
  const status = await readRunningDesktopAppStatus();
  if (!status) return null;

  const id = `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  try {
    await mkdir(join(desktopControlDir(), "requests"), { recursive: true });
    await mkdir(join(desktopControlDir(), "responses"), { recursive: true });
  } catch (e: any) {
    console.error(`[Minutes] Failed to create desktop control dirs: ${e.message}`);
    return null;
  }

  const request = {
    id,
    created_at: new Date().toISOString(),
    action: {
      type: "start-recording",
      mode: args.mode,
      intent: args.intent,
      allow_degraded: args.allow_degraded,
      title: args.title,
      language: args.language,
    },
  };

  const requestPath = desktopRequestPath(id);
  const responsePath = desktopResponsePath(id);
  await writeFile(requestPath, JSON.stringify(request, null, 2), "utf8");

  const timeoutAt = Date.now() + 10000;
  try {
    while (Date.now() < timeoutAt) {
      if (existsSync(responsePath)) {
        // The Tauri side writes via tmp → rename, so the file may briefly exist
        // as an empty or partial write. Catch parse errors and keep polling.
        try {
          const response = JSON.parse(
            await readFile(responsePath, "utf8")
          ) as DesktopControlResponse;
          await rm(responsePath, { force: true });
          return response;
        } catch {
          // Partial write or rename in progress — retry on next poll cycle.
        }
      }
      await new Promise((resolve) => setTimeout(resolve, 250));
    }
    throw new Error("Minutes desktop app did not respond to the recording request in time.");
  } finally {
    await rm(requestPath, { force: true }).catch(() => {});
  }
}

const CLI_INSTALL_MSG =
  `Recording requires the minutes CLI binary.\n` +
  `Searched: ${MINUTES_BIN}\n\n` +
  `Install it:\n` +
  `  macOS:   brew tap silverstein/tap && brew install minutes\n` +
  `  Any:     cargo install minutes-cli\n` +
  `  Source:  https://github.com/silverstein/minutes\n\n` +
  `If already installed via Homebrew, try:\n` +
  `  sudo ln -s /opt/homebrew/bin/minutes /usr/local/bin/minutes`;

// Common binary locations that may not be in Claude Desktop's restricted PATH.
const EXTRA_PATH_DIRS = [
  join(homedir(), ".local", "bin"),
  join(homedir(), ".cargo", "bin"),
  "/opt/homebrew/bin",
  "/usr/local/bin",
];

function augmentedEnv(extra?: Record<string, string>): Record<string, string | undefined> {
  const currentPath = process.env.PATH || "";
  const augmentedPath = [...EXTRA_PATH_DIRS, currentPath].join(delimiter);
  return { ...process.env, PATH: augmentedPath, ...extra };
}

export const LIVE_EVENTS_RESOURCE_URI = "minutes://events/live";
export const LIVE_EVENTS_URI_TEMPLATE = "minutes://events/live{?since_seq,limit}";
const LIVE_EVENTS_DEFAULT_RECENT_LIMIT = 20;
const LIVE_EVENTS_DEFAULT_CURSOR_LIMIT = 100;
const LIVE_EVENTS_POLL_INTERVAL_MS = Math.max(
  250,
  Number.parseInt(process.env.MINUTES_MCP_EVENT_POLL_MS || "1000", 10) || 1000
);

type JsonObject = Record<string, unknown>;

export type LiveEventsResourceOptions = {
  uri: string;
  sinceSeq: number | null;
  limit: number;
};

export type LiveEventsResourcePayload = {
  v: 1;
  resource: typeof LIVE_EVENTS_RESOURCE_URI;
  mode: "recent" | "since_seq";
  since_seq: number | null;
  limit: number;
  latest_seq: number;
  events: unknown[];
  reconnect: {
    cursor: number;
    read_uri: string;
  };
};

export function parseLiveEventsResourceUri(rawUri: string): LiveEventsResourceOptions | null {
  let url: URL;
  try {
    url = new URL(rawUri);
  } catch {
    return null;
  }

  if (url.protocol !== "minutes:" || url.hostname !== "events" || url.pathname !== "/live") {
    return null;
  }

  const sinceSeqRaw = url.searchParams.get("since_seq");
  const limitRaw = url.searchParams.get("limit");
  const sinceSeq = parseOptionalNonNegativeInteger(sinceSeqRaw);
  const limit = parseOptionalPositiveInteger(
    limitRaw,
    sinceSeq === null ? LIVE_EVENTS_DEFAULT_RECENT_LIMIT : LIVE_EVENTS_DEFAULT_CURSOR_LIMIT
  );

  if (sinceSeqRaw !== null && sinceSeq === null) {
    throw new McpError(ErrorCode.InvalidParams, "since_seq must be a non-negative integer");
  }
  if (limitRaw !== null && limit === null) {
    throw new McpError(ErrorCode.InvalidParams, "limit must be a positive integer");
  }

  return {
    uri: url.href,
    sinceSeq,
    limit: limit ?? LIVE_EVENTS_DEFAULT_CURSOR_LIMIT,
  };
}

function parseOptionalNonNegativeInteger(raw: string | null): number | null {
  if (raw === null) return null;
  if (!/^\d+$/.test(raw)) return null;
  const parsed = Number.parseInt(raw, 10);
  return Number.isSafeInteger(parsed) ? parsed : null;
}

function parseOptionalPositiveInteger(raw: string | null, fallback: number): number | null {
  if (raw === null) return fallback;
  if (!/^\d+$/.test(raw)) return null;
  const parsed = Number.parseInt(raw, 10);
  if (!Number.isSafeInteger(parsed) || parsed <= 0) return null;
  return Math.min(parsed, 1000);
}

function eventSeq(event: unknown): number {
  if (!event || typeof event !== "object") return 0;
  const seq = (event as JsonObject).seq;
  return typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0 ? seq : 0;
}

function maxEventSeq(events: unknown[], floor: number = 0): number {
  return events.reduce<number>((max, event) => Math.max(max, eventSeq(event)), floor);
}

export function buildLiveEventsResourcePayload(
  options: LiveEventsResourceOptions,
  events: unknown[],
  latestSeq: number
): LiveEventsResourcePayload {
  const deliveredCursorFloor = options.sinceSeq ?? latestSeq;
  const cursor = maxEventSeq(events, deliveredCursorFloor);
  const latestKnownSeq = maxEventSeq(events, latestSeq);
  return {
    v: 1,
    resource: LIVE_EVENTS_RESOURCE_URI,
    mode: options.sinceSeq === null ? "recent" : "since_seq",
    since_seq: options.sinceSeq,
    limit: options.limit,
    latest_seq: latestKnownSeq,
    events,
    reconnect: {
      cursor,
      read_uri: `${LIVE_EVENTS_RESOURCE_URI}?since_seq=${cursor}`,
    },
  };
}

// ── Helper: run minutes CLI command (uses execFile, not exec) ──

async function runMinutes(
  args: string[],
  timeoutMs: number = 30000
): Promise<{ stdout: string; stderr: string }> {
  try {
    const { stdout, stderr } = await execFileAsync(MINUTES_BIN, args, {
      timeout: timeoutMs,
      env: augmentedEnv({ RUST_LOG: "info" }),
    });
    return { stdout: stdout.trim(), stderr: stderr.trim() };
  } catch (error: any) {
    if (error.killed) {
      throw new Error(`Command timed out after ${timeoutMs}ms`);
    }
    const stderr = error.stderr?.trim() || "";
    const stdout = error.stdout?.trim() || "";
    throw new Error(stderr || stdout || error.message);
  }
}

function parseJsonOutput(stdout: string): any {
  try {
    return JSON.parse(stdout);
  } catch {
    return { raw: stdout };
  }
}

async function readEventsFromCli(args: string[]): Promise<unknown[]> {
  if (!(await isCliAvailable())) {
    return [];
  }
  const { stdout } = await runMinutes(args, 10000);
  const parsed = parseJsonOutput(stdout);
  return Array.isArray(parsed) ? parsed : [];
}

async function readRecentEventsFromCli(limit: number): Promise<unknown[]> {
  return readEventsFromCli(["events", "--limit", String(limit)]);
}

async function readAgentAnnotationsFromCli(limit: number): Promise<any[]> {
  const events = await readEventsFromCli([
    "events",
    "--event-type",
    "agent.annotation",
    "--limit",
    String(limit),
  ]);
  return events.filter((event: any) => event?.event_type === "agent.annotation");
}

async function readEventsSinceSeqFromCli(sinceSeq: number, limit: number): Promise<unknown[]> {
  return readEventsFromCli(["events", "--since-seq", String(sinceSeq), "--limit", String(limit)]);
}

function parseStructuredCliError(message: string): any | null {
  const trimmed = message.trim();
  const start = trimmed.indexOf("{");
  const end = trimmed.lastIndexOf("}");
  if (start === -1 || end === -1 || end < start) {
    return null;
  }
  try {
    return JSON.parse(trimmed.slice(start, end + 1));
  } catch {
    return null;
  }
}

async function latestEventSeqFromCli(): Promise<number> {
  const events = await readRecentEventsFromCli(1);
  return maxEventSeq(events);
}

async function readLiveEventsResource(uri: URL): Promise<{
  contents: Array<{ uri: string; mimeType: string; text: string }>;
}> {
  const options = parseLiveEventsResourceUri(uri.href);
  if (!options) {
    throw new McpError(ErrorCode.InvalidParams, `Unsupported live events resource: ${uri.href}`);
  }

  if (!(await isCliAvailable())) {
    const unavailable = {
      v: 1,
      resource: LIVE_EVENTS_RESOURCE_URI,
      mode: options.sinceSeq === null ? "recent" : "since_seq",
      since_seq: options.sinceSeq,
      limit: options.limit,
      latest_seq: options.sinceSeq ?? 0,
      events: [],
      reconnect: {
        cursor: options.sinceSeq ?? 0,
        read_uri: `${LIVE_EVENTS_RESOURCE_URI}?since_seq=${options.sinceSeq ?? 0}`,
      },
      unavailable: "Minutes CLI is not installed; live event reads require the local CLI.",
    };
    return {
      contents: [{
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(unavailable, null, 2),
      }],
    };
  }

  const events = options.sinceSeq === null
    ? await readRecentEventsFromCli(options.limit)
    : await readEventsSinceSeqFromCli(options.sinceSeq, options.limit);
  const latestSeq = options.sinceSeq === null ? maxEventSeq(events) : await latestEventSeqFromCli();
  const payload = buildLiveEventsResourcePayload(options, events, latestSeq);

  return {
    contents: [{
      uri: uri.href,
      mimeType: "application/json",
      text: JSON.stringify(payload, null, 2),
    }],
  };
}

export type LiveEventsSubscriptionOptions = {
  pollIntervalMs?: number;
  latestEventSeq?: () => Promise<number>;
  readEventsSinceSeq?: (sinceSeq: number, limit: number) => Promise<unknown[]>;
  sendResourceUpdated?: (uri: string) => Promise<void>;
  onError?: (error: unknown) => void;
};

export type LiveEventsSubscriptionController = {
  stop: () => void;
  subscriptionCount: () => number;
};

export function registerLiveEventsSubscriptionHandlers(
  mcpServer: McpServer,
  options: LiveEventsSubscriptionOptions = {}
): LiveEventsSubscriptionController {
  const subscriptions = new Set<string>();
  const pollIntervalMs = options.pollIntervalMs ?? LIVE_EVENTS_POLL_INTERVAL_MS;
  const loadLatestSeq = options.latestEventSeq ?? latestEventSeqFromCli;
  const loadEventsSinceSeq = options.readEventsSinceSeq ?? readEventsSinceSeqFromCli;
  const sendResourceUpdated = options.sendResourceUpdated ??
    ((uri: string) => mcpServer.server.sendResourceUpdated({ uri }));
  const onError = options.onError ?? ((error: unknown) => {
    console.error(`[Minutes] live event subscription failed: ${error instanceof Error ? error.message : String(error)}`);
  });

  let cursor = 0;
  let pollTimer: NodeJS.Timeout | null = null;
  let pollInFlight = false;

  mcpServer.server.registerCapabilities({
    resources: { subscribe: true },
  });

  async function ensurePollerStarted(): Promise<void> {
    if (pollTimer) return;
    try {
      cursor = await loadLatestSeq();
    } catch (error) {
      onError(error);
      cursor = 0;
    }

    pollTimer = setInterval(() => {
      void pollOnce();
    }, pollIntervalMs);
    pollTimer.unref?.();
  }

  function stopPollerIfIdle(): void {
    if (subscriptions.size > 0 || !pollTimer) return;
    clearInterval(pollTimer);
    pollTimer = null;
    pollInFlight = false;
  }

  async function pollOnce(): Promise<void> {
    if (pollInFlight || subscriptions.size === 0) return;
    pollInFlight = true;
    try {
      const events = await loadEventsSinceSeq(cursor, LIVE_EVENTS_DEFAULT_CURSOR_LIMIT);
      const nextCursor = maxEventSeq(events, cursor);
      if (nextCursor > cursor) {
        cursor = nextCursor;
        await Promise.all([...subscriptions].map(async (uri) => {
          try {
            await sendResourceUpdated(uri);
          } catch (error) {
            onError(error);
          }
        }));
      }
    } catch (error) {
      onError(error);
    } finally {
      pollInFlight = false;
    }
  }

  function normalizeSubscriptionUri(rawUri: string): string {
    const parsed = parseLiveEventsResourceUri(rawUri);
    if (!parsed) {
      throw new McpError(
        ErrorCode.InvalidParams,
        `Only ${LIVE_EVENTS_RESOURCE_URI} subscriptions are supported`
      );
    }
    return parsed.sinceSeq === null && parsed.limit === LIVE_EVENTS_DEFAULT_RECENT_LIMIT
      ? LIVE_EVENTS_RESOURCE_URI
      : parsed.uri;
  }

  mcpServer.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
    const uri = normalizeSubscriptionUri(request.params.uri);
    subscriptions.add(uri);
    await ensurePollerStarted();
    return {};
  });

  mcpServer.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
    const uri = normalizeSubscriptionUri(request.params.uri);
    subscriptions.delete(uri);
    stopPollerIfIdle();
    return {};
  });

  return {
    stop: () => {
      subscriptions.clear();
      stopPollerIfIdle();
    },
    subscriptionCount: () => subscriptions.size,
  };
}

// ── MCP Server ──────────────────────────────────────────────

crashTrace("pre-mcp-server-construct");
const server = new McpServer({
  name: "minutes",
  version: MCP_SERVER_VERSION,
});
crashTrace("post-mcp-server-construct");

// Declare MCP Apps extension support so hosts classify this server as interactive.
// The `extensions` field is part of the draft MCP spec (SEP-1724) — not yet in the
// stable SDK types, so we cast through `any`.
(server.server as any).registerCapabilities({
  extensions: { [EXTENSION_ID]: {} },
} as any);

// Configurable directories — override via env vars in Claude Desktop extension settings
const MEETINGS_DIR = canonicalizeRoot(
  expandHomeLikePath(process.env.MEETINGS_DIR || join(homedir(), "meetings"))
);
const MINUTES_HOME = canonicalizeRoot(
  expandHomeLikePath(process.env.MINUTES_HOME || join(homedir(), ".minutes"))
);
let effectiveMeetingsDirPromise: Promise<string> | null = null;

async function getEffectiveMeetingsDir(): Promise<string> {
  if (effectiveMeetingsDirPromise) {
    return effectiveMeetingsDirPromise;
  }

  effectiveMeetingsDirPromise = (async () => {
    if (!(await isCliAvailable())) {
      return MEETINGS_DIR;
    }

    try {
      const { stdout } = await runMinutes(["paths", "--json"]);
      const parsed = parseJsonOutput(stdout);
      if (parsed && typeof parsed.output_dir === "string" && parsed.output_dir.length > 0) {
        return canonicalizeRoot(parsed.output_dir);
      }
    } catch {
      // Fall back to the MCP-configured default when the CLI cannot report paths.
    }

    return MEETINGS_DIR;
  })();

  return effectiveMeetingsDirPromise;
}

// ── UI Resource: MCP App dashboard ──────────────────────────

registerAppResource(
  server,
  "Minutes Dashboard",
  UI_RESOURCE_URI,
  { description: "Interactive meeting dashboard and detail viewer" },
  async () => {
    const htmlPath = join(__dirname, "..", "dist-ui", "index.html");
    const html = await readFile(htmlPath, "utf-8");
    return {
      contents: [{
        uri: UI_RESOURCE_URI,
        mimeType: RESOURCE_MIME_TYPE,
        text: html,
      }],
    };
  }
);

// ── Tool: start_recording ───────────────────────────────────

registerTool(
 "start_recording",
  "Start recording audio with call-aware preflight. When a known call app is active, Minutes can infer call intent and block silent mic-only call captures unless explicitly allowed. Note: this server does not listen to audio content. Recordings are stopped by invoking stop_recording after the user types a request in chat — never promise the user they can speak a 'stop recording' voice command.",
  {
    title: z.string().optional().describe("Optional title for this recording"),
    mode: z
      .enum(["meeting", "quick-thought"])
      .optional()
      .default("meeting")
      .describe("Live capture mode"),
    intent: z
      .enum(["memo", "room", "call"])
      .optional()
      .describe("Optional recording intent. If omitted and a known call app is active, Minutes may infer call intent."),
    allow_degraded: z
      .boolean()
      .optional()
      .default(false)
      .describe("Allow a mic-only capture to continue even if Minutes detects a call but no system-audio route is configured."),
    language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
    skip_audio_probe_reason: z
      .string()
      .min(1)
      .optional()
      .describe("Per-call reason to skip the system-audio readiness probe. This is not persisted and is written into recording_health."),
  },
  { title: "Start Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({ title, mode, intent, allow_degraded, language, skip_audio_probe_reason }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    const { stdout: statusOut } = await runMinutes(["status"]);
    const status = parseJsonOutput(statusOut);
    if (status.recording) {
      return {
        content: [
          {
            type: "text" as const,
            text: `Already recording (PID: ${status.pid}). Run stop_recording first.`,
          },
        ],
      };
    }

    const preflightArgs = ["preflight-record", "--json", "--mode", mode, "--intent", intent || "auto"];
    if (allow_degraded) preflightArgs.push("--allow-degraded");
    const { stdout: preflightOut } = await runMinutes(preflightArgs);
    const preflight = parseJsonOutput(preflightOut);

    // In extension mode, always delegate to the desktop app — the extension
    // runtime's audit session severs TCC mic grants for child processes.
    // For non-extension mode, still delegate call recordings to the desktop app
    // (it has system audio capture that the CLI can't do).
    if (isExtensionRuntime || preflight.intent === "call") {
      if (skip_audio_probe_reason) {
        return {
          content: [{
            type: "text" as const,
            text: "skip_audio_probe_reason cannot be honored for desktop-delegated recordings yet. Start the recording from the CLI with --skip-audio-probe \"<reason>\" if you intentionally want to bypass the system-audio readiness probe.",
          }],
          structuredContent: { preflight },
          isError: true,
        };
      }

      let response: DesktopControlResponse | null;
      try {
        response = await delegateRecordingToDesktop({
          title,
          mode,
          intent: intent || preflight.intent,
          allow_degraded,
          language,
        });
      } catch (e: any) {
        return {
          content: [{
            type: "text" as const,
            text: `Failed to delegate recording to the Minutes desktop app: ${e.message}\n\n` +
              "Check if Minutes.app is responding, or restart it and try again.",
          }],
          isError: true,
        };
      }
      if (response) {
        if (!response.accepted) {
          return {
            content: [{ type: "text" as const, text: response.detail }],
            structuredContent: { preflight, desktop_response: response },
          };
        }

        await new Promise((r) => setTimeout(r, 750));
        const { stdout: newStatus } = await runMinutes(["status"]);
        const result = parseJsonOutput(newStatus);
        let desktopLiveMsg = "";
        try {
          const { stdout: ltOut } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
          const ltStatus = parseJsonOutput(ltOut);
          if (ltStatus?.active) {
            desktopLiveMsg = " A live transcript is streaming — use read_live_transcript to follow along.";
          }
        } catch { /* sidecar may not have started yet */ }
        return {
          content: [
            {
              type: "text" as const,
              text: result.recording
                ? `Recording started in the running Minutes desktop app (PID: ${result.pid}).${Array.isArray(preflight.warnings) && preflight.warnings.length ? ` ${preflight.warnings[0]}` : ""}${desktopLiveMsg} When the user asks to finish (typed in chat), invoke stop_recording to process the transcript and summary. This server does not listen to audio content, so do not tell the user they can speak a stop command.`
                : response.detail,
            },
          ],
          structuredContent: { preflight, desktop_response: response },
        };
      }

      // Desktop app not running — in extension mode this means audio capture won't work.
      if (isExtensionRuntime) {
        return {
          content: [
            {
              type: "text" as const,
              text: "The Minutes desktop app is not running. The Claude Desktop extension " +
                "cannot capture audio directly (macOS blocks microphone access for " +
                "processes spawned from the extension runtime).\n\n" +
                "To fix: launch Minutes.app and try again. The extension will " +
                "delegate recording to the desktop app, which has its own " +
                "microphone permission.\n\n" +
                "Download: https://github.com/silverstein/minutes/releases/latest",
            },
          ],
          isError: true,
        };
      }
    }

    if (preflight.blocking_reason) {
      return {
        content: [
          {
            type: "text" as const,
            text: preflight.blocking_reason,
          },
        ],
        structuredContent: { preflight },
      };
    }

    // Spawn recording as a child process (not detached).
    // detached: true calls setsid() which creates a new macOS audit session,
    // severing the TCC microphone grant inherited from the host app (Claude Desktop).
    // CoreAudio then delivers all-zero samples — silent recordings.
    // The MCP server is long-lived, and the recording process ignores SIGTERM,
    // so child.unref() alone is sufficient.
    const args = ["record", "--mode", mode];
    if (title) args.push("--title", title);
    if (intent) args.push("--intent", intent);
    if (allow_degraded) args.push("--allow-degraded");
    if (skip_audio_probe_reason) args.push("--skip-audio-probe", skip_audio_probe_reason);
    if (language) args.push("--language", language);

    const child = spawn(MINUTES_BIN, args, {
      stdio: "ignore",
      env: { ...process.env, RUST_LOG: "info" },
    });
    child.unref();

    // Wait for PID file to appear
    await new Promise((r) => setTimeout(r, 1000));

    const { stdout: newStatus } = await runMinutes(["status"]);
    const result = parseJsonOutput(newStatus);

    // Check if the live transcript sidecar started (may still be loading the whisper model)
    let liveMsg = "";
    try {
      const { stdout: ltOut } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
      const ltStatus = parseJsonOutput(ltOut);
      if (ltStatus?.active) {
        liveMsg = " A live transcript is streaming — use read_live_transcript to follow along.";
      }
    } catch { /* sidecar may not have started yet — omit the message */ }

    return {
      content: [
        {
          type: "text" as const,
          text: result.recording
            ? `${result.recording_mode === "quick-thought" ? "Quick thought" : "Recording"} started (PID: ${result.pid}).${Array.isArray(preflight.warnings) && preflight.warnings.length ? ` ${preflight.warnings[0]}` : ""}${liveMsg} When the user asks to finish (typed in chat), invoke stop_recording to process the transcript and summary. This server does not listen to audio content, so do not tell the user they can speak a stop command.`
            : "Recording failed to start. Check `minutes logs` for details.",
        },
      ],
    };
  }
);

// ── Tool: stop_recording ────────────────────────────────────

registerTool(
  "stop_recording",
  "Stop the current recording and process it (transcribe, diarize, summarize).",
  {},
  { title: "Stop Recording", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async () => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    try {
      const { stdout, stderr } = await runMinutes(["stop"], 180000);
      const result = parseJsonOutput(stdout);

      if (result.status === "queued") {
        const title = result.title ? ` for ${result.title}` : "";
        const jobLine = result.job_id ? ` Job: ${result.job_id}.` : "";
        return {
          content: [
            {
              type: "text" as const,
              text: `Recording stopped. Processing queued${title}.${jobLine}`,
            },
          ],
        };
      }

      if (!result.file) {
        return { content: [{ type: "text" as const, text: stderr || "Recording stopped." }] };
      }

      // Trigger QMD re-index so new meeting is immediately searchable
      triggerQmdIndex();

      // Build a rich summary by reading the meeting frontmatter
      let summary = `## ${result.title ?? "Recording"}\n\n`;
      summary += `**Saved:** ${result.file}\n`;
      if (result.words != null) summary += `**Words:** ${result.words}\n`;

      try {
        const meeting = await reader.getMeeting(result.file);
        if (meeting) {
          const fm = meeting.frontmatter;
          if (fm.duration) summary += `**Duration:** ${fm.duration}\n`;
          if (fm.people?.length) summary += `**People:** ${fm.people.join(", ")}\n`;

          const actions = fm.action_items?.filter((a: any) => a.status === "open") || [];
          if (actions.length > 0) {
            summary += `\n### Action Items\n`;
            for (const item of actions) {
              summary += `- [ ] ${item.task}`;
              if (item.assignee) summary += ` (${item.assignee})`;
              if (item.due) summary += ` — due ${item.due}`;
              summary += `\n`;
            }
          }

          if (fm.decisions?.length) {
            summary += `\n### Decisions\n`;
            for (const d of fm.decisions) {
              summary += `- ${d.text}\n`;
            }
          }
        }
      } catch {
        // Frontmatter read is best-effort — basic info is already in the summary
      }

      return { content: [{ type: "text" as const, text: summary }] };
    } catch (error: any) {
      return {
        content: [{ type: "text" as const, text: `Stop failed: ${error.message}` }],
      };
    }
  }
);

// ── Tool: get_status ────────────────────────────────────────

registerTool(
  "get_status",
  "Check if a recording is currently in progress.",
  {},
  { title: "Recording Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async () => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: `No recording in progress (read-only mode).\n\n${CLI_INSTALL_MSG}` }] };
    }
    const { stdout } = await runMinutes(["status"]);
    const status = parseJsonOutput(stdout);
    const modeLabel = status.recording_mode === "quick-thought" ? "Quick thought" : "Recording";
    const processingLabel =
      status.recording_mode === "quick-thought" ? "Quick thought processing" : "Processing";
    const text = status.recording
      ? `${modeLabel} in progress (PID: ${status.pid})`
      : status.processing
        ? `${processingLabel}${status.processing_title ? ` for ${status.processing_title}` : ""}${status.processing_stage ? `: ${status.processing_stage}` : "."}${status.processing_job_count > 1 ? ` (${status.processing_job_count} jobs queued)` : ""}`
        : "No recording in progress.";
    return { content: [{ type: "text" as const, text }] };
  }
);

registerTool(
  "list_processing_jobs",
  "List background processing jobs for recent recordings, including queued, transcript-ready, needs-review, failed, and completed work.",
  {
    limit: z.number().optional().default(10).describe("Maximum number of jobs"),
    include_completed: z.boolean().optional().default(true).describe("Include completed and failed jobs"),
  },
  { title: "Processing Jobs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ limit, include_completed }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    const args = ["jobs", "--json", "--limit", String(limit)];
    if (include_completed) args.push("--all");

    try {
      const { stdout } = await runMinutes(args);
      const jobs = parseJsonOutput(stdout);
      if (!Array.isArray(jobs) || jobs.length === 0) {
        return {
          content: [{ type: "text" as const, text: "No processing jobs right now." }],
          structuredContent: { jobs: [] },
        };
      }

      const lines = jobs.map((job: any) => {
        const title = job.title || "Queued recording";
        const state = job.state || "queued";
        const stage = job.stage ? ` — ${job.stage}` : "";
        return `- ${job.id}: ${state} — ${title}${stage}`;
      });

      return {
        content: [{ type: "text" as const, text: `Processing jobs:\n\n${lines.join("\n")}` }],
        structuredContent: { jobs },
      };
    } catch (error: any) {
      return {
        content: [{ type: "text" as const, text: `Failed to list processing jobs: ${error.message}` }],
        isError: true,
      };
    }
  }
);

// ── Tool: list_meetings ─────────────────────────────────────

registerDocsAppTool(
  server,
  "list_meetings",
  {
    description: "List recent meetings and voice memos.",
    inputSchema: {
      limit: z.number().optional().default(10).describe("Maximum results"),
      type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
    },
    annotations: { title: "List Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ limit, type: contentType }) => {
    // Pure-TS fallback when CLI is not available
    if (!(await isCliAvailable())) {
      const meetings = await reader.listMeetings(MEETINGS_DIR, limit);
      const filtered = contentType
        ? meetings.filter((m) => m.frontmatter.type === contentType)
        : meetings;
      const openActions = await reader.findOpenActions(MEETINGS_DIR);

      if (filtered.length === 0) {
        return {
          content: [{ type: "text" as const, text: "No meetings or memos found." }],
          structuredContent: { meetings: [], actions: [], view: "dashboard" },
          _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
        };
      }

      const text = filtered
        .map((m) => `${m.frontmatter.date} — ${m.frontmatter.title} [${m.frontmatter.type}]\n  ${m.path}`)
        .join("\n\n");

      const meetingsJson = filtered.map(meetingListItem);

      return {
        content: [{ type: "text" as const, text }],
        structuredContent: { meetings: meetingsJson, actions: openActions.map((a) => a.item), view: "dashboard" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
      };
    }

    const args = ["list", "--limit", String(limit)];
    if (contentType) args.push("-t", contentType);

    // Fetch meetings and action items in parallel
    const [meetingsResult, actionsResult] = await Promise.all([
      runMinutes(args),
      runMinutes(["search", "", "--intents-only", "--intent-kind", "action-item", "--limit", "20"]).catch(() => ({ stdout: "[]", stderr: "" })),
    ]);

    const meetings = parseJsonOutput(meetingsResult.stdout);
    let actions: any[] = [];
    const parsedActions = parseJsonOutput(actionsResult.stdout);
    if (Array.isArray(parsedActions)) actions = parsedActions;

    if (Array.isArray(meetings) && meetings.length === 0) {
      return {
        content: [{ type: "text" as const, text: "No meetings or memos found." }],
        structuredContent: { meetings: [], actions, view: "dashboard" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
      };
    }

    const text = Array.isArray(meetings)
      ? meetings
          .map((m: any) => `${m.date} — ${m.title} [${m.content_type}]\n  ${m.path}`)
          .join("\n\n")
      : (meetingsResult.stderr || meetingsResult.stdout);

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { meetings: Array.isArray(meetings) ? meetings : [], actions, view: "dashboard" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
    };
  }
);

// ── Tool: search_meetings ───────────────────────────────────

registerDocsAppTool(
  server,
  "search_meetings",
  {
    description: "Search meeting transcripts and voice memos.",
    inputSchema: {
      query: z.string().describe("Text to search for"),
      type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
      since: z.string().optional().describe("Only results after this date (ISO)"),
      limit: z.number().optional().default(10).describe("Maximum results"),
      intent_kind: z
        .enum(["action-item", "decision", "open-question", "commitment"])
        .optional()
        .describe("Filter structured intents by kind"),
      owner: z.string().optional().describe("Filter structured intents by owner / person"),
      intents_only: z
        .boolean()
        .optional()
        .default(false)
        .describe("Return structured intent records instead of transcript snippets"),
    },
    annotations: { title: "Search Meetings", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ query, type: contentType, since, limit, intent_kind, owner, intents_only }) => {
    // Pure-TS fallback when CLI is not available
    if (!(await isCliAvailable())) {
      const droppedFilters = [since && "since", intent_kind && "intent_kind", owner && "owner", intents_only && "intents_only"].filter(Boolean);
      const filterWarning = droppedFilters.length > 0
        ? `\n\n(Note: ${droppedFilters.join(", ")} filters require the CLI. Install: brew install minutes)`
        : "";

      const results = await reader.searchMeetings(MEETINGS_DIR, query, limit);
      const filtered = contentType
        ? results.filter((m) => m.frontmatter.type === contentType)
        : results;

      if (filtered.length === 0) {
        return {
          content: [{ type: "text" as const, text: `No results for "${query}".${filterWarning}` }],
          structuredContent: { results: [], view: "search" },
          _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "search" },
        };
      }

      const text = filtered
        .map((m) => `${m.frontmatter.date} — ${m.frontmatter.title} [${m.frontmatter.type}]\n  ${m.path}`)
        .join("\n\n") + filterWarning;

      return {
        content: [{ type: "text" as const, text }],
        structuredContent: {
          results: filtered.map(meetingSearchItem),
          view: "search",
        },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "search" },
      };
    }

    // Intent/metadata queries always use CLI (QMD doesn't index YAML frontmatter fields)
    const useCliOnly = intents_only || intent_kind || owner || since;

    // Try QMD semantic search for text queries
    let results: any[] | null = null;
    let usedQmd = false;

    if (!useCliOnly) {
      results = await searchViaQmd(query, limit, contentType);
      if (results) usedQmd = true;
    }

    // Fall back to CLI regex search
    if (!results) {
      const args = ["search", query, "--limit", String(limit)];
      if (contentType) args.push("-t", contentType);
      if (since) args.push("--since", since);
      if (intent_kind) args.push("--intent-kind", intent_kind);
      if (owner) args.push("--owner", owner);
      if (intents_only) args.push("--intents-only");

      const { stdout, stderr } = await runMinutes(args);
      const parsed = parseJsonOutput(stdout);
      results = Array.isArray(parsed) ? parsed : [];
    }

    if (results.length === 0) {
      return {
        content: [{ type: "text" as const, text: `No results found for "${query}".` }],
        structuredContent: { meetings: [], actions: [], view: "dashboard" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
      };
    }

    const text = intents_only
      ? results
          .map(
            (r: any) =>
              `${r.date} — ${r.title} [${r.content_type}]\n  ${r.kind}: ${r.what}${r.who ? ` (@${r.who})` : ""}${r.by_date ? ` by ${r.by_date}` : ""}\n  ${r.path}`
          )
          .join("\n\n")
      : results
          .map(
            (r: any) =>
              `${r.date} — ${r.title} [${r.content_type}]\n  ${r.snippet}\n  ${r.path}`
          )
          .join("\n\n");

    // Map search results to meeting-like objects for the dashboard view
    const meetings = results.map((r: any) => ({
          date: r.date,
          title: r.title,
          content_type: r.content_type,
          path: r.path,
          snippet: r.snippet || (intents_only ? `${r.kind}: ${r.what}` : undefined),
        }));

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { meetings, actions: [], view: "dashboard" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "dashboard" },
    };
  }
);

// ── Tool: activity_summary ──────────────────────────────────
// Feature-gated (#183 phase 2). Hidden when an already-installed CLI does not
// report activity_summary support. If the CLI is missing at boot, the tool
// stays visible so first-run auto-install can still make it usable without a
// server restart.

if (hasFeature(CLI_CAPABILITIES, "activity_summary"))
registerDocsAppTool(
  server,
  "activity_summary",
  {
    description: "Summarize meeting-adjacent desktop context for a linked artifact, context session, or explicit time window.",
    inputSchema: {
      session_id: z.string().optional().describe("Explicit desktop-context session id"),
      path: z.string().optional().describe("Linked artifact path, such as a meeting markdown file or live transcript JSONL"),
      start: z.string().optional().describe("Window start (RFC3339); use with end when no session/path is provided"),
      end: z.string().optional().describe("Window end (RFC3339); use with start when no session/path is provided"),
    },
    annotations: { title: "Activity Summary", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ session_id, path, start, end }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: `Desktop-context summaries require the full CLI.\n\n${CLI_INSTALL_MSG}` }] };
    }

    const args = ["context", "activity-summary", "--json"];
    if (session_id) args.push("--session", session_id);
    if (path) args.push("--path", path);
    if (start) args.push("--start", start);
    if (end) args.push("--end", end);

    const { stdout, stderr } = await runMinutes(args);
    const parsed = parseJsonOutput(stdout);
    if (!parsed || typeof parsed !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    const apps = Array.isArray((parsed as any).top_apps) ? (parsed as any).top_apps : [];
    const windows = Array.isArray((parsed as any).top_windows) ? (parsed as any).top_windows : [];
    const events = Array.isArray((parsed as any).events) ? (parsed as any).events : [];
    const lines = [
      `Desktop context summary: ${(parsed as any).window?.start || "?"} -> ${(parsed as any).window?.end || "?"}`,
      apps.length ? `Top apps: ${apps.map((entry: any) => `${entry.name} (${entry.count})`).join(", ")}` : "",
      windows.length ? `Top windows: ${windows.map((entry: any) => `${entry.name} (${entry.count})`).join(", ")}` : "",
      events.length ? `Events: ${events.length}` : "Events: 0",
    ].filter(Boolean);

    return {
      content: [{ type: "text" as const, text: lines.join("\n") }],
      structuredContent: { ...(parsed as any), kind: "activity_summary", view: "context" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "context", kind: "activity_summary" },
    };
  }
);

// ── Tool: search_context ────────────────────────────────────
// Feature-gated (#183 phase 2). See activity_summary comment above.

if (hasFeature(CLI_CAPABILITIES, "search_context"))
registerDocsAppTool(
  server,
  "search_context",
  {
    description: "Search desktop-context events across app focus and captured window titles, including opted-in browser titles.",
    inputSchema: {
      query: z.string().describe("Text query for app names, bundle ids, or captured window titles"),
      limit: z.number().optional().default(20).describe("Maximum results"),
    },
    annotations: { title: "Search Context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ query, limit }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: `Desktop-context search requires the full CLI.\n\n${CLI_INSTALL_MSG}` }] };
    }

    const { stdout, stderr } = await runMinutes(["context", "search", query, "--limit", String(limit), "--json"]);
    const parsed = parseJsonOutput(stdout);
    if (!parsed || typeof parsed !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    const results = Array.isArray((parsed as any).results) ? (parsed as any).results : [];
    const text = results.length === 0
      ? `No desktop-context events found for "${query}".`
      : results
          .map(
            (event: any) =>
              `${event.observed_at} — ${event.app_name || event.bundle_id || "unknown"}${event.window_title ? ` :: ${event.window_title}` : ""}`
          )
          .join("\n");

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { query, results, view: "context", kind: "search_context" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "context", kind: "search_context" },
    };
  }
);

// ── Tool: get_moment ────────────────────────────────────────
// Feature-gated (#183 phase 2). See activity_summary comment above.

if (hasFeature(CLI_CAPABILITIES, "get_moment"))
registerDocsAppTool(
  server,
  "get_moment",
  {
    description: "Show the local rewind around a linked artifact, context session, or explicit timestamp.",
    inputSchema: {
      session_id: z.string().optional().describe("Explicit desktop-context session id"),
      path: z.string().optional().describe("Linked artifact path, such as a meeting markdown file or live transcript JSONL"),
      at: z.string().optional().describe("Explicit anchor timestamp (RFC3339)"),
      before_minutes: z.number().optional().default(10).describe("Minutes before the anchor"),
      after_minutes: z.number().optional().default(10).describe("Minutes after the anchor"),
    },
    annotations: { title: "Get Moment", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ session_id, path, at, before_minutes, after_minutes }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: `Desktop-context rewind requires the full CLI.\n\n${CLI_INSTALL_MSG}` }] };
    }

    const args = ["context", "get-moment", "--json", "--before-minutes", String(before_minutes), "--after-minutes", String(after_minutes)];
    if (session_id) args.push("--session", session_id);
    if (path) args.push("--path", path);
    if (at) args.push("--at", at);

    const { stdout, stderr } = await runMinutes(args);
    const parsed = parseJsonOutput(stdout);
    if (!parsed || typeof parsed !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    const events = Array.isArray((parsed as any).events) ? (parsed as any).events : [];
    const text = [
      `Moment window: ${(parsed as any).window?.start || "?"} -> ${(parsed as any).window?.end || "?"}`,
      ...events.map(
        (event: any) =>
          `${event.observed_at} — ${event.app_name || event.bundle_id || "unknown"}${event.window_title ? ` :: ${event.window_title}` : ""}`
      ),
    ].join("\n");

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { ...(parsed as any), view: "context", kind: "get_moment" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "context", kind: "get_moment" },
    };
  }
);

// ── Tool: consistency_report ───────────────────────────────

registerDocsAppTool(
  server,
  "consistency_report",
  {
    description: "Flag conflicting decisions and stale commitments across meetings using structured intent data.",
    inputSchema: {
      owner: z.string().optional().describe("Filter stale commitments by owner / person"),
      stale_after_days: z
        .number()
        .optional()
        .default(7)
        .describe("Flag commitments this many days old or older"),
    },
    annotations: { title: "Consistency Report", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ owner, stale_after_days }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: `Consistency reports require the full CLI for structured intent analysis.\n\n${CLI_INSTALL_MSG}` }] };
    }
    const args = ["consistency", "--stale-after-days", String(stale_after_days)];
    if (owner) args.push("--owner", owner);

    const { stdout, stderr } = await runMinutes(args);
    const report = parseJsonOutput(stdout);

    if (!report || typeof report !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    const decisionConflicts = Array.isArray(report.decision_conflicts)
      ? report.decision_conflicts
      : [];
    const staleCommitments = Array.isArray(report.stale_commitments)
      ? report.stale_commitments
      : [];

    if (decisionConflicts.length === 0 && staleCommitments.length === 0) {
      return {
        content: [{ type: "text" as const, text: "No consistency issues found." }],
        structuredContent: { decision_conflicts: [], stale_commitments: [], view: "report" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "report" },
      };
    }

    const sections = [];
    if (decisionConflicts.length > 0) {
      sections.push(
        "Decision conflicts:\n" +
          decisionConflicts
            .map(
              (conflict: any) =>
                `- ${conflict.topic}: latest "${conflict.latest.what}" (${conflict.latest.title})`
            )
            .join("\n")
      );
    }
    if (staleCommitments.length > 0) {
      sections.push(
        "Stale commitments:\n" +
          staleCommitments
            .map(
              (stale: any) =>
                `- ${stale.kind}: ${stale.entry.what}${stale.entry.who ? ` (@${stale.entry.who})` : ""} — ${Array.isArray(stale.reasons) ? stale.reasons.join(", ") : `${stale.age_days} days old`}${stale.latest_follow_up ? `; latest follow-up: ${stale.latest_follow_up.title}` : ""}`
            )
            .join("\n")
      );
    }

    return {
      content: [{ type: "text" as const, text: sections.join("\n\n") }],
      structuredContent: { decision_conflicts: decisionConflicts, stale_commitments: staleCommitments, view: "report" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "report" },
    };
  }
);

// ── Tool: get_person_profile ───────────────────────────────

registerDocsAppTool(
  server,
  "get_person_profile",
  {
    description: "Get a rich relationship profile for a person: meetings, commitments, topics, relationship score, and trend. Uses the conversation graph index for instant results.",
    inputSchema: {
      name: z.string().describe("Person / attendee name to profile"),
    },
    annotations: { title: "Person Profile", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ name }) => {
    // Try graph index first (via CLI `minutes people --json`)
    if (await isCliAvailable()) {
      const { stdout } = await runMinutes(["people", "--json"]);
      const people = parseJsonOutput(stdout);

      if (Array.isArray(people)) {
        const nameLower = name.toLowerCase();
        const match = people.find((p: any) =>
          p.name?.toLowerCase().includes(nameLower) ||
          p.slug?.toLowerCase().includes(nameLower)
        );

        if (match) {
          const daysSince = Math.round(match.days_since || 0);
          const last = daysSince < 1 ? "today" : daysSince < 2 ? "yesterday" : `${daysSince}d ago`;
          const sections = [];

          sections.push(`Relationship score: ${(match.score || 0).toFixed(1)} | ${match.meeting_count} meetings | last: ${last}`);

          if (match.losing_touch) {
            sections.push("⚠ LOSING TOUCH — meeting frequency has declined");
          }

          if (match.top_topics?.length > 0) {
            sections.push("Top topics: " + match.top_topics.join(", "));
          }

          if (match.open_commitments > 0) {
            sections.push(`Open commitments: ${match.open_commitments}`);
          }

          return {
            content: [{ type: "text" as const, text: `Profile for ${match.name}:\n\n${sections.join("\n")}` }],
            structuredContent: { ...match, view: "person" },
            _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
          };
        }
      }

      // Fall back to legacy CLI person command for richer meeting-level data
      const { stdout: legacyOut, stderr } = await runMinutes(["person", name]);
      const profile = parseJsonOutput(legacyOut);

      if (profile && typeof profile === "object") {
        const topics = Array.isArray(profile.top_topics) ? profile.top_topics : [];
        const openIntents = Array.isArray(profile.open_intents) ? profile.open_intents : [];
        const recentMeetings = Array.isArray(profile.recent_meetings) ? profile.recent_meetings : [];

        if (topics.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
          return {
            content: [{ type: "text" as const, text: `No profile data found for ${name}.` }],
            structuredContent: { name, top_topics: [], open_intents: [], recent_meetings: [], view: "person" },
            _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
          };
        }

        const sections = [];
        if (topics.length > 0) sections.push("Top topics:\n" + topics.map((t: any) => `- ${t.topic} (${t.count})`).join("\n"));
        if (openIntents.length > 0) sections.push("Open commitments:\n" + openIntents.map((i: any) => `- ${i.kind}: ${i.what}${i.by_date ? ` by ${i.by_date}` : ""}`).join("\n"));
        if (recentMeetings.length > 0) sections.push("Recent meetings:\n" + recentMeetings.map((m: any) => `- ${m.date} — ${m.title}`).join("\n"));

        return {
          content: [{ type: "text" as const, text: `Profile for ${profile.name}:\n\n${sections.join("\n\n")}` }],
          structuredContent: { ...profile, view: "person" },
          _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
        };
      }

      return { content: [{ type: "text" as const, text: stderr || legacyOut || `No data found for ${name}.` }] };
    }

    // Pure-TS fallback when CLI is not available
    const profile = await reader.getPersonProfile(MEETINGS_DIR, name);
    const sections = [];
    if (profile.topics.length > 0) sections.push("Topics: " + profile.topics.join(", "));
    if (profile.meetings.length > 0) sections.push("Meetings:\n" + profile.meetings.map((m) => `- ${m.date} — ${m.title}`).join("\n"));
    if (profile.openActions.length > 0) sections.push("Open actions:\n" + profile.openActions.map((a) => `- ${a.task} (${a.status})`).join("\n"));
    const text = sections.length > 0 ? sections.join("\n\n") : `No profile data found for ${name}.`;
    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { name, top_topics: profile.topics.map((t) => ({ topic: t, count: 1 })), open_intents: profile.openActions, recent_meetings: profile.meetings, view: "person" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "person" },
    };
  }
);

// ── Tool: research_topic ────────────────────────────────────

registerTool(
  "research_topic",
  "Research a topic across meetings, decisions, and open follow-ups.",
  {
    query: z.string().describe("Topic or question to investigate across meetings"),
    type: z.enum(["meeting", "memo"]).optional().describe("Filter by type"),
    since: z.string().optional().describe("Only results after this date (ISO)"),
    attendee: z.string().optional().describe("Filter by attendee / person"),
  },
  { title: "Research Topic", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ query, type: contentType, since, attendee }) => {
    if (!(await isCliAvailable())) {
      // Fallback: basic search when CLI is not available
      const results = await reader.searchMeetings(MEETINGS_DIR, query, 20);
      const filtered = contentType ? results.filter((m) => m.frontmatter.type === contentType) : results;
      const text = filtered.length > 0
        ? filtered.map((m) => `${m.frontmatter.date} — ${m.frontmatter.title}\n  ${m.path}`).join("\n\n")
        : `No results for "${query}". (Note: advanced research features require the CLI.)`;
      return { content: [{ type: "text" as const, text }] };
    }

    const args = ["research", query];
    if (contentType) args.push("-t", contentType);
    if (since) args.push("--since", since);
    if (attendee) args.push("--attendee", attendee);

    const { stdout, stderr } = await runMinutes(args);
    const report = parseJsonOutput(stdout);

    if (!report || typeof report !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    const decisions = Array.isArray(report.related_decisions) ? report.related_decisions : [];
    const openIntents = Array.isArray(report.related_open_intents)
      ? report.related_open_intents
      : [];
    const recentMeetings = Array.isArray(report.recent_meetings)
      ? report.recent_meetings
      : [];
    const topics = Array.isArray(report.related_topics) ? report.related_topics : [];

    if (decisions.length === 0 && openIntents.length === 0 && recentMeetings.length === 0) {
      return {
        content: [
          {
            type: "text" as const,
            text: `No cross-meeting results found for ${query}.`,
          },
        ],
      };
    }

    const sections = [];
    if (topics.length > 0) {
      sections.push(
        "Related topics:\n" +
          topics.map((topic: any) => `- ${topic.topic} (${topic.count})`).join("\n")
      );
    }
    if (decisions.length > 0) {
      sections.push(
        "Recent decisions:\n" +
          decisions
            .map((decision: any) => `- ${decision.date} — ${decision.what} (${decision.title})`)
            .join("\n")
      );
    }
    if (openIntents.length > 0) {
      sections.push(
        "Open follow-ups:\n" +
          openIntents
            .map(
              (intent: any) =>
                `- ${intent.kind}: ${intent.what}${intent.who ? ` (@${intent.who})` : ""}${intent.by_date ? ` by ${intent.by_date}` : ""}`
            )
            .join("\n")
      );
    }
    if (recentMeetings.length > 0) {
      sections.push(
        "Matching meetings:\n" +
          recentMeetings
            .map((meeting: any) => `- ${meeting.date} — ${meeting.title}`)
            .join("\n")
      );
    }

    return {
      content: [
        {
          type: "text" as const,
          text: `Cross-meeting research for ${query}:\n\n${sections.join("\n\n")}`,
        },
      ],
    };
  }
);

// ── Tool: get_meeting ───────────────────────────────────────

registerDocsAppTool(
  server,
  "get_meeting",
  {
    description: "Get the full transcript and details of a specific meeting or memo. Speaker attributions reflect sidecar overlay confirmations.",
    inputSchema: {
      path: z.string().describe("Path to the meeting markdown file"),
    },
    annotations: { title: "View Meeting", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ path: filePath }) => {
    try {
      const resolved = validatePathInDirectory(filePath, await getEffectiveMeetingsDir(), [".md"]);
      const rawContent = await readFile(resolved, "utf-8");

      // Ask the CLI for an overlay-applied structured view. Raw markdown on
      // disk is never mutated — the CLI just layers ~/.minutes/overlays.db on
      // top of the parsed frontmatter. If the CLI is unavailable or the call
      // fails, degrade gracefully to raw content.
      //
      // structuredContent mirrors what is on disk: the transcript body plus the
      // synthesized fields (summary, action_items, decisions, intents). The raw
      // markdown still rides along in content[0].text, but structured-content
      // consumers and MCP-App hosts that surface structuredContent over the text
      // block must not be left with an envelope only (issue #255).
      const rawParsed = reader.parseFrontmatter(rawContent, resolved);
      let structured = meetingDetailPayload({
        path: resolved,
        recording_health: (rawParsed?.frontmatter as any)?.recording_health,
        title: (rawParsed?.frontmatter as any)?.title,
        summary: extractMarkdownSection(rawParsed?.body, "Summary"),
        action_items: (rawParsed?.frontmatter as any)?.action_items ?? [],
        decisions: (rawParsed?.frontmatter as any)?.decisions ?? [],
        intents: (rawParsed?.frontmatter as any)?.intents ?? [],
        body: rawParsed?.body ?? rawContent,
      });

      if (await isCliAvailable()) {
        try {
          const { stdout } = await runMinutes(["get", resolved, "--json"], 10000);
          const parsed = parseJsonOutput(stdout);
          if (parsed && typeof parsed === "object" && !parsed.raw) {
            const speakerMap = parsed.frontmatter?.speaker_map;
            const body = typeof parsed.body === "string" ? parsed.body : rawParsed?.body;
            structured = meetingDetailPayload({
              path: resolved,
              speaker_map: Array.isArray(speakerMap) ? speakerMap : [],
              recording_health: parsed.frontmatter?.recording_health,
              overlay_applied: Boolean(parsed.overlay_applied),
              title: parsed.frontmatter?.title,
              summary: extractMarkdownSection(body, "Summary"),
              action_items: Array.isArray(parsed.frontmatter?.action_items)
                ? parsed.frontmatter.action_items
                : [],
              decisions: Array.isArray(parsed.frontmatter?.decisions)
                ? parsed.frontmatter.decisions
                : [],
              intents: Array.isArray(parsed.frontmatter?.intents)
                ? parsed.frontmatter.intents
                : [],
              body: body ?? rawContent,
            });
          }
        } catch {
          // Non-fatal: fall through to raw content with no speaker_map enrichment.
        }
      }

      return {
        content: [{ type: "text" as const, text: rawContent }],
        structuredContent: structured,
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "detail", path: resolved },
      };
    } catch (error: any) {
      return {
        content: [{ type: "text" as const, text: `Could not read: ${error.message}` }],
      };
    }
  }
);

// ── Tool: process_audio ─────────────────────────────────────

registerTool(
  "process_audio",
  "Process an audio file through the transcription pipeline.",
  {
    file_path: z.string().describe("Path to audio file (.wav, .m4a, .mp3)"),
    type: z.enum(["meeting", "memo"]).optional().default("memo").describe("Content type"),
    title: z.string().optional().describe("Optional title"),
    language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
  },
  { title: "Process Audio", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({ file_path, type: contentType, title, language }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    const allowedDirs = [
      join(MINUTES_HOME, "inbox"),
      await getEffectiveMeetingsDir(),
      join(homedir(), "Downloads"),
    ];
    const audioExts = [".wav", ".m4a", ".mp3", ".ogg", ".webm"];

    try {
      const resolved = validatePathInDirectories(file_path, allowedDirs, audioExts);
      const args = ["process", resolved, "-t", contentType];
      if (title) args.push("--title", title);
      if (language) args.push("--language", language);
      const { stdout } = await runMinutes(args, 300000);
      const result = parseJsonOutput(stdout);

      return {
        content: [
          {
            type: "text" as const,
            text: result.file
              ? `Processed: ${result.file}\nTitle: ${result.title}\nWords: ${result.words}`
              : stdout,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [{ type: "text" as const, text: `Failed: ${error.message}` }],
      };
    }
  }
);

// ── Tool: add_note ───────────────────────────────────────────

registerTool(
  "add_note",
  "Add a note to the current recording. Notes are timestamped and included in the meeting summary. If no recording is active, annotate an existing meeting file with --meeting.",
  {
    text: z.string().describe("The note text (plain text, no markdown needed)"),
    meeting_path: z
      .string()
      .optional()
      .describe("Path to an existing meeting file to annotate (for post-meeting notes)"),
  },
  { title: "Add Note", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({ text, meeting_path }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    try {
      const args = ["note", text];
      if (meeting_path) {
        const resolved = validatePathInDirectory(
          meeting_path,
          await getEffectiveMeetingsDir(),
          [".md"]
        );
        args.push("--meeting", resolved);
      }

      const { stdout, stderr } = await runMinutes(args);
      return {
        content: [{ type: "text" as const, text: stderr || stdout || "Note added." }],
      };
    } catch (error: any) {
      return {
        content: [{ type: "text" as const, text: `Note failed: ${error.message}` }],
      };
    }
  }
);

// ── Tool: qmd_collection_status ─────────────────────────────

registerTool(
  "qmd_collection_status",
  "Check whether the Minutes output directory is already registered as a QMD collection.",
  {
    collection: z
      .string()
      .optional()
      .default("minutes")
      .describe("QMD collection name to check"),
  },
  { title: "QMD Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ collection }) => {
    const { stdout, stderr } = await runMinutes([
      "qmd",
      "status",
      "--collection",
      collection,
    ]);
    const report = parseJsonOutput(stdout);

    if (!report || typeof report !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    if (!report.qmd_available) {
      return {
        content: [
          {
            type: "text" as const,
            text: `QMD is not installed or not on PATH. Install qmd, then run register_qmd_collection for "${collection}".`,
          },
        ],
      };
    }

    if (report.registered) {
      return {
        content: [
          {
            type: "text" as const,
            text: `QMD collection "${collection}" already indexes ${report.output_dir}.`,
          },
        ],
      };
    }

    const aliases = Array.isArray(report.matching_collections)
      ? report.matching_collections.map((candidate: any) => candidate.name)
      : [];

    return {
      content: [
        {
          type: "text" as const,
          text:
            aliases.length > 0
              ? `${report.output_dir} is already indexed in QMD under: ${aliases.join(", ")}.`
              : `${report.output_dir} is not indexed in QMD yet.`,
        },
      ],
    };
  }
);

// ── Tool: register_qmd_collection ───────────────────────────

registerTool(
  "register_qmd_collection",
  "Register the Minutes output directory as a QMD collection.",
  {
    collection: z
      .string()
      .optional()
      .default("minutes")
      .describe("QMD collection name to register"),
  },
  { title: "Register QMD", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ collection }) => {
    const { stdout, stderr } = await runMinutes([
      "qmd",
      "register",
      "--collection",
      collection,
    ]);
    const report = parseJsonOutput(stdout);

    if (!report || typeof report !== "object") {
      return { content: [{ type: "text" as const, text: stderr || stdout }] };
    }

    if (!report.registered) {
      return {
        content: [
          {
            type: "text" as const,
            text: stderr || stdout || `Failed to register QMD collection "${collection}".`,
          },
        ],
      };
    }

    return {
      content: [
        {
          type: "text" as const,
          text: `Registered ${report.output_dir} as QMD collection "${collection}".`,
        },
      ],
    };
  }
);

// ── Tool: track_commitments ─────────────────────────────────

registerDocsAppTool(
  server,
  "track_commitments",
  {
    description: "List open and stale commitments (action items, intents, decisions) across all meetings. Optionally filter by person. Answers: 'What did I promise Sarah?' or 'What's overdue?'",
    inputSchema: {
      person: z.string().optional().describe("Filter by person name or slug (optional — omit for all commitments)"),
    },
    annotations: { title: "Track Commitments", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ person }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: "Minutes CLI not available. Install with: cargo install minutes-cli" }] };
    }

    // Use dedicated commitments command for full text detail
    const args = ["commitments", "--json"];
    if (person) args.push("--person", person);

    const { stdout } = await runMinutes(args);
    const commitments = parseJsonOutput(stdout);

    if (!Array.isArray(commitments) || commitments.length === 0) {
      const scope = person ? ` for ${person}` : "";
      return {
        content: [{ type: "text" as const, text: `No open commitments found${scope}.` }],
        structuredContent: { commitments: [], person: person || null, view: "commitments" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
      };
    }

    // Group by status
    const stale = commitments.filter((c: any) => c.status === "stale");
    const open = commitments.filter((c: any) => c.status === "open");

    const lines: string[] = [];
    if (stale.length > 0) {
      lines.push(`STALE (${stale.length} overdue):`);
      for (const c of stale) {
        const who = c.person_name || "unassigned";
        lines.push(`  ⚠ ${c.text} (${who}; due: ${c.due_date || "no date"}; from: ${c.meeting_title})`);
      }
    }
    if (open.length > 0) {
      if (stale.length > 0) lines.push("");
      lines.push(`OPEN (${open.length}):`);
      for (const c of open) {
        const who = c.person_name || "unassigned";
        lines.push(`  · ${c.text} (${who}; from: ${c.meeting_title})`);
      }
    }

    const text = `Commitments${person ? ` for ${person}` : ""}:\n\n${lines.join("\n")}`;

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { commitments, person: person || null, stale_count: stale.length, open_count: open.length, view: "commitments" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "commitments" },
    };
  }
);

// ── Tool: relationship_map ──────────────────────────────────

registerDocsAppTool(
  server,
  "relationship_map",
  {
    description: "Show all contacts with relationship scores, meeting frequency, and 'losing touch' alerts. Overview of your entire conversation network.",
    inputSchema: {
      limit: z.number().optional().describe("Max people to return (default: 15)"),
    },
    annotations: { title: "Relationship Map", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
    _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
  },
  async ({ limit }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: "Minutes CLI not available. Install with: cargo install minutes-cli" }] };
    }

    const maxPeople = limit || 15;
    const { stdout } = await runMinutes(["people", "--json", "--limit", String(maxPeople)]);
    const people = parseJsonOutput(stdout);

    if (!Array.isArray(people) || people.length === 0) {
      return {
        content: [{ type: "text" as const, text: "No relationship data found. Run: minutes people --rebuild" }],
        structuredContent: { people: [], view: "relationship_map" },
        _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "relationship_map" },
      };
    }

    // Format human-readable output
    const lines: string[] = [];
    const losingTouch: string[] = [];

    for (const p of people) {
      const daysSince = Math.round(p.days_since || 0);
      const last = daysSince < 1 ? "today" : daysSince < 2 ? "yesterday" : `${daysSince}d ago`;
      const status = p.losing_touch
        ? "⚠ losing touch"
        : p.open_commitments > 0
          ? `${p.open_commitments} open commitment${p.open_commitments !== 1 ? "s" : ""}`
          : "✓ all clear";

      lines.push(`${p.name} — ${p.meeting_count} meetings, last: ${last}, ${status} (score: ${(p.score || 0).toFixed(1)})`);

      if (p.losing_touch) {
        losingTouch.push(`${p.name} — ${p.meeting_count} meetings total, last seen ${daysSince}d ago`);
      }
    }

    let text = `Relationship Map (${people.length} contacts):\n\n${lines.join("\n")}`;
    if (losingTouch.length > 0) {
      text += `\n\nLosing Touch:\n${losingTouch.join("\n")}`;
    }

    return {
      content: [{ type: "text" as const, text }],
      structuredContent: { people, view: "relationship_map" },
      _meta: { ui: { resourceUri: UI_RESOURCE_URI }, view: "relationship_map" },
    };
  }
);

// ── Resources ───────────────────────────────────────────────

server.resource(
  "recent_meetings",
  "minutes://meetings/recent",
  { description: "List of recent meetings and memos" },
  async () => {
    if (!(await isCliAvailable())) {
      const meetings = await reader.listMeetings(MEETINGS_DIR, 20);
      const json = JSON.stringify(meetings.map(meetingListItem));
      return { contents: [{ uri: "minutes://meetings/recent", mimeType: "application/json", text: json }] };
    }
    const { stdout } = await runMinutes(["list", "--limit", "20"]);
    return { contents: [{ uri: "minutes://meetings/recent", mimeType: "application/json", text: stdout }] };
  }
);

server.resource(
  "recording_status",
  "minutes://status",
  { description: "Current recording status" },
  async () => {
    if (!(await isCliAvailable())) {
      return { contents: [{ uri: "minutes://status", mimeType: "application/json", text: JSON.stringify({ recording: false, processing: false, note: "Read-only mode (CLI not installed)" }) }] };
    }
    const { stdout } = await runMinutes(["status"]);
    return { contents: [{ uri: "minutes://status", mimeType: "application/json", text: stdout }] };
  }
);

server.resource(
  "open_actions",
  "minutes://actions/open",
  { description: "All open action items across meetings" },
  async () => {
    if (!(await isCliAvailable())) {
      const actions = await reader.findOpenActions(MEETINGS_DIR);
      return { contents: [{ uri: "minutes://actions/open", mimeType: "application/json", text: JSON.stringify(actions) }] };
    }
    const { stdout } = await runMinutes(["search", "", "--intents-only", "--intent-kind", "action-item"]);
    return { contents: [{ uri: "minutes://actions/open", mimeType: "application/json", text: stdout }] };
  }
);

server.resource(
  "recent_events",
  "minutes://events/recent",
  { description: "Recent pipeline events (recordings, processing, notes)" },
  async () => {
    if (!(await isCliAvailable())) {
      return { contents: [{ uri: "minutes://events/recent", mimeType: "application/json", text: "[]" }] };
    }
    const { stdout } = await runMinutes(["events", "--limit", "20"]);
    return { contents: [{ uri: "minutes://events/recent", mimeType: "application/json", text: stdout }] };
  }
);

server.resource(
  "agent_annotations",
  "minutes://events/agent-annotations",
  { description: "Recent append-only agent.annotation events, separate from human meeting markdown" },
  async () => {
    if (!(await isCliAvailable())) {
      return { contents: [{ uri: "minutes://events/agent-annotations", mimeType: "application/json", text: "[]" }] };
    }
    const { stdout } = await runMinutes(["events", "--event-type", "agent.annotation", "--limit", "50"]);
    return { contents: [{ uri: "minutes://events/agent-annotations", mimeType: "application/json", text: stdout }] };
  }
);

if (LIVE_EVENTS_SUPPORTED) {
  server.resource(
    "live_events",
    LIVE_EVENTS_RESOURCE_URI,
    {
      description:
        "Subscribable live event stream. Subscribe to receive notifications/resources/updated, then read this resource or minutes://events/live?since_seq=N to resume from a durable event cursor.",
    },
    async (uri) => readLiveEventsResource(uri)
  );

  server.resource(
    "live_events_since_seq",
    new ResourceTemplate(LIVE_EVENTS_URI_TEMPLATE, { list: undefined }),
    {
      description:
        "Read live event stream entries after a stable event sequence cursor. Example: minutes://events/live?since_seq=4826&limit=100",
    },
    async (uri) => readLiveEventsResource(uri)
  );

  registerLiveEventsSubscriptionHandlers(server);
} else {
  crashTrace("live-events-resource-disabled", { reason: "missing events_since_seq CLI capability" });
}

server.resource(
  "meeting",
  new ResourceTemplate("minutes://meetings/{slug}", { list: undefined }),
  { description: "Get a specific meeting by its filename slug" },
  async (uri, variables) => {
    const slug = String(variables.slug);
    if (!(await isCliAvailable())) {
      // Without CLI resolve, find by filename match
      const meetings = await reader.listMeetings(MEETINGS_DIR, 1000);
      const match = meetings.find((m) => m.path.includes(slug));
      if (match) {
        const content = await readFile(match.path, "utf-8");
        return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
      }
      return { contents: [{ uri: uri.href, mimeType: "text/plain", text: `Meeting not found: ${slug}` }] };
    }
    const { stdout } = await runMinutes(["resolve", slug]);
    const parsed = parseJsonOutput(stdout);
    if (parsed.path) {
      const validated = validatePathInDirectory(parsed.path, await getEffectiveMeetingsDir(), [".md"]);
      const content = await readFile(validated, "utf-8");
      return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: content }] };
    }
    return { contents: [{ uri: uri.href, mimeType: "text/plain", text: `Meeting not found: ${slug}` }] };
  }
);

// ── Resource: recent_ideas (voice memos from last N days) ──

server.resource(
  "recent-ideas",
  "minutes://ideas/recent",
  { description: "Recent voice memos and ideas captured from any device (last 14 days)" },
  async (uri) => {
    const meetings = await reader.listMeetings(await getEffectiveMeetingsDir(), 200);
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - 14);

    const memos = meetings.filter((m) => {
      if (m.frontmatter.type !== "memo") return false;
      const date = new Date(m.frontmatter.date);
      return date >= cutoff;
    });

    if (memos.length === 0) {
      return {
        contents: [{
          uri: uri.href,
          mimeType: "text/plain",
          text: "No voice memos in the last 14 days.",
        }],
      };
    }

    const lines = memos
      .sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime())
      .slice(0, 20)
      .map((m) => {
        const date = new Date(m.frontmatter.date).toLocaleDateString("en-US", {
          month: "short",
          day: "numeric",
        });
        const device = m.frontmatter.device ? ` (${m.frontmatter.device})` : "";
        return `- [${date}] ${m.frontmatter.title}${device} — ${m.frontmatter.duration}`;
      })
      .join("\n");

    return {
      contents: [{
        uri: uri.href,
        mimeType: "text/plain",
        text: `Recent voice memos (${memos.length} in last 14 days):\n\n${lines}`,
      }],
    };
  }
);

// ── Tool: start_dictation ──────────────────────────────────

registerTool(
  "start_dictation",
  "Start dictation mode. Speak naturally — text accumulates across pauses and the combined result is written when dictation ends. Runs until stop_dictation is called or silence timeout.",
  {
    language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
  },
  { title: "Start Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({ language }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    const { stdout: statusOut } = await runMinutes(["status"]);
    const status = parseJsonOutput(statusOut);
    if (status.recording) {
      return {
        content: [
          {
            type: "text" as const,
            text: "Recording in progress — stop recording before dictating.",
          },
        ],
      };
    }

    // Extension runtime: mic won't work for spawned child processes.
    // Desktop delegation for dictation requires a future Tauri extension.
    if (isExtensionRuntime) {
      return {
        content: [
          {
            type: "text" as const,
            text: "Dictation is not yet supported via the Claude Desktop extension. " +
              "The extension runtime cannot pass microphone access to child processes.\n\n" +
              "Workaround: run `minutes dictate` from your terminal, or use start_recording instead " +
              "(recording delegates to the Minutes desktop app when it's running).",
          },
        ],
        isError: true,
      };
    }

    // Spawn dictation as child (not detached — preserves macOS TCC mic grant)
    const dictArgs = ["dictate"];
    if (language) dictArgs.push("--language", language);
    const child = spawn(MINUTES_BIN, dictArgs, {
      stdio: "ignore",
      env: { ...process.env, RUST_LOG: "info" },
    });
    child.unref();

    // Wait briefly for startup
    await new Promise((r) => setTimeout(r, 500));

    return {
      content: [
        {
          type: "text" as const,
          text: "Dictation started. Speak naturally — text accumulates across pauses and will be copied when dictation ends. Say \"stop dictation\" when done.",
        },
      ],
    };
  }
);

// ── Tool: stop_dictation ───────────────────────────────────

registerTool(
  "stop_dictation",
  "Stop the current dictation session.",
  {},
  { title: "Stop Dictation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async () => {
    // Send stop signal by killing the dictation process via PID file
    const minutesDir = join(homedir(), ".minutes");
    const pidPath = join(minutesDir, "dictation.pid");
    if (existsSync(pidPath)) {
      try {
        const pidContent = await readFile(pidPath, "utf-8");
        const pid = parseInt(pidContent.trim(), 10);
        if (Number.isFinite(pid) && pid > 0) {
          process.kill(pid, "SIGTERM");
        }
      } catch {
        // Process already dead or PID file invalid
      }
    }

    return {
      content: [
        {
          type: "text" as const,
          text: "Dictation stop requested.",
        },
      ],
    };
  }
);

// ── Tool: list_voices ────────────────────────────────────────

registerTool(
  "list_voices",
  "List enrolled voice profiles for speaker identification. Shows who has been enrolled, sample count, and model version.",
  {},
  { title: "Voice Profiles", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async () => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: "Minutes CLI not available." }] };
    }

    const { stdout, stderr } = await runMinutes(["voices", "--json"]);
    const profiles = parseJsonOutput(stdout);

    if (!Array.isArray(profiles) || profiles.length === 0) {
      return {
        content: [{ type: "text" as const, text: "No voice profiles enrolled. The user can enroll with: minutes enroll" }],
      };
    }

    const lines = profiles.map((p: any) =>
      `${p.name} — ${p.sample_count} samples, ${p.source} (${p.model_version})`
    );

    return {
      content: [{ type: "text" as const, text: `Voice profiles (${profiles.length}):\n\n${lines.join("\n")}` }],
      structuredContent: { profiles, view: "voices" },
    };
  }
);

// ── Tool: confirm_speaker ────────────────────────────────────

registerTool(
  "confirm_speaker",
  "Confirm or correct a speaker attribution in a meeting. Stores the correction in Minutes' sidecar overlay store so the original markdown capture stays immutable. Optionally saves the speaker's voice profile for future meetings.",
  {
    meeting: z.string().describe("Path to the meeting markdown file"),
    speaker_label: z.string().describe("Speaker label to confirm (e.g., SPEAKER_1)"),
    name: z.string().describe("Real name to assign to this speaker"),
    save_voice: z.boolean().optional().default(false).describe("Save this speaker's voice profile for future automatic identification"),
  },
  { title: "Confirm Speaker", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ meeting, speaker_label, name, save_voice }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: "Minutes CLI not available." }] };
    }

    const args = ["confirm", "--meeting", meeting, "--speaker", speaker_label, "--name", name];
    if (save_voice) args.push("--save-voice");

    try {
      const { stdout, stderr } = await runMinutes(args);
      const output = (stderr || stdout || "").trim();

      return {
        content: [{ type: "text" as const, text: output || `Confirmed: ${speaker_label} = ${name}` }],
        structuredContent: { meeting, speaker_label, name, save_voice, confirmed: true },
      };
    } catch (error: any) {
      const msg = error?.stderr || error?.message || String(error);
      return {
        content: [{ type: "text" as const, text: `Failed to confirm speaker: ${msg}` }],
        isError: true,
      };
    }
  }
);

// ── Tool: add_agent_annotation ─────────────────────────────

registerTool(
  "add_agent_annotation",
  "Append attributed agent commentary as an agent.annotation event. This never edits meeting markdown/frontmatter and is rejected unless the agent_id is allowed in ~/.minutes/agents.allow.",
  {
    agent_id: z.string().describe("Stable agent identifier listed in ~/.minutes/agents.allow"),
    tools: z.array(z.string()).optional().default([]).describe("Tool or model names used to produce the annotation"),
    subkind: z.string().optional().default("commentary").describe("Annotation subtype, e.g. coaching, correction, risk, summary"),
    meeting_id: z.string().optional().describe("Target meeting identifier, if known"),
    meeting_path: z.string().optional().describe("Target meeting markdown path, if known"),
    span_start_ms: z.number().optional().describe("Start offset of the target span in milliseconds"),
    span_end_ms: z.number().optional().describe("End offset of the target span in milliseconds"),
    body: z.string().describe("Annotation body"),
    citations: z.array(z.string()).optional().default([]).describe("Source citations or event references"),
    confidence: z.enum(["low", "medium", "high", "tentative", "inferred", "strong", "explicit"]).optional().default("medium"),
    provenance: z.any().optional().describe("JSON-serializable provenance object"),
  },
  { title: "Add Agent Annotation", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({
    agent_id,
    tools,
    subkind,
    meeting_id,
    meeting_path,
    span_start_ms,
    span_end_ms,
    body,
    citations,
    confidence,
    provenance,
  }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }], isError: true };
    }

    const args = [
      "agent-annotate",
      "--agent-id",
      agent_id,
      "--subkind",
      subkind,
      "--body",
      body,
      "--confidence",
      confidence,
      "--provenance",
      JSON.stringify(provenance ?? { via: "minutes-mcp", tool: "add_agent_annotation" }),
    ];
    for (const tool of tools ?? []) args.push("--tool", tool);
    for (const citation of citations ?? []) args.push("--citation", citation);
    if (meeting_id) args.push("--meeting-id", meeting_id);
    if (meeting_path) args.push("--meeting-path", meeting_path);
    if (span_start_ms !== undefined || span_end_ms !== undefined) {
      if (span_start_ms !== undefined) args.push("--span-start-ms", String(span_start_ms));
      if (span_end_ms !== undefined) args.push("--span-end-ms", String(span_end_ms));
    }

    try {
      const { stdout } = await runMinutes(args, 10000);
      const event = parseJsonOutput(stdout);
      return {
        content: [{ type: "text" as const, text: `Appended agent.annotation seq ${event?.seq ?? "unknown"}.` }],
        structuredContent: { event },
      };
    } catch (error: any) {
      const message = error?.message || String(error);
      const structured = parseStructuredCliError(message);
      return {
        content: [{ type: "text" as const, text: structured?.message || `Failed to append agent.annotation: ${message}` }],
        structuredContent: structured ? { error: structured } : undefined,
        isError: true,
      };
    }
  }
);

// ── Tool: get_agent_annotations ────────────────────────────

registerTool(
  "get_agent_annotations",
  "Read append-only agent.annotation events separately from human-authored meeting markdown/frontmatter.",
  {
    limit: z.number().optional().default(50).describe("Maximum number of annotations"),
    agent_id: z.string().optional().describe("Filter by agent id"),
    meeting_id: z.string().optional().describe("Filter by target meeting id"),
    meeting_path: z.string().optional().describe("Filter by target meeting path"),
  },
  { title: "Get Agent Annotations", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ limit, agent_id, meeting_id, meeting_path }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }], isError: true };
    }

    const annotations = (await readAgentAnnotationsFromCli(limit)).filter((event: any) => {
      if (agent_id && event?.agent?.id !== agent_id) return false;
      if (meeting_id && event?.target?.meeting_id !== meeting_id) return false;
      if (meeting_path && event?.target?.meeting_path !== meeting_path) return false;
      return true;
    });

    return {
      content: [{ type: "text" as const, text: JSON.stringify(annotations, null, 2) }],
      structuredContent: { annotations },
    };
  }
);

// ── Tool: get_meeting_insights ─────────────────────────────

registerTool(
  "get_meeting_insights",
  "Query structured insights extracted from meetings — decisions, commitments, and questions with confidence levels. Use this to find what was decided, who committed to what, and what's still open across all meetings. External systems can subscribe to these events for workflow automation.",
  {
    kind: z.enum(MEETING_INSIGHT_KINDS).optional().describe("Filter by insight type"),
    confidence: z.enum(["tentative", "inferred", "strong", "explicit"]).optional().describe("Minimum confidence level"),
    participant: z.string().optional().describe("Filter by participant name (partial match)"),
    since: z.string().optional().describe("Only insights since this date (YYYY-MM-DD)"),
    limit: z.number().optional().default(50).describe("Maximum number of results"),
    actionable_only: z.boolean().optional().default(false).describe("Only return actionable insights (Strong or Explicit confidence)"),
  },
  { title: "Get Meeting Insights", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ kind, confidence, participant, since, limit, actionable_only }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    const args = ["insights", "--limit", String(limit ?? 50)];
    if (kind) { args.push("--kind", kind); }
    if (actionable_only) {
      args.push("--actionable");
    } else if (confidence) {
      args.push("--confidence", confidence);
    }
    if (participant) { args.push("--participant", participant); }
    if (since) { args.push("--since", since); }

    try {
      const { stdout } = await runMinutes(args);
      const insights = parseJsonOutput(stdout);
      const count = Array.isArray(insights) ? insights.length : 0;

      if (count === 0) {
        return {
          content: [{ type: "text" as const, text: "No meeting insights found matching the filter criteria. Insights are extracted when meetings are processed with summarization enabled." }],
        };
      }

      return {
        content: [{ type: "text" as const, text: `Found ${count} insight(s):\n\n${JSON.stringify(insights, null, 2)}` }],
        structuredContent: { count, insights },
      };
    } catch (error: any) {
      const msg = error?.stderr || error?.message || String(error);
      return {
        content: [{ type: "text" as const, text: `Failed to query insights: ${msg}` }],
        isError: true,
      };
    }
  }
);

// ── Tool: start_live_transcript ──────────────────────────────

registerTool(
  "start_live_transcript",
  "Start real-time transcription. If a recording is already running, it already includes a live transcript — use read_live_transcript to read it. Runs until stop is called.",
  {
    language: z.string().optional().describe("Transcription language code (e.g. 'en', 'ur', 'es', 'zh'). Overrides config.toml setting."),
  },
  { title: "Start Live Transcript", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
  async ({ language }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }
    // Pre-flight checks with short timeouts (these are instant file reads)
    const { stdout: statusOut } = await runMinutes(["status"], 5000);
    const status = parseJsonOutput(statusOut);
    if (status.recording) {
      return {
        content: [{ type: "text" as const, text: "Recording already in progress — it includes a live transcript. Use read_live_transcript to follow along." }],
      };
    }

    // Check if a live transcript is already running
    try {
      const { stdout: ltStatus } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
      const ltParsed = parseJsonOutput(ltStatus);
      if (ltParsed?.active) {
        return {
          content: [{ type: "text" as const, text: "Live transcript already running. Use read_live_transcript to read it, or minutes stop to end it." }],
        };
      }
    } catch { /* no active session, proceed */ }

    // Extension runtime: mic won't work for spawned child processes.
    if (isExtensionRuntime) {
      return {
        content: [
          {
            type: "text" as const,
            text: "Live transcript is not yet supported via the Claude Desktop extension. " +
              "The extension runtime cannot pass microphone access to child processes.\n\n" +
              "Workaround: run `minutes live` from your terminal, or use start_recording instead " +
              "(recording delegates to the Minutes desktop app when it's running).",
          },
        ],
        isError: true,
      };
    }

    // Spawn live transcript as child (not detached — preserves macOS TCC mic grant)
    const liveArgs = ["live"];
    if (language) liveArgs.push("--language", language);
    const child = spawn(MINUTES_BIN, liveArgs, {
      stdio: "ignore",
      env: { ...process.env, RUST_LOG: "info" },
    });
    child.unref();

    // Verify the session actually started
    await new Promise((r) => setTimeout(r, 1000));
    try {
      const { stdout: verifyOut } = await runMinutes(["transcript", "--status", "--format", "json"], 5000);
      const verifyStatus = parseJsonOutput(verifyOut);
      if (verifyStatus?.active) {
        return {
          content: [{ type: "text" as const, text: "Live transcript started. Use read_live_transcript to read the transcript. Use minutes stop to end the session." }],
        };
      }
    } catch { /* fall through to error */ }

    return {
      content: [{ type: "text" as const, text: "Live transcript may have failed to start. Check minutes health or try again. Common causes: no microphone, whisper model not downloaded, or another session already active." }],
      isError: true,
    };
  }
);

// ── Tool: read_live_transcript ──────────────────────────────

registerTool(
  "read_live_transcript",
  "Read the live transcript — works during both recordings and live transcript sessions. Use 'since' to get new lines after a cursor (line number) or time window (e.g., '5m', '30s'). Use 'status' mode to check if a session is active.",
  {
    since: z.string().optional().describe("Line number (e.g., '42') or duration (e.g., '5m', '30s'). Omit to get all lines."),
    status_only: z.boolean().optional().default(false).describe("If true, return session status instead of transcript lines"),
  },
  { title: "Read Live Transcript", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ since, status_only }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    const args = ["transcript", "--format", "json"];
    if (status_only) {
      args.push("--status");
    } else if (since) {
      args.push("--since", since);
    }

    try {
      const { stdout } = await runMinutes(args, 10000);
      // For status queries, a message is helpful. For transcript reads, empty = no new lines.
      const fallback = status_only ? "No transcript data available." : "";
      return {
        content: [{ type: "text" as const, text: stdout || fallback }],
      };
    } catch (error: any) {
      const msg = error?.stderr || error?.message || String(error);
      return {
        content: [{ type: "text" as const, text: `Failed to read transcript: ${msg}` }],
        isError: true,
      };
    }
  }
);

// ── Tool: ingest_meeting ────────────────────────────────────

registerTool(
  "ingest_meeting",
  "Extract facts from a meeting and update the knowledge base (person profiles, log, index). Requires [knowledge] to be configured in config.toml. Uses structured frontmatter data only by default (zero hallucination risk). Set engine to 'agent' for richer LLM-based extraction.",
  {
    path: z.string().optional().describe("Path to a specific meeting .md file. Omit to process all meetings."),
    all: z.boolean().optional().default(false).describe("Process all meetings in the output directory"),
    dry_run: z.boolean().optional().default(false).describe("Show what would be extracted without writing anything"),
  },
  { title: "Ingest Meeting", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async ({ path, all, dry_run }) => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    const args = ["ingest"];
    if (path) {
      // Validate path is within the meetings directory to prevent path traversal
      const resolved = validatePathInDirectory(path, await getEffectiveMeetingsDir(), [".md"]);
      args.push(resolved);
    }
    if (all) args.push("--all");
    if (dry_run) args.push("--dry-run");

    if (!path && !all) {
      return {
        content: [{ type: "text" as const, text: "Provide a meeting path or use all=true to process all meetings." }],
        isError: true,
      };
    }

    try {
      const { stdout, stderr } = await runMinutes(args);
      const output = stderr || stdout;
      return { content: [{ type: "text" as const, text: output }] };
    } catch (error: any) {
      const msg = error?.stderr || error?.message || String(error);
      return {
        content: [{ type: "text" as const, text: `Knowledge ingestion failed: ${msg}` }],
        isError: true,
      };
    }
  }
);

// ── Tool: knowledge_status ──────────────────────────────────

registerTool(
  "knowledge_status",
  "Show the current state of the knowledge base — whether it's configured, which adapter is in use, and how many person profiles and log entries exist.",
  {},
  { title: "Knowledge Status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async () => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    try {
      const { stdout } = await runMinutes(["paths", "--json"]);
      const paths = parseJsonOutput(stdout);
      const configPath = paths?.config || "unknown";

      // Read config to check knowledge settings
      const { readFile: readFileAsync } = await import("fs/promises");
      let configContent = "";
      try {
        configContent = await readFileAsync(configPath, "utf-8");
      } catch {
        // Try default location
        try {
          configContent = await readFileAsync(join(homedir(), ".config", "minutes", "config.toml"), "utf-8");
        } catch {
          return { content: [{ type: "text" as const, text: "Knowledge base: not configured.\n\nAdd [knowledge] section to ~/.config/minutes/config.toml with enabled = true and a path." }] };
        }
      }

      const knowledge = parseKnowledgeConfig(configContent);
      if (!knowledge || !knowledge.enabled) {
        return { content: [{ type: "text" as const, text: "Knowledge base: not configured or disabled.\n\nAdd [knowledge] section to ~/.config/minutes/config.toml with enabled = true and a path." }] };
      }

      const rawKbPath = knowledge.path || "unknown";
      const kbPath = rawKbPath.startsWith("~") ? join(homedir(), rawKbPath.slice(1)) : rawKbPath;
      const { adapter, engine } = knowledge;

      // Count people and log entries
      const { readdir, stat: statAsync } = await import("fs/promises");
      let peopleCount = 0;
      let logEntries = 0;

      try {
        const peopleDir = adapter === "para" ? join(kbPath, "areas", "people") : join(kbPath, "people");
        const entries = await readdir(peopleDir, { withFileTypes: true });
        peopleCount = entries.filter(e => e.isDirectory() || e.name.endsWith(".md")).length;
      } catch { /* dir may not exist yet */ }

      try {
        const logPath = adapter === "para" ? join(kbPath, "memory", "log.md") : join(kbPath, "log.md");
        const logContent = await readFileAsync(logPath, "utf-8");
        logEntries = (logContent.match(/^## \[/gm) || []).length;
      } catch { /* log may not exist yet */ }

      const lines = [
        `Knowledge base: **enabled**`,
        `Path: ${kbPath}`,
        `Adapter: ${adapter}`,
        `Extraction engine: ${engine}`,
        `People profiles: ${peopleCount}`,
        `Log entries: ${logEntries}`,
      ];

      return { content: [{ type: "text" as const, text: lines.join("\n") }] };
    } catch (error: any) {
      const msg = error?.stderr || error?.message || String(error);
      return {
        content: [{ type: "text" as const, text: `Failed to check knowledge status: ${msg}` }],
        isError: true,
      };
    }
  }
);

// ── Dashboard ───────────────────────────────────────────────

registerTool(
  "open_dashboard",
  "Open the Meeting Intelligence Dashboard in the browser. Shows a visual overview of your conversation memory: metrics, meeting timeline, decisions, recurring topics, action items, and voice memos. Runs a local HTTP server — data never leaves your machine.",
  {},
  { title: "Open Dashboard", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  async () => {
    if (!(await isCliAvailable())) {
      return { content: [{ type: "text" as const, text: CLI_INSTALL_MSG }] };
    }

    // Check if dashboard is already running via PID file
    const pidPath = join(homedir(), ".minutes", "dashboard.pid");
    try {
      const pidStr = await readFile(pidPath, "utf-8");
      const pid = parseInt(pidStr.trim(), 10);
      if (pid > 0) {
        // Check if process is alive
        try {
          process.kill(pid, 0);
          return {
            content: [{
              type: "text" as const,
              text: `Dashboard already running (PID ${pid}). Open http://localhost:3141 in your browser.`,
            }],
          };
        } catch {
          // Process not alive, stale PID — proceed to launch
        }
      }
    } catch {
      // No PID file — proceed to launch
    }

    // Spawn dashboard as detached subprocess
    const { spawn } = await import("child_process");
    const child = spawn(MINUTES_BIN, ["dashboard"], {
      detached: true,
      stdio: "ignore",
    });
    child.unref();

    // Give it a moment to start
    await new Promise((resolve) => setTimeout(resolve, 1000));

    // Count meetings for the response
    try {
      const { stdout } = await runMinutes(["list", "--format", "json", "--limit", "999"]);
      const lines = stdout.trim().split("\n").filter(Boolean);
      return {
        content: [{
          type: "text" as const,
          text: `Dashboard opened at http://localhost:3141 (${lines.length} meetings loaded).`,
        }],
      };
    } catch {
      return {
        content: [{
          type: "text" as const,
          text: "Dashboard opened at http://localhost:3141.",
        }],
      };
    }
  }
);

// ── Start server ────────────────────────────────────────────

async function main() {
  crashTrace("main-start");
  const transport = new StdioServerTransport();
  crashTrace("transport-created");
  await server.connect(transport);
  crashTrace("transport-connected");
  console.error("Minutes MCP server running on stdio");
}

crashTrace("pre-main-guard", {
  argv1: process.argv[1] ?? null,
  resolvedArgv1: process.argv[1] ? resolve(process.argv[1]) : null,
  __filename,
  match: shouldRunMainEntry(process.argv[1], __filename),
});

if (shouldRunMainEntry(process.argv[1], __filename)) {
  main().catch((error) => {
    crashTrace("main-rejected", error);
    console.error("Fatal error:", error);
    process.exit(1);
  });
} else {
  crashTrace("main-skipped-argv-mismatch");
}
