// Dewx Local — unified MCP server over the shared local DB.
// Exposes read tools (inbox.* / crm.*) so the Dew terminal + external agents
// (Claude Code / Codex / Kimi) can query the whole app. Read-only, loopback only.
//
// Run:  node --experimental-strip-types dewx-mcp.ts
// Env:  DEWX_DB_PATH (default ~/Library/Application Support/DewLink/dewxlocal.db)
//       DEWX_MCP_PORT (default 8765)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import os from "node:os";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { DatabaseSync } from "node:sqlite";
import http from "node:http";
import path from "node:path";
import { z } from "zod";
import * as ops from "./core/ops.ts";       // control-plane CORE (the single source of logic for ops_*)
import * as dev from "./core/dev.ts";       // DEV workspace CORE (SRS/tasks/bugs/memory/browser/team)
import * as work from "./core/work.ts";     // Universal Work Console CORE (GTM/CX/Ops/DEV/Dew work_*)
import * as inbox from "./core/inbox.ts";   // inbox-management CORE (archive/mute/mark-read/unread/compose)
import * as identity from "./core/identity.ts"; // cross-channel identity CORE (suggest/merge/unmerge)
import * as calendar from "./core/calendar.ts"; // calendar CORE (reads the EventKit mirror)
import * as calls from "./core/calls.ts";         // call/meeting intelligence CORE (stored local transcripts/summaries)
import * as cloudsync from "./core/cloudsync.ts"; // cloud-sync preview CORE (Phase 2 ingest mapping)
import * as mail from "./core/mail.ts";         // Mail bridge CORE (read/draft via Mail.app, no OAuth)
import * as reminders from "./core/reminders.ts"; // Reminders bridge CORE (read/create via Reminders.app)
import * as macContacts from "./core/contacts_mac.ts"; // Contacts bridge CORE (address-book search)
import * as notes from "./core/notes.ts";       // Notes bridge CORE (list/read/create Apple Notes)
import * as imessage from "./core/imessage.ts"; // iMessage/SMS bridge CORE (read chat.db, needs FDA)
import * as crmMutations from "./core/crmMutations.ts"; // local CRM edit queue for future up-sync
import * as inchatApprovals from "./core/inchatApprovals.ts"; // in-chat approval control-chat setting + parser
import { createAiRequest, ensureAiRequestsSchema, waitForAiRequest } from "./core/aiRequests.ts";
import { cockpit as cockpitCore } from "./core/cockpit.ts";   // operator situational-awareness snapshot
import { guardRequest } from "./core/guard.ts";               // pure, unit-tested localhost security guard
import * as websync from "./core/sync/webClient.ts";          // web→local sync: companion API client
import * as syncpull from "./core/sync/pull.ts";              // web→local sync: inbox upsert into unified tables
import * as synccrm from "./core/sync/crm.ts";                // web→local sync: CRM upsert into contact/company/deal
import * as syncloop from "./core/sync/loop.ts";              // web→local sync loop + health/cursors
import * as syncworkspace from "./core/sync/workspace.ts";    // selected org/workspace local partition
import { replyRouteForConversation } from "./core/sync/replyRouting.ts";
import { buildMemoryAwareInstructions, recordAiToolExchange, type AiToolName } from "./core/aiMemory.ts";
import { WorkMemoryStore } from "./memory/store.ts";
import { memoryTool } from "./memory/tool.ts";
import { telegramStatusFromDb } from "./channels/telegram/adapter.ts";

const HOME = process.env.HOME || os.homedir();
const DB_PATH = process.env.DEWX_DB_PATH ||
  path.join(HOME, "Library", "Application Support", "DewLink", "dewxlocal.db");
const PORT = process.env.DEWX_MCP_PORT ? parseInt(process.env.DEWX_MCP_PORT, 10) : 8765;
// Loopback shared-secret (OPT-IN): if set, every request must carry it (Authorization: Bearer <t>
// or X-Dewx-Token). Unset = backward-compatible (no token required). Swift app injects this at spawn.
// Guard logic lives in core/guard.ts (pure + unit-tested); this surface just supplies the token.
const TOKEN = process.env.DEWX_MCP_TOKEN || "";

function db(): DatabaseSync { return new DatabaseSync(DB_PATH, { readOnly: true } as any); }
function query(sql: string, params: any[] = []): any[] {
  try { const d = db(); const rows = d.prepare(sql).all(...params); d.close(); return rows as any[]; }
  catch (e: any) { return [{ error: e?.message ?? String(e) }]; }
}
// Alias usable inside tools whose input param is named `query` (which would shadow the fn above).
const sqlAll = query;
function currentWorkspaceId(): string {
  const d = new DatabaseSync(DB_PATH);
  try {
    d.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
    return syncworkspace.selectedWorkspaceId(d);
  } catch {
    return syncworkspace.LOCAL_WORKSPACE_ID;
  } finally {
    d.close();
  }
}

// Writable connection (WAL) for the control-plane ops_* tools; ensures the ops schema, runs fn, closes.
// All ops logic lives in core/ops.ts — these wrappers add NO business logic (parity guard enforces this).
function withDb<T>(fn: (d: DatabaseSync) => T): T {
  const d = new DatabaseSync(DB_PATH);
  // foreign_keys is OFF by default per-connection in SQLite — must be set every open or the FKs that
  // schema-ops-v1 declares are silently unenforced (orphan-row risk). Enforce on every writable connection.
  try { d.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;"); ops.ensureSchema(d); ensureAiRequestsSchema(d); return fn(d); }
  finally { d.close(); }
}
async function withDbAsync<T>(fn: (d: DatabaseSync) => Promise<T>): Promise<T> {
  const d = new DatabaseSync(DB_PATH);
  try { d.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;"); ops.ensureSchema(d); ensureAiRequestsSchema(d); return await fn(d); }
  finally { d.close(); }
}
const ok = (data: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] });
const AI_REQUEST_TIMEOUT_MS = 60_000;

const AI_SYSTEM_INSTRUCTIONS: Record<"ai_summarize_thread" | "ai_draft_reply" | "ai_rewrite_text", string> = {
  ai_summarize_thread: [
    "You are DewAI summarizing a local inbox thread.",
    "Use only the supplied local conversation messages and memory snapshot.",
    "Return a concise summary with citations to message labels when available.",
  ].join("\n"),
  ai_draft_reply: [
    "You are DewAI drafting a local inbox reply.",
    "Use only the supplied local conversation messages and memory snapshot.",
    "Return composer-ready draft text only; never send, queue, or mutate external systems.",
  ].join("\n"),
  ai_rewrite_text: [
    "You are DewAI rewriting local composer text.",
    "Use only the supplied text, requested action, style, and memory snapshot.",
    "Return composer-ready draft text only; never send, queue, or mutate external systems.",
  ].join("\n"),
};

function aiOk(d: DatabaseSync, tool: AiToolName, input: unknown, response: Record<string, unknown>) {
  recordAiToolExchange(d, { tool, input, response });
  return ok(response);
}

function jsonPrompt(kind: string, payload: Record<string, unknown>): string {
  return JSON.stringify({ kind, local_only: true, source: "dewx-mcp", ...payload }, null, 2);
}

function parseBridgeJson(raw: string | null): Record<string, any> | null {
  if (!raw) return null;
  try {
    const parsed = JSON.parse(raw);
    return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}

function conversationPacket(d: DatabaseSync, conversationId?: string | null) {
  const id = conversationId?.trim();
  if (!id) return { conversation: null, messages: [] };
  const conversation = d.prepare(`
    SELECT id, channel, account_id, external_id, COALESCE(title, external_id) AS title, contact_id, last_ts, unread
    FROM unified_conversation
    WHERE id=? AND deleted_at IS NULL
    LIMIT 1
  `).get(id) as Record<string, unknown> | undefined;
  const rows = d.prepare(`
    SELECT id, direction, COALESCE(body,'') AS body, sender, ts, media_type, media_path
    FROM unified_message
    WHERE conversation_id=? AND deleted_at IS NULL
    ORDER BY ts ASC LIMIT 500
  `).all(id) as Record<string, unknown>[];
  return {
    conversation: conversation ?? { id, missing: true },
    messages: rows.map((row, idx) => ({ label: `M${idx + 1}`, ...row })),
  };
}

function aiBridgeStatus(d: DatabaseSync) {
  ensureAiRequestsSchema(d);
  const pending = d.prepare("SELECT COUNT(*) AS c FROM ai_requests WHERE status='pending';").get() as { c: number };
  const lastDone = d.prepare(`
    SELECT id, completed_at FROM ai_requests
    WHERE status='done' AND completed_at IS NOT NULL
    ORDER BY completed_at DESC LIMIT 1
  `).get() as { id: string; completed_at: number } | undefined;
  const lastError = d.prepare(`
    SELECT id, error, completed_at FROM ai_requests
    WHERE status='error' AND completed_at IS NOT NULL
    ORDER BY completed_at DESC LIMIT 1
  `).get() as { id: string; error: string | null; completed_at: number } | undefined;
  const hasSyncHealth = d.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='sync_health' LIMIT 1").get();
  const statusRows = hasSyncHealth ? d.prepare(`
    SELECT source, status, last_started_at, last_finished_at, counts_json, last_error, updated_at
    FROM sync_health
    WHERE source IN ('ai', 'dew_ai', 'dewai', 'ai_bridge', 'ai_requests')
    ORDER BY updated_at DESC
  `).all() : [];
  return {
    table: "ai_requests",
    pending: pending.c,
    timeout_ms: AI_REQUEST_TIMEOUT_MS,
    app_watcher: "DewLink AIRequestWatcher",
    status: statusRows.length ? "reported_by_swift" : "bridge",
    swift_status_rows: statusRows,
    last_success_at: lastDone?.completed_at ?? null,
    last_success_request_id: lastDone?.id ?? null,
    last_error_at: lastError?.completed_at ?? null,
    last_error_request_id: lastError?.id ?? null,
    last_error: lastError?.error ?? null,
  };
}

async function runAiBridgeTool(
  d: DatabaseSync,
  tool: AiToolName,
  input: Record<string, unknown>,
  instructions: string,
  prompt: string,
  success: (raw: string, parsed: Record<string, any> | null, row: { id: string; completed_at: number | null }) => Record<string, unknown>,
) {
  const request = createAiRequest(d, { instructions, prompt });
  try {
    const completed = await waitForAiRequest(d, request.id, { timeoutMs: AI_REQUEST_TIMEOUT_MS, pollMs: 1_000 });
    if (completed.status === "done") {
      const raw = completed.response ?? "";
      return aiOk(d, tool, input, {
        ok: true,
        id: completed.id,
        status: "done",
        completed_at: completed.completed_at,
        ...success(raw, parseBridgeJson(raw), completed),
      });
    }
    return aiOk(d, tool, input, {
      ok: false,
      id: completed.id,
      status: "error",
      error: completed.error ?? "AI generation failed.",
      completed_at: completed.completed_at,
    });
  } catch (e: any) {
    return aiOk(d, tool, input, {
      ok: false,
      id: request.id,
      status: "timeout",
      error: e?.message ?? String(e),
      timeout_ms: AI_REQUEST_TIMEOUT_MS,
    });
  }
}

// Pin specific chats first in list_chats (e.g. the AHN tester group + Sascha). Comma-separated
// jid/name substrings, e.g. DEWX_PIN_JIDS="anyhelpnow,Sascha,12345@g.us".
const PIN = (process.env.DEWX_PIN_JIDS || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
const isoTs = (ms: any) => { const n = Number(ms); return n > 0 ? new Date(n).toISOString() : null; };
const chatType = (jid: string) => (jid || "").endsWith("@g.us") ? "group" : "dm";
function mimeFor(p: string | null, type: string | null): string {
  const ext = (p || "").split(".").pop()?.toLowerCase() || "";
  if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
  if (ext === "png") return "image/png";
  if (ext === "webp") return "image/webp";
  if (ext === "m4a") return "audio/mp4";
  if (ext === "ogg") return "audio/ogg";
  if (ext === "mp4") return "video/mp4";
  if (ext === "pdf") return "application/pdf";
  switch (type) {
    case "image": return "image/jpeg";
    case "audio": return "audio/mp4";
    case "video": return "video/mp4";
    default: return "application/octet-stream";
  }
}
/// Map a stored unified_message row → the AHN tool message shape (with media flags).
function toMsg(r: any) {
  const hasMedia = !!r.media_path;
  return {
    message_id: r.id, chat_id: r.conversation_id,
    sender_name: r.direction === "OUT" ? "You" : (r.conv_title || r.sender || "Unknown"),
    sender_id: r.sender || null,
    timestamp: isoTs(r.ts),
    text: r.body || "",
    type: r.media_type || "text",
    has_media: hasMedia,
    media_id: hasMedia ? r.id : undefined,   // get_media takes this
    direction: r.direction,
  };
}

/// Build a fresh server with all tools. Called per request (stateless mode) so any number of
/// agents (Claude Code, Codex, the in-app Dew terminal) can connect independently — no single
/// shared session that rejects the 2nd client with "Server already initialized".
function buildServer(): McpServer {
  const server = new McpServer({ name: "dewx-local", version: "1.0.0", capabilities: { tools: {} } });

  server.tool("channels_list", {},
    async () => ok(query(
      `SELECT channel, COUNT(*) AS conversations, COALESCE(SUM(unread),0) AS unread
       FROM unified_conversation WHERE workspace_id=? AND deleted_at IS NULL
       GROUP BY channel ORDER BY conversations DESC`, [currentWorkspaceId()])));

  server.tool("channels_telegram_status", {},
    async () => ok(withDb((d) => telegramStatusFromDb(d))));

  server.tool("inbox_conversations",
    {
      limit: z.number().optional().describe("max conversations (default 50)"),
      channel: z.string().optional().describe("filter to one channel: WHATSAPP | EMAIL | TELEGRAM | LINKEDIN"),
    },
    async ({ limit, channel }) => ok(query(
      `SELECT id, channel, account_id AS account, COALESCE(title, external_id) AS title,
              last_ts, unread FROM unified_conversation
       WHERE workspace_id=? AND deleted_at IS NULL AND (? = '' OR channel = ?)
       ORDER BY last_ts DESC LIMIT ?`,
      [currentWorkspaceId(), channel ?? "", channel ?? "", limit ?? 50])));

  server.tool("inbox_thread",
    { conversationId: z.string().describe("conversation id from inbox_conversations") },
    async ({ conversationId }) => ok(query(
      `SELECT direction, COALESCE(body,'') AS body, sender, ts FROM unified_message
       WHERE conversation_id=? AND deleted_at IS NULL ORDER BY ts ASC LIMIT 500`,
      [conversationId])));

  // A unified person across channels: their channel identities + a single cross-channel message
  // timeline. Follows manual merges (contact_merge) so a merged person resolves to the survivor.
  server.tool("crm_person",
    { contactId: z.string().describe("a contact id (from crm_contacts / inbox_conversations)") },
    async ({ contactId }) => {
      // Resolve the effective (post-merge) contact.
      let eff = contactId;
      for (let i = 0; i < 10; i++) {
        const r = query("SELECT primary_contact_id AS p FROM contact_merge WHERE merged_contact_id=? AND undone_at IS NULL LIMIT 1", [eff]);
        if (!r.length || r[0].error || !r[0].p) break;
        eff = r[0].p;
      }
      const person = query("SELECT id, COALESCE(display_name,'') AS name FROM contact WHERE id=?", [eff])[0] ?? { id: eff, name: "" };
      const merged = query("SELECT merged_contact_id AS m FROM contact_merge WHERE primary_contact_id=? AND undone_at IS NULL", [eff]).map((r: any) => r.m);
      const ids = [eff, ...merged];
      const ph = ids.map(() => "?").join(",");
      const identities = query(
        `SELECT channel, handle, email, phone FROM contact_identity WHERE contact_id IN (${ph}) AND deleted_at IS NULL`, ids);
      const timeline = query(
        `SELECT cc.channel, m.direction, COALESCE(m.body,'') AS body, m.ts
         FROM unified_message m JOIN unified_conversation cc ON m.conversation_id=cc.id
         WHERE cc.contact_id=? AND m.deleted_at IS NULL ORDER BY m.ts ASC LIMIT 500`, [eff]);
      const channels = Array.from(new Set(identities.map((i: any) => i.channel)));
      return ok({ person, channels, identities, timeline });
    });

  server.tool("crm_contacts", { limit: z.number().optional() },
    async ({ limit }) => ok(query(
      `SELECT id, COALESCE(display_name,'') AS name FROM contact
       WHERE workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?`,
      [currentWorkspaceId(), limit ?? 200])));

  server.tool("crm_companies", {},
    async () => ok(query(`SELECT name, industry, domain FROM company WHERE workspace_id=? AND deleted_at IS NULL ORDER BY name`, [currentWorkspaceId()])));

  server.tool("crm_deals", {},
    async () => ok(query(`SELECT name, company, stage, value, close_date FROM crm_deal WHERE workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC`, [currentWorkspaceId()])));

  server.tool("crm_pending_mutations",
    { limit: z.number().optional().describe("max pending local CRM mutations (default 100)") },
    async (a) => ok(withDb((d) => crmMutations.pendingCrmMutations(d, a))));

  // ── AHN WhatsApp tool surface (read chats/bug-reports incl. media, draft replies) ──

  server.tool("list_chats",
    {
      limit: z.number().optional().describe("max chats (default 30)"),
      query: z.string().optional().describe("filter by chat/contact name (substring)"),
    },
    async ({ limit, query }) => {
      const q = (query ?? "").toLowerCase();
      const chats = (sqlAll(
        `SELECT c.id AS chat_id, c.external_id AS jid,
                COALESCE(c.title, c.external_id) AS name, c.unread AS unread_count, c.last_ts,
                (SELECT m.body FROM unified_message m WHERE m.conversation_id=c.id AND m.deleted_at IS NULL
                   ORDER BY m.ts DESC LIMIT 1) AS last_preview
         FROM unified_conversation c
         WHERE c.workspace_id=? AND c.channel='WHATSAPP' AND c.deleted_at IS NULL AND c.archived_at IS NULL
         ORDER BY c.last_ts DESC LIMIT ?`,
        [currentWorkspaceId(), Math.max(limit ?? 30, q ? 200 : (limit ?? 30))],
      ) as any[])
        .map((r) => ({
          chat_id: r.chat_id, name: r.name, type: chatType(r.jid),
          unread_count: r.unread_count ?? 0, last_message_at: isoTs(r.last_ts),
          last_preview: (r.last_preview || "").slice(0, 140), _jid: r.jid,
        }))
        .filter((r) => !q || r.name.toLowerCase().includes(q) || (r._jid || "").toLowerCase().includes(q));
      // Pinned chats (env) first, then by recency.
      const pinned = (r: any) => PIN.some((p) => r.name.toLowerCase().includes(p) || (r._jid || "").toLowerCase().includes(p));
      chats.sort((a, b) => (pinned(b) ? 1 : 0) - (pinned(a) ? 1 : 0));
      return ok(chats.slice(0, limit ?? 30).map(({ _jid, ...c }) => c));
    });

  server.tool("get_messages",
    {
      chat_id: z.string().describe("chat_id from list_chats"),
      limit: z.number().optional().describe("max messages (default 50), newest last"),
      before_id: z.string().optional().describe("paginate: only messages older than this message_id"),
    },
    async ({ chat_id, limit, before_id }) => {
      let beforeTs = Number.MAX_SAFE_INTEGER;
      if (before_id) {
        const b = sqlAll(`SELECT ts FROM unified_message WHERE id=?`, [before_id]) as any[];
        if (b.length && b[0].ts != null) beforeTs = Number(b[0].ts);
      }
      // Pull newest-first (so LIMIT keeps the most recent), then reverse to newest-last.
      const rows = sqlAll(
        `SELECT m.id, m.conversation_id, m.direction, COALESCE(m.body,'') AS body, m.sender, m.ts,
                m.media_type, m.media_path, COALESCE(c.title, c.external_id) AS conv_title
         FROM unified_message m JOIN unified_conversation c ON m.conversation_id=c.id
         WHERE m.conversation_id=? AND m.deleted_at IS NULL AND m.ts < ?
         ORDER BY m.ts DESC LIMIT ?`,
        [chat_id, beforeTs, limit ?? 50],
      ) as any[];
      return ok(rows.reverse().map(toMsg));
    });

  server.tool("get_media",
    { media_id: z.string().describe("media_id (= message_id) of a message with has_media=true") },
    async ({ media_id }) => {
      const r = (sqlAll(`SELECT media_type, media_path FROM unified_message WHERE id=?`, [media_id]) as any[])[0];
      if (!r) return ok({ error: "message not found" });
      if (!r.media_path) return ok({ error: "media not downloaded for this message (text-only, or media expired/never fetched)" });
      const fs = await import("node:fs");
      if (!fs.existsSync(r.media_path)) return ok({ error: `media file missing on disk: ${r.media_path}` });
      return ok({
        local_path: r.media_path,
        mime_type: mimeFor(r.media_path, r.media_type),
        kind: r.media_type || "document",
        filename: r.media_path.split("/").pop(),
      });
    });

  server.tool("search_messages",
    {
      query: z.string().optional().describe("text to find in message bodies"),
      chat_id: z.string().optional().describe("restrict to one chat"),
      sender: z.string().optional().describe("restrict to a sender (jid/name substring)"),
      since: z.string().optional().describe("ISO date lower bound"),
      until: z.string().optional().describe("ISO date upper bound"),
      limit: z.number().optional(),
    },
    async ({ query: q, chat_id, sender, since, until, limit }) => {
      const where: string[] = ["m.deleted_at IS NULL", "c.channel='WHATSAPP'", "c.workspace_id=?"];
      const params: any[] = [currentWorkspaceId()];
      if (q) { where.push("m.body LIKE ?"); params.push(`%${q}%`); }
      if (chat_id) { where.push("m.conversation_id=?"); params.push(chat_id); }
      if (sender) { where.push("m.sender LIKE ?"); params.push(`%${sender}%`); }
      if (since) { const t = Date.parse(since); if (!isNaN(t)) { where.push("m.ts >= ?"); params.push(t); } }
      if (until) { const t = Date.parse(until); if (!isNaN(t)) { where.push("m.ts <= ?"); params.push(t); } }
      params.push(limit ?? 50);
      const rows = sqlAll(
        `SELECT m.id, m.conversation_id, m.direction, COALESCE(m.body,'') AS body, m.sender, m.ts,
                m.media_type, m.media_path, COALESCE(c.title, c.external_id) AS conv_title
         FROM unified_message m JOIN unified_conversation c ON m.conversation_id=c.id
         WHERE ${where.join(" AND ")} ORDER BY m.ts DESC LIMIT ?`, params,
      ) as any[];
      return ok(rows.map(toMsg));
    });

  server.tool("mark_read",
    { chat_id: z.string().describe("chat_id from list_chats") },
    async ({ chat_id }) => {
      try {
        const d = new DatabaseSync(DB_PATH);
        d.prepare(`UPDATE unified_conversation SET unread=0, updated_at=? WHERE id=?`).run(Date.now(), chat_id);
        d.close();
        return ok({ ok: true });
      } catch (e: any) { return ok({ ok: false, error: e?.message ?? String(e) }); }
    });

  // ── Inbox management (run the inbox from the terminal) — DB-level via core/inbox.ts ──

  server.tool("archive_conversation",
    { chat_id: z.string(), archived: z.boolean().optional().describe("default true; false to unarchive") },
    async (a) => ok(withDb((d) => inbox.archiveConversation(d, a))));

  server.tool("mute_conversation",
    { chat_id: z.string(), muted: z.boolean().optional().describe("default true; false to unmute") },
    async (a) => ok(withDb((d) => inbox.muteConversation(d, a))));

  server.tool("mark_all_read", {},
    async () => ok(withDb((d) => inbox.markAllRead(d))));

  server.tool("unread_total", {},
    async () => ok(withDb((d) => inbox.unreadTotal(d))));

  server.tool("connected_accounts", {},
    async () => ok(withDb((d) => inbox.connectedAccounts(d))));

  server.tool("calendar_events",
    { days: z.number().optional().describe("look-ahead window in days (default 14)"),
      limit: z.number().optional().describe("max events (default 100)") },
    async (a) => ok(withDb((d) => calendar.calendarEvents(d, a))));

  // Book an event on the user's LOCAL macOS Calendar (EventKit, via the DewLink app) — no cloud OAuth.
  // Queues a calendar_command row; the app's CalendarStore poller creates it (~5s) and mirrors it back.
  server.tool("calendar_create_event",
    { title: z.string().describe("event title, e.g. 'Intro call — Acme (Roki)'"),
      start: z.string().describe("start time, ISO 8601 (e.g. 2026-06-10T15:00:00-04:00 or ...Z)"),
      end: z.string().optional().describe("end time ISO 8601; omit to use duration_min"),
      duration_min: z.number().optional().describe("length in minutes if end omitted (default 30)"),
      notes: z.string().optional().describe("agenda / context / who"),
      location: z.string().optional().describe("location or video link"),
      calendar: z.string().optional().describe("calendar title to book on (e.g. an account name); default = your default calendar") },
    async ({ title, start, end, duration_min, notes, location, calendar }) => {
      try {
        const startMs = Date.parse(start);
        if (Number.isNaN(startMs)) return ok({ ok: false, error: "invalid 'start' — use ISO 8601" });
        let endMs = end ? Date.parse(end) : NaN;
        if (Number.isNaN(endMs)) endMs = startMs + (duration_min ?? 30) * 60_000;
        const d = new DatabaseSync(DB_PATH);
        d.exec(`CREATE TABLE IF NOT EXISTS calendar_command (
          id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT 'local',
          title TEXT NOT NULL, start_ms INTEGER NOT NULL, end_ms INTEGER NOT NULL,
          notes TEXT, location TEXT, calendar TEXT,
          status TEXT NOT NULL DEFAULT 'pending', source TEXT DEFAULT 'mcp',
          event_id TEXT, error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);`);
        const now = Date.now();
        const id = `cc:${now}:${Math.random().toString(36).slice(2, 8)}`;
        d.prepare(
          `INSERT INTO calendar_command
           (id, workspace_id, title, start_ms, end_ms, notes, location, calendar, status, source, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'mcp', ?, ?)`,
        ).run(id, currentWorkspaceId(), title, startMs, endMs, notes ?? "", location ?? "", calendar ?? "", now, now);
        d.close();
        return ok({ status: "queued", command_id: id,
          start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(),
          detail: "Queued — DewLink creates it on your local Calendar within ~5s. Verify with calendar_events." });
      } catch (e: any) {
        return ok({ ok: false, error: e?.message ?? String(e) });
      }
    });

  server.tool("list_call_recordings",
    { contactId: z.string().optional(), conversationId: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => calls.listCallRecordings(d, a))));

  server.tool("get_call_transcript",
    { callId: z.string() },
    async (a) => ok(withDb((d) => calls.getCallTranscript(d, a))));

  server.tool("get_call_summary",
    { callId: z.string() },
    async (a) => ok(withDb((d) => calls.getCallSummary(d, a))));

  server.tool("search_calls",
    { query: z.string(), contactId: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => calls.searchCalls(d, a))));

  server.tool("list_crm_update_proposals",
    { status: z.string().optional(), callId: z.string().optional(), contactId: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => calls.listCrmUpdateProposals(d, a))));

  server.tool("apply_crm_update_proposal",
    { proposalId: z.string() },
    async (a) => ok(withDb((d) => calls.applyCrmUpdateProposal(d, a))));

  server.tool("reject_crm_update_proposal",
    { proposalId: z.string() },
    async (a) => ok(withDb((d) => calls.rejectCrmUpdateProposal(d, a))));

  server.tool("sync_preview",
    { chat_id: z.string().describe("conversation id from list_chats — preview the cloud-ingest payload for it"),
      limit: z.number().optional().describe("max messages to include (default 20)") },
    async (a) => ok(withDb((d) => cloudsync.syncPreview(d, a))));

  // ── Web→local sync: pull the signed-in user's web data (companion JWT) ──
  server.tool("sync_health", {},
    async () => ok(withDb((d) => syncloop.syncHealth(d))));

  server.tool("sync_now",
    { limit: z.number().optional().describe("max inbox conversations for this pull (default 100)") },
    async (a) => {
      try {
        const result = await withDbAsync((d) => syncloop.runIncrementalSync(d, undefined, { inboxLimit: a.limit }));
        return ok(result);
      } catch (e: any) {
        return ok({ ok: false, error: e?.message ?? String(e) });
      }
    });

  server.tool("sync_bootstrap", {},
    async () => {
      try {
        const tree = await websync.bootstrap();
        const integrations = websync.flattenIntegrations(tree);
        const workspaceState = withDb((d) => syncworkspace.storeBootstrapWorkspaces(d, tree));
        return ok({
          ok: true,
          activeOrganizationId: tree.activeOrganizationId,
          selectedWorkspaceId: workspaceState.selectedWorkspaceId,
          organizations: tree.organizations.map((o) => ({
            id: o.id, name: o.name, role: o.role, workspaces: o.workspaces.length,
          })),
          integrations,
          counts: { organizations: tree.organizations.length, integrations: integrations.length },
        });
      } catch (e: any) {
        return ok({ ok: false, error: e?.message ?? String(e),
          hint: e instanceof websync.WebAuthError ? "Sign in via the DewLink app first." : undefined });
      }
    });

  // Pull web conversations (+ recent messages) into the unified inbox (Phase 2).
  server.tool("sync_inbox",
    { integrationId: z.string().optional().describe("scope to one connected account (integration id)"),
      limit: z.number().optional().describe("max conversations") },
    async (a) => {
      try {
        const convs = await websync.fetchConversations(a);
        const counts = withDb((d) => syncpull.storeConversations(d, convs, { workspaceId: syncworkspace.selectedWorkspaceId(d) }));
        return ok({ ok: true, ...counts });
      } catch (e: any) {
        return ok({ ok: false, error: e?.message ?? String(e),
          hint: e instanceof websync.WebAuthError ? "Sign in via the DewLink app first." : undefined });
      }
    });

  // Pull web CRM (contacts/companies/deals) into the local CRM tables (Phase 3).
  server.tool("sync_crm", {},
    async () => {
      try {
        const crm = await websync.fetchCrm();
        const counts = withDb((d) => synccrm.storeCrm(d, crm, { workspaceId: syncworkspace.selectedWorkspaceId(d) }));
        return ok({ ok: true, ...counts });
      } catch (e: any) {
        return ok({ ok: false, error: e?.message ?? String(e),
          hint: e instanceof websync.WebAuthError ? "Sign in via the DewLink app first." : undefined });
      }
    });

  // ── Local AI wrappers (P0/P1) ──
  // Swift owns DewAI. MCP callers use ai_generate, which queues a local DB request for
  // the app watcher to fulfill through Apple Intelligence → Codex CLI.
  server.tool("ai_status", {},
    async () => withDb((d) => {
      const bridge = aiBridgeStatus(d);
      return aiOk(d, "ai_status", {}, {
        ok: true,
        local_only: true,
        queue: bridge,
        platform: {
          os: process.platform,
          arch: process.arch,
          apple_silicon: process.platform === "darwin" && process.arch === "arm64",
        },
        generation_backends: {
          order: ["Apple Intelligence", "Codex CLI"],
          active: "reported_by_swift_or_runtime_bridge",
          policy: "DewAI only: Apple Intelligence first, Codex CLI fallback.",
        },
        swift_ui: {
          ai_summarize_thread: "wired in InboxView through DewAI",
          ai_draft_reply: "wired in InboxView through DewDraftService",
          ai_rewrite_text: "wired in InboxView through DewDraftService",
          ai_transcribe_audio: "wired in InboxView through WhisperKit SpeechModelManager",
        },
        swift_loopback: { status: "wired_via_localdb_queue", table: "ai_requests" },
        mcp_wrappers: {
          ai_generate: "wired queue/poll bridge",
          ai_status: "wired metadata",
          ai_models_list: "wired metadata",
          ai_summarize_thread: "wired queue/poll bridge",
          ai_draft_reply: "wired queue/poll bridge; draft_only true",
          ai_rewrite_text: "wired queue/poll bridge; draft_only true",
          ai_transcribe_audio: "wired queue/poll bridge; kind transcribe",
        },
      });
    }));

  server.tool("ai_generate",
    {
      instructions: z.string().optional().describe("system/developer-style instructions for DewAI"),
      prompt: z.string().min(1).describe("user prompt to generate from"),
    },
    async ({ instructions, prompt }) => withDbAsync(async (d) => {
      const request = createAiRequest(d, { instructions, prompt });
      try {
        const completed = await waitForAiRequest(d, request.id, { timeoutMs: AI_REQUEST_TIMEOUT_MS, pollMs: 1_000 });
        if (completed.status === "done") {
          return ok({ ok: true, id: completed.id, response: completed.response ?? "", completed_at: completed.completed_at });
        }
        return ok({ ok: false, id: completed.id, error: completed.error ?? "AI generation failed.", completed_at: completed.completed_at });
      } catch (e: any) {
        return ok({ ok: false, id: request.id, error: e?.message ?? String(e), status: "timeout" });
      }
    }));

  server.tool("ai_models_list", {},
    async () => withDb((d) => {
      return aiOk(d, "ai_models_list", {}, {
        ok: true,
        local_only: true,
        bridge: aiBridgeStatus(d),
        text_generation: {
          order: ["Apple Intelligence", "Codex CLI"],
          active: "DewAI chooses at runtime in the Swift watcher.",
          models: [
            { backend: "Apple Intelligence", role: "Primary on-device text generation" },
            { backend: "Codex CLI", role: "Fallback text generation through flat ChatGPT auth" },
          ],
        },
        transcription: {
          backend: "reported_by_ai_transcribe_audio_response",
          note: "Speech model details come from the Swift transcription service response, not Node metadata.",
        },
      });
    }));

  server.tool("ai_summarize_thread",
    { conversationId: z.string().optional().describe("conversation id from inbox_conversations"),
      contactId: z.string().optional().describe("contact id for the unified local thread") },
    async ({ conversationId, contactId }) => withDbAsync(async (d) => {
      const memory = buildMemoryAwareInstructions(d, {
        tool: "ai_summarize_thread",
        conversationId,
        contactId,
        baseSystemInstructions: AI_SYSTEM_INSTRUCTIONS.ai_summarize_thread,
      });
      const input = { conversationId: conversationId ?? null, contactId: contactId ?? null };
      const prompt = jsonPrompt("summarize_thread", { requested: input, ...conversationPacket(d, conversationId) });
      return runAiBridgeTool(d, "ai_summarize_thread", input, memory.system_instructions, prompt, (raw, parsed) => ({
        summary: parsed?.summary ?? parsed?.response ?? raw,
        citations: parsed?.citations ?? [],
        model: parsed?.model ?? parsed?.backend ?? "bridge",
        requested: input,
        memory,
      }));
    }));

  server.tool("ai_draft_reply",
    { conversationId: z.string().optional().describe("conversation id from inbox_conversations"),
      contactId: z.string().optional().describe("contact id for the unified local thread"),
      style: z.string().optional().describe("explicit tone/style, e.g. warm, concise, direct") },
    async ({ conversationId, contactId, style }) => withDbAsync(async (d) => {
      const memory = buildMemoryAwareInstructions(d, {
        tool: "ai_draft_reply",
        conversationId,
        contactId,
        baseSystemInstructions: AI_SYSTEM_INSTRUCTIONS.ai_draft_reply,
      });
      const input = { conversationId: conversationId ?? null, contactId: contactId ?? null, style: style ?? null };
      const prompt = jsonPrompt("draft_reply", {
        requested: input,
        draft_only: true,
        send_side_effect: false,
        ...conversationPacket(d, conversationId),
      });
      return runAiBridgeTool(d, "ai_draft_reply", input, memory.system_instructions, prompt, (raw, parsed) => ({
        local_only: true,
        draft_only: true,
        draft: parsed?.draft ?? parsed?.response ?? raw,
        requested: input,
        memory,
        metadata: {
          intent: "draft_reply",
          ...(parsed?.metadata ?? {}),
          send_side_effect: false,
        },
      }));
    }));

  server.tool("ai_rewrite_text",
    { text: z.string().describe("composer text to rewrite; result is a draft only"),
      action: z.string().optional().describe("shorten | warmer | grammar | translate"),
      target_language: z.string().optional().describe("target language when action=translate"),
      style: z.string().optional().describe("explicit tone/style") },
    async ({ text, action, target_language, style }) => withDbAsync(async (d) => {
      const memory = buildMemoryAwareInstructions(d, {
        tool: "ai_rewrite_text",
        baseSystemInstructions: AI_SYSTEM_INSTRUCTIONS.ai_rewrite_text,
      });
      const input = { text_chars: text.length, action: action ?? "grammar", target_language: target_language ?? null, style: style ?? null };
      const prompt = jsonPrompt("rewrite_text", {
        requested: input,
        text,
        draft_only: true,
        send_side_effect: false,
      });
      return runAiBridgeTool(d, "ai_rewrite_text", input, memory.system_instructions, prompt, (raw, parsed) => ({
        local_only: true,
        draft_only: true,
        draft: parsed?.draft ?? parsed?.rewritten_text ?? parsed?.response ?? raw,
        requested: input,
        memory,
        metadata: {
          intent: "rewrite_text",
          ...(parsed?.metadata ?? {}),
          send_side_effect: false,
        },
      }));
    }));

  server.tool("ai_transcribe_audio",
    { messageId: z.string().optional().describe("unified_message id for a local audio message"),
      audioPath: z.string().optional().describe("local media path already downloaded by the app") },
    async ({ messageId, audioPath }) => withDbAsync(async (d) => {
      const input = { messageId: messageId ?? null, audioPath: audioPath ?? null };
      const prompt = jsonPrompt("transcribe", { requested: input, kind: "transcribe", messageId: messageId ?? null, audioPath: audioPath ?? null });
      return runAiBridgeTool(d, "ai_transcribe_audio", input, "Transcribe the referenced local audio through the Swift speech service and return JSON with transcript plus backend/model metadata.", prompt, (raw, parsed) => ({
        local_only: true,
        transcript: parsed?.transcript ?? parsed?.text ?? parsed?.response ?? raw,
        backend: parsed?.backend ?? parsed?.model?.backend ?? parsed?.model ?? "bridge",
        model: parsed?.model ?? null,
        cached: parsed?.cached ?? false,
        requested: input,
      }));
    }));

  // ── Mail bridge (read + draft the user's Gmail/IMAP via Mac Mail.app — no cloud OAuth) ──
  server.tool("mail_accounts", {},
    async () => ok(await mail.mailAccounts()));
  server.tool("mail_list",
    { limit: z.number().optional().describe("max recent inbox messages (default 20, max 100)") },
    async (a) => ok(await mail.mailList(a)));
  server.tool("mail_read",
    { id: z.string().describe("message id from mail_list") },
    async (a) => ok(await mail.mailRead(a)));
  server.tool("mail_draft",
    { to: z.string().describe("recipient email address"),
      subject: z.string().optional(),
      body: z.string().optional().describe("plain-text body"),
      cc: z.string().optional().describe("optional cc email address") },
    async (a) => ok(await mail.mailDraft(a)));   // HW#1: creates a VISIBLE draft, never sends
  server.tool("mail_send",
    { to: z.string().describe("recipient email address"),
      subject: z.string().optional(),
      body: z.string().optional().describe("plain-text body"),
      cc: z.string().optional().describe("optional cc email address"),
      from: z.string().optional().describe("which connected account sends (a user from mail_accounts); default account if omitted") },
    async (a) => ok(await mail.mailSend(a)));   // AUTONOMOUS: sends directly (Roki scoped HW#1 down for this email lane)

  // ── Reminders bridge (read + create via Mac Reminders.app) ──
  server.tool("reminder_lists", {}, async () => ok(await reminders.reminderLists()));
  server.tool("reminders_list",
    { list: z.string().optional().describe("a reminder list name (default list if omitted)"),
      limit: z.number().optional() },
    async (a) => ok(await reminders.remindersList(a)));
  server.tool("reminder_create",
    { title: z.string(), notes: z.string().optional(), list: z.string().optional() },
    async (a) => ok(await reminders.reminderCreate(a)));

  // ── Contacts bridge (search the Mac address book) ──
  server.tool("contacts_list",
    { limit: z.number().optional().describe("max address-book people (default 50, max 200)") },
    async (a) => ok(await macContacts.contactsList(a)));
  server.tool("contacts_search",
    { query: z.string().describe("name substring to search the address book"),
      limit: z.number().optional() },
    async (a) => ok(await macContacts.contactsSearch(a)));

  // ── Notes bridge (list/read/create Apple Notes via Notes.app) ──
  server.tool("notes_list", { limit: z.number().optional() },
    async (a) => ok(await notes.notesList(a)));
  server.tool("notes_read", { id: z.string().describe("note id from notes_list") },
    async (a) => ok(await notes.notesRead(a)));
  server.tool("note_create",
    { title: z.string(), body: z.string().optional(), folder: z.string().optional() },
    async (a) => ok(await notes.noteCreate(a)));

  // ── iMessage/SMS bridge (READ-only; needs Full Disk Access for DewLink) ──
  server.tool("imessage_chats", { limit: z.number().optional() },
    async (a) => ok(imessage.imessageChats(a)));
  server.tool("imessage_messages",
    { chat_id: z.number().describe("chat_id from imessage_chats"), limit: z.number().optional() },
    async (a) => ok(imessage.imessageMessages(a)));
  server.tool("imessage_search",
    { query: z.string().describe("text to search across all conversations"), limit: z.number().optional() },
    async (a) => ok(imessage.imessageSearch(a)));

  server.tool("delete_message",
    { message_id: z.string().describe("LOCAL delete only — never touches the provider") },
    async (a) => ok(withDb((d) => inbox.deleteMessageLocal(d, a))));

  server.tool("new_conversation",
    { channel: z.string().describe("WHATSAPP | EMAIL | TELEGRAM | DISCORD | SLACK | SMS | IMESSAGE"),
      account: z.string().describe("the connected account slug (e.g. wb, wp)"),
      external_id: z.string().describe("recipient handle: WhatsApp <digits>@s.whatsapp.net, email address, chat id, phone…"),
      title: z.string().optional() },
    async (a) => ok(withDb((d) => inbox.newConversation(d, a))));

  // ── Cross-channel identity (one person across channels) — DB-level via core/identity.ts ──

  server.tool("suggest_merges",
    { limit: z.number().optional().describe("max candidate pairs (default 50)") },
    async (a) => ok(withDb((d) => identity.suggestedMerges(d, a))));

  server.tool("merge_contacts",
    { primary: z.string().describe("surviving person — a contact id OR a chat_id (resolved to its contact)"),
      merged: z.string().describe("person to fold into primary — a contact id OR a chat_id"),
      signal: z.string().optional().describe("why (email|phone|name|manual); default manual") },
    async (a) => ok(withDb((d) => identity.mergeContacts(d, a))));

  server.tool("unmerge",
    { merge_id: z.string().describe("the merge_id returned by merge_contacts — reverses it") },
    async (a) => ok(withDb((d) => identity.unmergeContacts(d, a))));

  server.tool("link_identity",
    { contact: z.string().describe("the person — a contact id OR a chat_id"),
      channel: z.string().describe("WHATSAPP | EMAIL | TELEGRAM | …"),
      handle: z.string().describe("the channel handle (jid, email address, …) — email/phone signal auto-derived") },
    async (a) => ok(withDb((d) => identity.linkIdentity(d, a))));

  server.tool("identities_for",
    { contact: z.string().describe("the person — a contact id OR a chat_id; follows merges") },
    async (a) => ok(withDb((d) => identity.identitiesFor(d, a))));

  // Operator cockpit — ONE call = full situational awareness (state + the capability catalog).
  // Run this first in a fresh session to see what's going on and what you can do.
  server.tool("cockpit", {},
    async () => ok(withDb((d) => cockpitCore(d))));

  server.tool("memory",
    {
      action: z.enum(["add", "replace", "remove", "read"]).describe("memory action"),
      scope: z.string().optional().describe("business | owner | contact:<contact_id>; default business"),
      content: z.string().optional().describe("entry content for add/replace"),
      old_text: z.string().optional().describe("short unique substring for replace/remove"),
    },
    async (a) => ok(await withDb((d) => memoryTool(new WorkMemoryStore(d), a))));

  server.tool("memory_search",
    {
      query: z.string().describe("FTS text query across agent messages and durable memory entries"),
      scope: z.string().optional().describe("optional memory scope filter for memory entries"),
      limit: z.number().optional().describe("max results, default 20"),
    },
    async (a) => ok(withDb((d) => new WorkMemoryStore(d).search(a.query, { scope: a.scope, limit: a.limit }))));

  // Send-with-approval: this does NOT send. It queues an outbound draft that the user reviews and
  // sends from the app. Honors the never-auto-send rule — the human is always in the loop.
  server.tool("send_message",
    {
      chat_id: z.string().optional().describe("chat_id from list_chats (preferred)"),
      conversationId: z.string().optional().describe("alias for chat_id"),
      text: z.string().describe("the message body to queue for the user's approval"),
      media_path: z.string().optional().describe("optional local file to attach"),
      draft_only: z.boolean().optional().describe("default FALSE = send immediately. Pass true to hold as a draft for the user to approve in the app."),
    },
    async ({ chat_id, conversationId, text, media_path, draft_only }) => {
      const convId = chat_id || conversationId || "";
      const held = draft_only === true;                       // direct-send by default; hold only if asked
      const status = held ? "pending" : "approved";           // 'approved' → the app auto-sends it
      let d: DatabaseSync | null = null;
      try {
        d = new DatabaseSync(DB_PATH);
        d.exec(`CREATE TABLE IF NOT EXISTS outbound_draft (
          id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT 'local', conversation_id TEXT NOT NULL,
          channel TEXT NOT NULL, account_id TEXT NOT NULL, external_id TEXT NOT NULL, title TEXT,
          body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', source TEXT DEFAULT 'mcp',
          created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);`);
        const conv = d.prepare(
          `SELECT id, workspace_id, account_id, external_id, channel, COALESCE(title, external_id) AS title, unread
           FROM unified_conversation WHERE id=? AND workspace_id=? AND deleted_at IS NULL`,
        ).get(convId, currentWorkspaceId()) as any;
        if (!conv) return ok({ status: "error", error: "chat_id/conversation not found" });
        const route = replyRouteForConversation(conv);
        if (route.kind === "web") {
          if (held) {
            return ok({ status: "error", error: "draft_only is not supported for web-mirrored conversations yet" });
          }
          if (media_path) {
            return ok({ status: "error", error: "media_path is not supported for web-mirrored conversations yet" });
          }
          d.close(); d = null;
          const sent = await websync.sendReply(route.conversationId, route.integrationId, text);
          const w = new DatabaseSync(DB_PATH);
          try {
            w.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;");
            syncpull.storeConversations(w, [{
              externalId: route.conversationId,
              channel: conv.channel,
              accountId: route.integrationId,
              title: conv.title,
              lastTs: Number(sent.ts) || Date.now(),
              unread: Number(conv.unread) || 0,
              messages: [{
                providerMsgId: sent.providerMsgId,
                direction: "OUT",
                body: sent.body,
                sender: sent.sender,
                ts: Number(sent.ts) || Date.now(),
                source: "local",
              }],
            }], { workspaceId: conv.workspace_id ?? currentWorkspaceId() });
          } finally {
            w.close();
          }
          return ok({
            status: "sent",
            provider_msg_id: sent.providerMsgId,
            detail: `Sent to "${conv.title}" (${conv.channel}) via the web companion.`,
          });
        }
        const now = Date.now();
        const id = `od:${now}:${Math.random().toString(36).slice(2, 8)}`;
        const body = media_path ? `${text}\n[attach: ${media_path}]` : text;
        d.prepare(
          `INSERT INTO outbound_draft
           (id, workspace_id, conversation_id, channel, account_id, external_id, title, body, status, source, created_at, updated_at)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'mcp', ?, ?)`,
        ).run(id, conv.workspace_id, convId, conv.channel, conv.account_id, conv.external_id, conv.title, body, status, now, now);
        return ok(held
          ? { status: "drafted", message_id: id, detail: `Held a draft to "${conv.title}" (${conv.channel}) — approve it in the app to send.` }
          : { status: "sending", message_id: id, detail: `Sending to "${conv.title}" (${conv.channel}) now (the app delivers it on its next tick, ~4s).` });
      } catch (e: any) {
        if (e instanceof websync.WebAuthError) {
          return ok({ ok: false, error: "not signed in", hint: "Sign in via the DewLink app first." });
        }
        return ok({ ok: false, error: e?.message ?? String(e) });
      } finally {
        if (d) d.close();
      }
    });

  // ── DEV workspace (dev_*) — SRS, tasks/bugs, memory, team agents, decisions, browser QA ──

  server.tool("dev_project_srs_get",
    { project_slug: z.string() },
    async (a) => ok(withDb((d) => dev.devProjectSrsGet(d, a))));

  server.tool("dev_project_srs_upsert",
    { project_slug: z.string(), overview: z.string().optional(), requirements: z.string().optional(),
      modules: z.string().optional(), acceptance: z.string().optional(), open_questions: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devProjectSrsUpsert(d, a))));

  server.tool("dev_task_create",
    { project_slug: z.string(), title: z.string(), priority: z.string().optional(),
      module: z.string().optional(), owner: z.string().optional(), summary: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devTaskCreate(d, a))));

  server.tool("dev_bug_create",
    { project_slug: z.string(), title: z.string(), priority: z.string().optional(),
      module: z.string().optional(), owner: z.string().optional(), url: z.string().optional(),
      evidence_path: z.string().optional(), summary: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devBugCreate(d, a))));

  server.tool("dev_work_list",
    { project_slug: z.string(), kind: z.string().optional(), status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => dev.devWorkList(d, a))));

  server.tool("dev_memory_propose",
    { project_slug: z.string(), kind: z.string().optional(), body: z.string(), source: z.string().optional(),
      owner: z.string().optional(), confidence: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devMemoryPropose(d, a))));

  server.tool("dev_memory_approve",
    { memory_id: z.string(), owner: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devMemoryApprove(d, a))));

  server.tool("dev_memory_list",
    { project_slug: z.string(), status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => dev.devMemoryList(d, a))));

  server.tool("dev_agent_register",
    { project_slug: z.string(), name: z.string(), tool: z.string(), role: z.string().optional(),
      status: z.string().optional(), current_task: z.string().optional(), session_id: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devAgentRegister(d, a))));

  server.tool("dev_agent_heartbeat",
    { agent_id: z.string(), status: z.string().optional(), current_task: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devAgentHeartbeat(d, a))));

  server.tool("dev_decision_propose",
    { project_slug: z.string(), title: z.string(), proposal: z.string(), rationale: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devDecisionPropose(d, a))));

  server.tool("dev_decision_list",
    { project_slug: z.string(), status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => dev.devDecisionList(d, a))));

  server.tool("dev_browser_session_update",
    { project_slug: z.string(), url: z.string().optional(), title: z.string().optional(),
      status: z.string().optional(), last_capture_path: z.string().optional(), last_error: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devBrowserSessionUpdate(d, a))));

  server.tool("dev_bug_capture_create",
    { project_slug: z.string(), title: z.string(), url: z.string().optional(),
      note: z.string().optional(), evidence_path: z.string().optional(), session_id: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devBugCaptureCreate(d, a))));

  server.tool("dev_browser_scan",
    { project_slug: z.string(), url: z.string(), wait_ms: z.number().optional() },
    async (a) => ok(await withDbAsync((d) => dev.devBrowserScan(d, a))));

  server.tool("dev_health_snapshot",
    { project_slug: z.string().optional() },
    async (a) => ok(withDb((d) => dev.devHealthSnapshot(d, a))));

  // ── Universal Work Console (work_*) — module contexts, sessions, items, memory, decisions, evidence ──

  server.tool("work_context_upsert",
    { module: z.string().describe("gtm|cx|ops|dev|dew"), slug: z.string(), kind: z.string().optional(),
      title: z.string(), subtitle: z.string().optional(), status: z.string().optional(), owner: z.string().optional(),
      external_ref: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workContextUpsert(d, a))));

  server.tool("work_context_list",
    { module: z.string().optional(), kind: z.string().optional(), status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workContextList(d, a))));

  server.tool("work_context_get",
    { context_id: z.string().optional(), module: z.string().optional(), context_slug: z.string().optional() },
    async (a) => ok(withDb((d) => work.workContextGet(d, a))));

  server.tool("work_session_register",
    { session_id: z.string().optional(), module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      purpose: z.string(), owner: z.string().optional(), tool: z.string(), status: z.string().optional(),
      pid: z.number().optional(), cwd: z.string().optional(), endpoint: z.string().optional(),
      current_item_id: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workSessionRegister(d, a))));

  server.tool("work_session_heartbeat",
    { session_id: z.string(), status: z.string().optional(), purpose: z.string().optional(),
      current_item_id: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workSessionHeartbeat(d, a))));

  server.tool("work_item_create",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(), kind: z.string().optional(),
      title: z.string(), status: z.string().optional(), priority: z.string().optional(), owner: z.string().optional(),
      session_id: z.string().optional(), source: z.string().optional(), summary: z.string().optional(),
      due_at: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workItemCreate(d, a))));

  server.tool("work_item_update",
    { item_id: z.string(), module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      kind: z.string().optional(), title: z.string().optional(), status: z.string().optional(),
      priority: z.string().optional(), owner: z.string().optional(), session_id: z.string().optional(),
      summary: z.string().optional(), due_at: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workItemUpdate(d, a))));

  server.tool("work_item_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      kind: z.string().optional(), status: z.string().optional(), owner: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workItemList(d, a))));

  server.tool("work_memory_propose",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      kind: z.string().optional(), body: z.string(), source: z.string().optional(),
      confidence: z.string().optional(), owner: z.string().optional() },
    async (a) => ok(withDb((d) => work.workMemoryPropose(d, a))));

  server.tool("work_memory_approve",
    { memory_id: z.string(), owner: z.string().optional(), status: z.string().optional().describe("approved|rejected") },
    async (a) => ok(withDb((d) => work.workMemoryApprove(d, a))));

  server.tool("work_memory_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workMemoryList(d, a))));

  server.tool("work_decision_propose",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      title: z.string(), proposal: z.string().optional(), rationale: z.string().optional(),
      risk: z.string().optional().describe("low|normal|high|external|money|credential") },
    async (a) => ok(withDb((d) => work.workDecisionPropose(d, a))));

  server.tool("work_decision_approve",
    { decision_id: z.string(), decided_by: z.string(), status: z.string().optional().describe("approved|rejected"),
      rationale: z.string().optional() },
    async (a) => ok(withDb((d) => work.workDecisionApprove(d, a))));

  server.tool("work_decision_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workDecisionList(d, a))));

  server.tool("approvals_control_chat",
    { action: z.enum(["get", "set"]).optional(), control_chat_id: z.string().optional() },
    async ({ action, control_chat_id }) => ok(withDb((d) => {
      if ((action ?? "get") === "set") return inchatApprovals.setApprovalsControlChat(d, control_chat_id ?? "");
      return inchatApprovals.getApprovalsControlChat(d);
    })));

  server.tool("work_evidence_attach",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      item_id: z.string().optional(), session_id: z.string().optional(), kind: z.string().optional(),
      title: z.string(), source: z.string().optional(), uri: z.string().optional(),
      summary: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workEvidenceAttach(d, a))));

  server.tool("work_evidence_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      item_id: z.string().optional(), session_id: z.string().optional(), kind: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workEvidenceList(d, a))));

  server.tool("work_message_create",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      session_id: z.string().optional(), agent_id: z.string().optional(), speaker: z.string().optional(),
      role: z.string().optional().describe("roki|agent|system|safety"), kind: z.string().optional(),
      body: z.string(), status: z.string().optional(), metadata: z.any().optional() },
    async (a) => ok(withDb((d) => work.workMessageCreate(d, a))));

  server.tool("work_message_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      session_id: z.string().optional(), agent_id: z.string().optional(), kind: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workMessageList(d, a))));

  server.tool("work_agent_register",
    { module: z.string(), context_id: z.string().optional(), context_slug: z.string().optional(),
      name: z.string(), tool: z.string(), role: z.string().optional(), status: z.string().optional(),
      current_task: z.string().optional(), session_id: z.string().optional() },
    async (a) => ok(withDb((d) => work.workAgentRegister(d, a))));

  server.tool("work_agent_heartbeat",
    { agent_id: z.string(), status: z.string().optional(), current_task: z.string().optional(), session_id: z.string().optional() },
    async (a) => ok(withDb((d) => work.workAgentHeartbeat(d, a))));

  server.tool("work_agent_list",
    { module: z.string().optional(), context_id: z.string().optional(), context_slug: z.string().optional(),
      status: z.string().optional(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workAgentList(d, a))));

  server.tool("work_module_snapshot",
    { module: z.string(), limit: z.number().optional() },
    async (a) => ok(withDb((d) => work.workModuleSnapshot(d, a))));

  // ── Control plane (ops_*) — thin wrappers over core/ops.ts; brain runs everything from here ──

  server.tool("ops_register",
    { name: z.string(), project_slug: z.string(), pid: z.number().optional(), cwd: z.string().optional(),
      interval: z.number().optional().describe("heartbeat interval seconds (default 600)"),
      role: z.string().optional().describe("worker|brain"), model: z.string().optional() },
    async (a) => ok(withDb((d) => ops.opsRegister(d, a))));

  server.tool("ops_heartbeat",
    { tab_id: z.string(), status: z.string().optional().describe("running|idle|blocked"), current_task: z.string().optional() },
    async (a) => ok(withDb((d) => ops.opsHeartbeat(d, a))));

  server.tool("ops_report",
    { tab_id: z.string().optional(), project_slug: z.string(),
      kind: z.string().describe("progress|found|blocked|need|decision|done"), body: z.string() },
    async (a) => ok(withDb((d) => ops.opsReport(d, a))));

  server.tool("ops_inbox",
    { tab_id: z.string() },
    async (a) => ok(withDb((d) => ops.opsInbox(d, a))));

  server.tool("ops_ack",
    { message_id: z.string(), result: z.string().optional() },
    async (a) => ok(withDb((d) => ops.opsAck(d, a))));

  server.tool("ops_ask",
    { tab_id: z.string().optional(), project_slug: z.string().optional(), question: z.string(),
      urgency: z.string().optional().describe("low|normal|high") },
    async (a) => ok(withDb((d) => ops.opsAsk(d, a))));

  server.tool("ops_board", {},
    async () => ok(withDb((d) => ops.opsBoard(d))));

  server.tool("ops_project",
    { slug: z.string() },
    async (a) => ok(withDb((d) => ops.opsProject(d, a))));

  server.tool("ops_order",
    { target: z.string().describe("a tab_id OR a project slug"), body: z.string(), from_tab_id: z.string().optional() },
    async (a) => ok(withDb((d) => ops.opsOrder(d, a))));

  server.tool("ops_answer",
    { ask_id: z.string(), answer: z.string(), answered_by: z.string().optional() },
    async (a) => ok(withDb((d) => ops.opsAnswer(d, a))));

  server.tool("ops_resume", {},
    async () => ok(withDb((d) => ops.opsResume(d))));

  server.tool("ops_upsert_project",
    { slug: z.string(), name: z.string(), goal: z.string().optional(),
      status: z.string().optional().describe("active|paused|blocked|done"), priority: z.number().optional(),
      control_dir: z.string().optional(), repo_path: z.string().optional(),
      exec_host: z.string().optional().describe("mac|hetzner|server"),
      focus: z.string().optional(), blockers: z.string().optional() },
    async (a) => ok(withDb((d) => ops.upsertProject(d, a))));

  return server;
}

async function main() {
  const httpServer = http.createServer(async (req, res) => {
    // Stateless: every request gets its own server+transport, torn down when the response closes.
    if (req.method !== "POST") {
      res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
      res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed (use POST; stateless server)" }, id: null }));
      return;
    }
    const rejected = guardRequest(req.headers, TOKEN);
    if (rejected) {
      res.writeHead(403, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: `Forbidden: ${rejected}` }, id: null }));
      return;
    }
    const server = buildServer();
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    res.on("close", () => { transport.close(); server.close(); });
    try {
      await server.connect(transport);
      const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer);
      const raw = Buffer.concat(chunks).toString("utf8");
      await transport.handleRequest(req, res, raw ? JSON.parse(raw) : undefined);
    } catch (err: any) {
      if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null })); }
    }
  });
  httpServer.listen(PORT, "127.0.0.1", () => {
    process.stdout.write(`@@DEWX-MCP@@ listening http://127.0.0.1:${PORT}/mcp (db=${DB_PATH}, stateless)\n`);
    syncloop.startBackgroundSync(DB_PATH);
  });
}
main().catch((e) => { process.stderr.write(`dewx-mcp fatal: ${e?.message}\n`); process.exit(1); });
