"""Agent tools: actions the agent can perform, enforced by the contract.

Each tool maps to:
1. An Anthropic tool definition (sent to the LLM)
2. A contract action (checked before execution)
3. An execution function (calls our internal APIs)
"""

from __future__ import annotations

import asyncio
import logging
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from agntspace.agent.orchestrator import Orchestrator
    from agntspace.coach.service import CoachService
    from agntspace.integrations.email_client import EmailIntegration
    from agntspace.journal.service import JournalService
    from agntspace.memory.engine import MemoryEngine
    from agntspace.memory.graph import KnowledgeGraph
    from agntspace.projects.service import ProjectService
    from agntspace.security.contract import ContractEnforcer
    from agntspace.tasks.service import TaskService
    from agntspace.vault.reader import VaultReader
    from agntspace.vault.search import VaultSearch
    from agntspace.vault.writer import VaultWriter

log = logging.getLogger(__name__)


# ---- Tool definitions for Anthropic API ----

TOOL_DEFINITIONS = [
    {
        "name": "read_inbox",
        "description": "Read the user's email inbox. Returns recent emails with sender, subject, and snippet.",
        "input_schema": {
            "type": "object",
            "properties": {
                "limit": {"type": "integer", "description": "Number of emails to fetch", "default": 10},
                "unread_only": {"type": "boolean", "description": "Only show unread emails", "default": False},
            },
        },
    },
    {
        "name": "read_email",
        "description": "Read a specific email by UID. Returns full body.",
        "input_schema": {
            "type": "object",
            "properties": {
                "uid": {"type": "string", "description": "Email UID"},
            },
            "required": ["uid"],
        },
    },
    {
        "name": "save_draft",
        "description": "Save an email draft to Gmail Drafts folder. The draft appears in the user's Gmail app.",
        "input_schema": {
            "type": "object",
            "properties": {
                "to": {"type": "string", "description": "Recipient email address"},
                "subject": {"type": "string", "description": "Email subject"},
                "body": {"type": "string", "description": "Email body text"},
            },
            "required": ["to", "subject", "body"],
        },
    },
    {
        "name": "send_email",
        "description": "Send an email on behalf of the user. REQUIRES CONFIRMATION per contract.",
        "input_schema": {
            "type": "object",
            "properties": {
                "to": {"type": "string", "description": "Recipient email"},
                "subject": {"type": "string", "description": "Subject"},
                "body": {"type": "string", "description": "Body"},
            },
            "required": ["to", "subject", "body"],
        },
    },
    {
        "name": "search_vault",
        "description": "Search the user's knowledge vault for information.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
            },
            "required": ["query"],
        },
    },
    {
        "name": "create_task",
        "description": "Create a new task. Tasks always live inside a project. If no project slug is given, the task is filed in the default 'personal' project (auto-created on first use).",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Task title"},
                "brief": {"type": "string", "description": "Task description"},
                "mode": {"type": "string", "enum": ["jdi", "review", "recommend", "watch"], "default": "review"},
                "project": {"type": "string", "description": "Optional project slug to file this task under. Defaults to 'personal' if omitted."},
            },
            "required": ["title", "brief"],
        },
    },
    {
        "name": "list_tasks",
        "description": "List the user's current tasks.",
        "input_schema": {
            "type": "object",
            "properties": {
                "status": {"type": "string", "description": "Filter by status (optional)"},
            },
        },
    },
    {
        "name": "journal_write",
        "description": "Write an observation to the agent's observations file. The user's journal is user-only — never write to it. Use this to log what you noticed, actions you took, or context worth recording.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {"type": "string", "description": "The observation to record"},
            },
            "required": ["text"],
        },
    },
    {
        "name": "list_goals",
        "description": "List the user's active goals.",
        "input_schema": {
            "type": "object",
            "properties": {},
        },
    },
    {
        "name": "create_goal",
        "description": "Create a new goal for the user to track. Optionally set a success_metric and budget for autonomous pursuit.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Goal title"},
                "description": {"type": "string", "description": "What the user wants to achieve"},
                "goal_type": {"type": "string", "enum": ["goal", "aspiration", "habit"], "description": "Type of goal (default: goal)"},
                "target_date": {"type": "string", "description": "Target completion date (YYYY-MM-DD)"},
                "success_metric": {"type": "string", "description": "Measurable success criterion (e.g. '10 wiki articles published')"},
                "budget": {"type": "number", "description": "Dollar budget for autonomous agent pursuit (0 = no pursuit)"},
            },
            "required": ["title", "description"],
        },
    },
    {
        "name": "update_goal",
        "description": "Update a goal's description or target date.",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Goal slug identifier"},
                "description": {"type": "string", "description": "New description"},
                "target_date": {"type": "string", "description": "New target date (YYYY-MM-DD)"},
            },
            "required": ["slug"],
        },
    },
    {
        "name": "update_goal_status",
        "description": "Change a goal's status (active, paused, achieved, abandoned).",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Goal slug identifier"},
                "status": {"type": "string", "enum": ["active", "paused", "achieved", "abandoned"], "description": "New status"},
            },
            "required": ["slug", "status"],
        },
    },
    {
        "name": "delete_goal",
        "description": "Delete a goal and clean up project references.",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Goal slug to delete"},
            },
            "required": ["slug"],
        },
    },
    {
        "name": "update_task_status",
        "description": "Update a task's status (delegated, in_progress, blocked, review, done, cancelled).",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Task slug identifier"},
                "status": {"type": "string", "enum": ["delegated", "in_progress", "blocked", "review", "done", "cancelled"], "description": "New status"},
            },
            "required": ["slug", "status"],
        },
    },
    {
        "name": "add_task_progress",
        "description": "Add a progress note to a task.",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Task slug identifier"},
                "note": {"type": "string", "description": "Progress update note"},
            },
            "required": ["slug", "note"],
        },
    },
    {
        "name": "delete_task",
        "description": "Delete a task permanently.",
        "input_schema": {
            "type": "object",
            "properties": {
                "slug": {"type": "string", "description": "Task slug to delete"},
            },
            "required": ["slug"],
        },
    },
    {
        "name": "report_goal_progress",
        "description": "Report progress toward a goal's metric after completing work.",
        "input_schema": {
            "type": "object",
            "properties": {
                "goal_slug": {"type": "string", "description": "Goal slug"},
                "progress_note": {"type": "string", "description": "What was accomplished"},
                "metric_achieved": {"type": "boolean", "description": "True if the success metric is now fully met"},
            },
            "required": ["goal_slug", "progress_note"],
        },
    },
    {
        "name": "read_vault_file",
        "description": "Read a specific file from the vault.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Logical vault path (e.g., 'knowledge/general/wiki/Example.md', 'projects/my-proj/PROJECT.md', 'system/identity/SOUL.md')"},
            },
            "required": ["path"],
        },
    },
    {
        "name": "get_inbox_count",
        "description": "Get total and unread email count.",
        "input_schema": {
            "type": "object",
            "properties": {},
        },
    },
    {
        "name": "recall_memory",
        "description": "Search deep memory (past daily memory files) for details that aren't in the current MEMORY.md summary. Use this when the user asks about something that happened days/weeks/months ago and you need more detail than your working memory provides.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "What to search for in past memories"},
                "days_back": {"type": "integer", "description": "How many days back to search (default 30, max 365)", "default": 30},
            },
            "required": ["query"],
        },
    },
    {
        "name": "correct_memory",
        "description": "Correct or remove a fact from the agent's permanent memory (MEMORY.md). Use when the user says something is wrong, outdated, or should be forgotten.",
        "input_schema": {
            "type": "object",
            "properties": {
                "wrong_fact": {"type": "string", "description": "The incorrect or outdated fact to find"},
                "correction": {"type": "string", "description": "The correct version (empty string to remove the fact entirely)"},
            },
            "required": ["wrong_fact"],
        },
    },
    {
        "name": "query_memory_graph",
        "description": "Query the knowledge graph for structured info about entities and their relationships. Use when asking 'what do I know about X?', 'how are X and Y connected?', or exploring entity relationships.",
        "input_schema": {
            "type": "object",
            "properties": {
                "entity": {"type": "string", "description": "Entity name to query (person, company, project, concept)"},
                "related_to": {"type": "string", "description": "Optional: find the path/connection between entity and this second entity"},
                "depth": {"type": "integer", "description": "How many hops to traverse (default 2)", "default": 2},
            },
            "required": ["entity"],
        },
    },
    {
        "name": "query_knowledge",
        "description": "Unified knowledge query — searches across ALL knowledge sources automatically. Use this as your PRIMARY tool for finding information. It checks: 1) knowledge graph (structured facts), 2) vault search (full-text), 3) daily memory files (historical). Returns merged results with provenance.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "What to find — a name, topic, question, or keyword"},
                "scope": {"type": "string", "enum": ["all", "graph", "vault", "memory"], "default": "all", "description": "Limit search to specific source (default: all)"},
                "days_back": {"type": "integer", "description": "For memory search, how many days back (default 30)", "default": 30},
            },
            "required": ["query"],
        },
    },
    {
        "name": "deep_memory_recall",
        "description": "Search the memory ARCHIVES for historical context — past weekly, monthly, quarterly, and yearly summaries. Use when regular recall doesn't find enough, or when the user asks about patterns over time ('what happened in Q1?', 'what were my patterns last month?', 'how has X changed over the year?'). This searches archived summaries that are NOT in the current MEMORY.md.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "What to search for in archived summaries"},
                "layers": {
                    "type": "array",
                    "items": {"type": "string", "enum": ["week", "month", "quarter", "year"]},
                    "description": "Which archive layers to search (default: all). Use 'week' for recent detail, 'quarter'/'year' for long-term patterns.",
                },
                "period": {
                    "type": "string",
                    "description": "Optional: read a specific period directly (e.g. '2026-W15', '2026-04', '2026-Q1', '2026'). Skips search, returns full content.",
                },
                "list_archives": {
                    "type": "boolean",
                    "description": "If true, just list available archive periods per layer (no search).",
                    "default": False,
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "update_ontology",
        "description": "Add a new predicate or entity type to the knowledge graph ontology. Use after observing a pattern (3+ similar relationships falling back to 'related_to'). The user must approve the change. After writing, the ontology reloads automatically.",
        "input_schema": {
            "type": "object",
            "properties": {
                "action": {"type": "string", "enum": ["add_predicate", "get_fallbacks", "get_status"], "description": "What to do"},
                "predicate": {"type": "object", "description": "For add_predicate: {name, domain, range, inverse, cardinality, description, knowledge_type}", "properties": {
                    "name": {"type": "string"},
                    "domain": {"type": "array", "items": {"type": "string"}},
                    "range": {"type": "array", "items": {"type": "string"}, "nullable": True},
                    "inverse": {"type": "string"},
                    "cardinality": {"type": "string", "enum": ["one", "many"]},
                    "description": {"type": "string"},
                    "knowledge_type": {"type": "string", "enum": ["declarative", "episodic", "procedural", "normative", "intentional"]},
                }},
            },
            "required": ["action"],
        },
    },
    {
        "name": "delegate_to_subagent",
        "description": "Delegate a task to a specialised subagent. Use when a task matches a subagent's role (e.g. writing, analysis). The subagent runs a separate LLM call with its own role-scoped prompt and returns the result to you.",
        "input_schema": {
            "type": "object",
            "properties": {
                "agent_name": {"type": "string", "description": "Name of the subagent to dispatch to"},
                "task": {"type": "string", "description": "The task description for the subagent"},
                "context": {"type": "string", "description": "Optional context to share with the subagent", "default": ""},
            },
            "required": ["agent_name", "task"],
        },
    },
    {
        "name": "list_subagents",
        "description": "List available subagents and their roles. Use to discover which subagents can be delegated to.",
        "input_schema": {
            "type": "object",
            "properties": {},
        },
    },
    {
        "name": "list_projects",
        "description": "List the user's active projects with their status, goals, and assigned agents.",
        "input_schema": {
            "type": "object",
            "properties": {
                "status": {"type": "string", "description": "Filter by status (active, on_hold, completed, archived)"},
            },
        },
    },
    {
        "name": "create_project",
        "description": "Create a new project to organize related tasks, goals, and subagents.",
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Project title"},
                "description": {"type": "string", "description": "What this project is about"},
                "target_date": {"type": "string", "description": "Target completion date (YYYY-MM-DD)"},
                "related_goals": {"type": "array", "items": {"type": "string"}, "description": "Goal slugs to link"},
                "assigned_agents": {"type": "array", "items": {"type": "string"}, "description": "Subagent keys to assign (e.g. 'planner', 'writer', 'researcher')"},
                "linked_kbs": {"type": "array", "items": {"type": "string"}, "description": "Knowledge base slugs to link"},
            },
            "required": ["title", "description"],
        },
    },
    {
        "name": "update_project",
        "description": "Update a project's metadata: title, description, target date, linked goals, assigned agents, linked KBs.",
        "input_schema": {
            "type": "object",
            "properties": {
                "project": {"type": "string", "description": "Project slug"},
                "title": {"type": "string", "description": "New title"},
                "description": {"type": "string", "description": "New description"},
                "target_date": {"type": "string", "description": "New target date (YYYY-MM-DD)"},
                "related_goals": {"type": "array", "items": {"type": "string"}, "description": "Goal slugs to link"},
                "assigned_agents": {"type": "array", "items": {"type": "string"}, "description": "Subagent keys to assign"},
                "linked_kbs": {"type": "array", "items": {"type": "string"}, "description": "Knowledge base slugs to link"},
            },
            "required": ["project"],
        },
    },
    {
        "name": "add_project_milestone",
        "description": "Add a milestone to an existing project.",
        "input_schema": {
            "type": "object",
            "properties": {
                "project": {"type": "string", "description": "Project slug"},
                "milestone": {"type": "string", "description": "Milestone description"},
            },
            "required": ["project", "milestone"],
        },
    },
    {
        "name": "log_project_progress",
        "description": "Add a progress note to a project's log.",
        "input_schema": {
            "type": "object",
            "properties": {
                "project": {"type": "string", "description": "Project slug"},
                "note": {"type": "string", "description": "Progress note"},
            },
            "required": ["project", "note"],
        },
    },
    {
        "name": "web_search",
        "description": "Search the web using DuckDuckGo. Returns titles, URLs, and snippets. No API key needed.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
                "max_results": {"type": "integer", "description": "Max results to return (default 8)", "default": 8},
            },
            "required": ["query"],
        },
    },
    {
        "name": "research_scrape",
        "description": "Search the web for a topic, then scrape the top results into a knowledge base inbox. Autonomous: searches, filters, scrapes, saves. The inbox watcher will auto-ingest the scraped content.",
        "input_schema": {
            "type": "object",
            "properties": {
                "topic": {"type": "string", "description": "What to research — the search query/topic"},
                "kb": {"type": "string", "description": "Knowledge base slug to save scraped content into"},
                "max_pages": {
                    "type": "integer",
                    "description": (
                        "Max pages to scrape. Honour any number the user mentions "
                        "('scrape 10 articles' → 10). Omit when the user is silent; "
                        "the skill's configured default will apply. The skill also "
                        "sets a hard upper cap."
                    ),
                },
            },
            "required": ["topic", "kb"],
        },
    },
    {
        "name": "scrape_url",
        "description": "Scrape a webpage and save the content to a knowledge base inbox for ingestion. Uses agent-browser if available, falls back to httpx for HTML extraction.",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The URL to scrape"},
                "kb": {"type": "string", "description": "Knowledge base slug to save into (e.g. 'general', 'competitors'). Saved to its inbox/ folder."},
                "filename": {"type": "string", "description": "Optional filename for the saved content (without extension). Auto-generated from URL if omitted."},
            },
            "required": ["url", "kb"],
        },
    },
    {
        "name": "update_relationship",
        "description": (
            "Update a section of RELATIONSHIP.md with new insights about the user. "
            "Use this when you learn something meaningful about the user's preferences, "
            "work patterns, strengths, boundaries, or when the user corrects you. "
            "Each insight should be dated and marked with confidence level."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "section": {
                    "type": "string",
                    "description": "The section to update",
                    "enum": [
                        "Communication Style",
                        "Work Patterns",
                        "Preferences & Values",
                        "Strengths & Expertise",
                        "Growth Areas",
                        "Boundaries",
                        "Trust History",
                        "Active Needs",
                        "Corrections Log",
                        "Predictions & Accuracy",
                    ],
                },
                "insight": {
                    "type": "string",
                    "description": (
                        "The insight to add. Prefix with date [YYYY-MM-DD]. "
                        "Mark uncertain insights as (tentative). "
                        "Example: '[2026-04-12] Prefers concise bullet-point briefings (tentative)'"
                    ),
                },
                "action": {
                    "type": "string",
                    "description": "Whether to add a new insight or remove an incorrect one",
                    "enum": ["add", "remove"],
                    "default": "add",
                },
            },
            "required": ["section", "insight"],
        },
    },
    {
        "name": "create_presentation",
        "description": (
            "Create a PowerPoint (.pptx) presentation file. "
            "Pass a JSON array of slides, each with a title and bullet points (and optional speaker notes). "
            "The file is saved to the vault output directory and can be downloaded by the user."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Presentation title (used for the title slide and filename)"},
                "slides": {
                    "type": "array",
                    "description": "Array of slide objects",
                    "items": {
                        "type": "object",
                        "properties": {
                            "title": {"type": "string", "description": "Slide headline"},
                            "bullets": {
                                "type": "array",
                                "items": {"type": "string"},
                                "description": "Bullet points for this slide (max 5)",
                            },
                            "notes": {"type": "string", "description": "Speaker notes for this slide (optional)"},
                        },
                        "required": ["title"],
                    },
                },
                "subtitle": {"type": "string", "description": "Subtitle on the title slide (optional)"},
                "project": {"type": "string", "description": "Project slug — if provided, saves to project output/ dir"},
                "theme": {
                    "type": "string",
                    "description": (
                        "Visual theme (optional). Available: 'minimalist' (Swiss/Linear modernist — white canvas, "
                        "single blue accent, generous whitespace), 'data-dense' (consulting / action-title band, "
                        "navy header + data body + footer), 'default' / omit for plain unthemed rendering. "
                        "Pick the theme that matches the skill in use; do not invent names."
                    ),
                },
                "show_on_canvas": {
                    "type": "boolean",
                    "description": (
                        "If true, push one themed HTML card per slide to the live Canvas alongside "
                        "saving the .pptx file. Uses the same theme colors/fonts as the pptx so the "
                        "canvas preview matches the deck. Default false."
                    ),
                    "default": False,
                },
            },
            "required": ["title", "slides"],
        },
    },
    {
        "name": "create_video",
        "description": (
            "Produce a narrated .mp4 video from any vault artifact or URL. "
            "Drives a headless browser through the live app, generates voiceover narration, "
            "and composes the final file. Returns a job_id immediately — poll /api/video/jobs/{id} "
            "for progress. Requires the [video] extra: pip install 'agntspace[video]'."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "target": {
                    "type": "string",
                    "description": (
                        "What to make a video about. One of: 'wiki:<slug>' (a wiki article), "
                        "'kb:<slug>' (a whole knowledge base), 'project:<slug>' (a project page), "
                        "'journal:YYYY-MM-DD' (a journal day), or 'url:https://...' (any web page)."
                    ),
                },
                "duration_seconds": {
                    "type": "integer",
                    "description": "Target duration in seconds. Default 60. Capped at 180.",
                    "default": 60,
                },
                "voice": {
                    "type": "string",
                    "description": "Voice key from styles.yaml. Default 'liam' (Canadian English male). Others: ava, guy, clara, ryan, sonia.",
                    "default": "liam",
                },
                "style": {
                    "type": "string",
                    "description": "One of 'walkthrough' (default, mid-pace conversational), 'tutorial' (slower, didactic), 'promo' (faster, punchier).",
                    "enum": ["walkthrough", "tutorial", "promo"],
                    "default": "walkthrough",
                },
            },
            "required": ["target"],
        },
    },
    {
        "name": "ingest_output",
        "description": (
            "Ingest an agent-generated output file into a knowledge base. "
            "Copies the file (or its companion .md summary) into the KB inbox "
            "so the watcher auto-ingests it into the wiki. Use this when the user "
            "asks to add a generated presentation, report, or document to a KB."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "file": {
                    "type": "string",
                    "description": "Path to the output file (relative to vault root, e.g. 'agntspace-projects/personal/output/my-deck.pptx')",
                },
                "kb": {"type": "string", "description": "Knowledge base slug to ingest into (e.g. 'general')"},
            },
            "required": ["file", "kb"],
        },
    },
    {
        "name": "whatsapp_answering_mode",
        "description": (
            "Toggle WhatsApp answering mode. "
            "When enabled, the agent answers messages from other people on WhatsApp. "
            "When disabled, only the user's self-chat messages are processed. "
            "Use this when the user asks you to start or stop answering WhatsApp messages."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "enabled": {
                    "type": "boolean",
                    "description": "True to start answering other people, False to stop",
                },
            },
            "required": ["enabled"],
        },
    },
    # ── Finance subsystem (Phase 1) ──────────────────────────────────
    # Receipt + invoice ingestion, ledger CRUD, merchant resolution.
    # Engine sits in agntspace.finance.{ingest,ledger,merchants,storage}.
    {
        "name": "ingest_receipt",
        "description": (
            "Process a single receipt or invoice file. Reads the file, runs vision/OCR + "
            "structured extraction, resolves the merchant, and writes a TransactionDraft to "
            "agntspace-finance/pending/ for user approval. NEVER commits to the verified ledger — "
            "approval happens at /finance via the user. Returns {draft_id, audit_flags, item_count, total, currency}."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Logical or physical path to the receipt file (.jpg, .png, .heic, .webp, .pdf)",
                },
            },
            "required": ["path"],
        },
    },
    {
        "name": "commit_transaction",
        "description": (
            "Promote a draft to the verified ledger. Pulls the draft from pending/, marks it "
            "verified, decrements any matching budgets (Phase 2), emits SPO triples, archives "
            "the draft markdown to transactions/YYYY-MM/. REQUIRES CONFIRMATION per contract."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "draft_id": {
                    "type": "string",
                    "description": "Draft ID returned from ingest_receipt or list_transactions(status='draft')",
                },
            },
            "required": ["draft_id"],
        },
    },
    {
        "name": "list_transactions",
        "description": (
            "Read-only ledger query. Filter by date range, merchant, project, category, "
            "currency, or status. Default excludes archived + rejected. Newest-first."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "since": {"type": "string", "description": "ISO YYYY-MM-DD lower bound (inclusive)"},
                "until": {"type": "string", "description": "ISO YYYY-MM-DD upper bound (inclusive)"},
                "merchant_slug": {"type": "string", "description": "Filter by canonical merchant slug"},
                "project_slug": {"type": "string", "description": "Filter by project (matches transaction-level OR any line item)"},
                "category_slug": {"type": "string", "description": "Filter by line-item category"},
                "currency": {"type": "string", "description": "ISO 4217 code, case-insensitive"},
                "status": {
                    "type": "string",
                    "description": "draft | verified | rejected | superseded | archived. Empty default excludes archived/rejected.",
                },
                "limit": {"type": "integer", "default": 50},
                "offset": {"type": "integer", "default": 0},
            },
        },
    },
    {
        "name": "update_transaction",
        "description": (
            "Update a transaction. Two surfaces: (a) transaction-level fields (project_slug, notes), "
            "(b) per-line-item fields via the items array (category_slug, project_slug, tax_deductible, notes). "
            "Used by the categorize and attribute skills + manual user corrections. REQUIRES CONFIRMATION."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "txn_id": {
                    "type": "string",
                    "description": "Transaction or draft ID",
                },
                "project_slug": {
                    "type": "string",
                    "description": "New transaction-level project assignment (empty string clears).",
                },
                "items": {
                    "type": "array",
                    "description": "Per-line-item updates. Each entry must include item_id; other fields are partial updates.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "item_id": {"type": "string"},
                            "category_slug": {"type": "string"},
                            "project_slug": {"type": "string"},
                            "tax_deductible": {"type": "boolean"},
                            "notes": {"type": "string"},
                        },
                        "required": ["item_id"],
                    },
                },
            },
            "required": ["txn_id"],
        },
    },
    {
        "name": "delete_transaction",
        "description": (
            "Soft-delete (archive) a transaction. Reversible via update_transaction with status=verified. "
            "REQUIRES CONFIRMATION per contract."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "txn_id": {"type": "string", "description": "Transaction ID to archive"},
            },
            "required": ["txn_id"],
        },
    },
    {
        "name": "query_finance",
        "description": (
            "Read-only fetch of one draft or transaction by ID. Use list_transactions for queries "
            "across many records."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "txn_id": {"type": "string", "description": "Transaction or draft ID"},
                "draft_id": {"type": "string", "description": "Alias for txn_id (drafts share the ID space)"},
            },
        },
    },
    {
        "name": "resolve_merchant",
        "description": (
            "Look up the canonical merchant slug for a raw merchant string. Returns null when no "
            "alias matches — the caller (bookkeeper) decides whether to propose a new merchant for "
            "user approval. Cross-language aliases (CJK / Cyrillic) resolve to the Latin slug."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "raw_text": {"type": "string", "description": "Merchant string as printed on the receipt"},
            },
            "required": ["raw_text"],
        },
    },
    {
        "name": "list_merchants",
        "description": "List canonical merchants newest-seen first.",
        "input_schema": {
            "type": "object",
            "properties": {
                "limit": {"type": "integer", "default": 50},
                "offset": {"type": "integer", "default": 0},
            },
        },
    },
]

# Map tool names to contract actions
TOOL_CONTRACT_MAP = {
    "read_inbox": "read_inbox",
    "read_email": "read_inbox",
    "save_draft": "draft_content",
    "send_email": "send_email",
    "search_vault": "search",
    "create_task": "create_task",
    "list_tasks": "list_tasks",
    "journal_write": "journal_write",
    "list_goals": "list_goals",
    "create_goal": "create_task",
    "update_goal": "create_task",
    "update_goal_status": "create_task",
    "delete_goal": "create_task",
    "update_task_status": "create_task",
    "add_task_progress": "create_task",
    "delete_task": "create_task",
    "report_goal_progress": "create_task",
    "read_vault_file": "read_vault",
    "get_inbox_count": "read_inbox",
    "recall_memory": "read_vault",
    "correct_memory": "write_vault",
    "query_memory_graph": "read_vault",
    "deep_memory_recall": "read_vault",
    "query_knowledge": "search",
    "update_ontology": "write_vault",
    # Graph curator (entity dedup auto-apply) — list is read-only, apply
    # is gated by auto_merge_entity. See _meta/graph-curator/SKILL.md.
    "list_merge_proposals": "search",
    "apply_merge_proposal": "auto_merge_entity",
    "delegate_to_subagent": "delegate_task",
    "list_subagents": "list_tasks",
    "list_projects": "list_tasks",
    "create_project": "create_task",
    "update_project": "modify_project",
    "add_project_milestone": "modify_project",
    "log_project_progress": "modify_project",
    "web_search": "search",
    "research_scrape": "write_vault",
    "scrape_url": "write_vault",
    "create_kb": "create_task",
    "whatsapp_answering_mode": "manage_channels",
    # Canvas (A2UI) tools
    "canvas_push": "canvas_push",
    "canvas_update": "canvas_push",
    "canvas_remove": "canvas_push",
    "canvas_reset": "canvas_push",
    "canvas_snapshot": "canvas_push",
    # Presentation + output ingestion
    "create_presentation": "write_vault",
    "generate_kb_presentation": "write_vault",
    "update_presentation": "write_vault",
    "research_and_present": "write_vault",
    "ingest_output": "write_vault",
    "create_video": "create_video",
    # Agent healing
    "heal_agents": "delegate_task",
    # Relationship
    "update_relationship": "store_user_insights",
    # Finance subsystem (Phase 1) — read paths via query_finance,
    # mutating writes via financial_entry, deletes via delete_transaction
    # (which the contract gates separately so soft-delete is auditable).
    "ingest_receipt": "ingest_receipt",
    "commit_transaction": "financial_entry",
    "list_transactions": "query_finance",
    "update_transaction": "financial_entry",
    "delete_transaction": "delete_transaction",
    "query_finance": "query_finance",
    "resolve_merchant": "query_finance",
    "list_merchants": "query_finance",
}


# Module-level main-loop reference. Wired by main.py at startup so
# _run_async can route coroutines back to the SAME loop that owns
# aiosqlite Connections, vault writers, and other loop-bound services.
#
# Without this, _run_async spawned a new event loop in a worker thread
# and called `await self._db.execute(...)` on it. aiosqlite's connection
# captures the loop where it was opened, then `call_soon_threadsafe`s
# the future result back to THAT loop — but the future was created in
# the new worker loop, so the result never lands. The await hangs for
# 30s, the worker thread sits in the default ThreadPoolExecutor, and
# the SAME ThreadPoolExecutor is shared by every `loop.run_in_executor`
# call the main loop makes (middleware, agent dispatch, settings reads).
# A few stuck _run_async calls saturate the pool and EVERY endpoint
# queues — that's the "/api/health takes 5 minutes" symptom.
_MAIN_LOOP: asyncio.AbstractEventLoop | None = None


def set_main_loop(loop) -> None:  # noqa: ANN001
    """Register the main asyncio loop. Call once from lifespan startup."""
    global _MAIN_LOOP
    _MAIN_LOOP = loop


def _run_async(coro, *, timeout: float = 30.0):  # noqa: ANN001, ANN202
    """Run an async coroutine from sync code without breaking the main loop.

    Resolution order:
      1. If we have a registered main loop AND we're NOT on it (e.g. a
         worker thread inside loop.run_in_executor), use
         ``asyncio.run_coroutine_threadsafe(coro, _MAIN_LOOP).result(...)``.
         This runs the coroutine on the main loop where its dependencies
         (aiosqlite, services, contextvars) live, blocking the calling
         thread (acceptable — it's a worker thread, not the main loop).
      2. If we're already ON the main loop somehow (synchronous code
         called from middleware/route), spawn a fresh isolated loop in
         a fresh thread pool. Bug-for-bug compat with the old behavior;
         these call sites should be migrated to async_handlers.
      3. No loop running anywhere → simple asyncio.run().
    """
    import asyncio
    import concurrent.futures

    try:
        running = asyncio.get_running_loop()
    except RuntimeError:
        running = None

    # Path 1: bridge from worker thread back to the main loop.
    if _MAIN_LOOP is not None and _MAIN_LOOP.is_running() and running is not _MAIN_LOOP:
        try:
            return asyncio.run_coroutine_threadsafe(coro, _MAIN_LOOP).result(timeout=timeout)
        except Exception:
            # Fall through to Path 2 / 3 below if the bridge fails for any reason.
            pass

    # Path 2: caller is on a running loop (legacy sync-from-async path).
    if running is not None and running.is_running():
        with concurrent.futures.ThreadPoolExecutor() as pool:
            return pool.submit(asyncio.run, coro).result(timeout=timeout)

    # Path 3: no loop at all — just run.
    return asyncio.run(coro)


class ToolExecutor:
    """Executes agent tool calls with contract enforcement."""

    def __init__(
        self,
        contract: ContractEnforcer,
        email: EmailIntegration | None = None,
        vault_reader: VaultReader | None = None,
        vault_writer: VaultWriter | None = None,
        vault_search: VaultSearch | None = None,
        task_service: TaskService | None = None,
        journal: JournalService | None = None,
        coach: CoachService | None = None,
        memory_engine: MemoryEngine | None = None,
        orchestrator: Orchestrator | None = None,
        project_service: ProjectService | None = None,
        knowledge_graph: KnowledgeGraph | None = None,
        vault_embeddings: object | None = None,
        channel_manager: object | None = None,
        kb_service: object | None = None,
        finance_ledger: object | None = None,
        merchant_resolver: object | None = None,
        receipt_ingest: object | None = None,
    ) -> None:
        self._contract = contract
        self._email = email
        self._vault = vault_reader
        self._knowledge_graph = knowledge_graph
        self._writer = vault_writer
        self._search = vault_search
        self._tasks = task_service
        self._journal = journal
        self._coach = coach
        self._memory_engine = memory_engine
        self._orchestrator = orchestrator
        self._projects = project_service
        self._embeddings = vault_embeddings
        self._channel_manager = channel_manager
        self._kb = kb_service
        # Finance services — Phase 1. None when finance feature not yet
        # used in this vault (lazy creation per the plan). Tool handlers
        # check for None and return a clear "feature not enabled" error
        # instead of NoneType crashes.
        self._finance_ledger = finance_ledger
        self._merchants = merchant_resolver
        self._ingest = receipt_ingest
        # Skill loader is wired post-init by main.py so ToolExecutor can read
        # skill YAML for strategy (e.g. research-scrape quality + relevance
        # gates) without smuggling thresholds into Python (Rule #12).
        self._skill_loader: object | None = None
        # Canvas service + main event loop — wired post-init. Used by
        # _exec_create_presentation when show_on_canvas=True to push one
        # themed HTML card per slide to the live canvas. Both must be set
        # for pushes to happen; missing either → pptx is still written,
        # canvas pushes are skipped with a non-fatal status in the result.
        self._canvas: object | None = None
        self._main_loop: object | None = None

    def populate_registry(self, registry: object) -> None:
        """Register all tools and integrations into a ToolRegistry.

        This is the bridge from the old hardcoded approach to the new
        registry pattern. New integrations should register directly.
        """
        from agntspace.agent.registry import ToolRegistry

        if not isinstance(registry, ToolRegistry):
            return

        # Register integrations
        if self._email:
            registry.register_integration(
                "email", "Email (Gmail)",
                "Read inbox, read emails, save drafts, send emails via Gmail IMAP/SMTP.",
                lambda: self._email.is_connected if self._email else False,
            )
        if self._search:
            registry.register_integration(
                "vault", "Vault",
                "Search and read files from the knowledge vault.",
                lambda: True,
            )
        registry.register_integration(
            "tasks", "Tasks & Goals",
            "Create and list tasks, list goals, delegate work.",
            lambda: bool(self._tasks),
        )
        registry.register_integration(
            "journal", "Journal",
            "Write thoughts, decisions, and learnings to the user's journal.",
            lambda: bool(self._journal),
        )
        if self._memory_engine:
            registry.register_integration(
                "memory", "Memory",
                "Search past memories, query the knowledge graph for entity relationships and structured facts.",
                lambda: True,
            )
        if self._orchestrator:
            registry.register_integration(
                "orchestrator", "Subagents",
                "Delegate tasks to specialised subagents with role-scoped prompts.",
                lambda: bool(self._orchestrator and self._orchestrator.agents),
            )
        if self._projects:
            registry.register_integration(
                "projects", "Projects",
                "Organise work into projects with milestones, linked goals, assigned subagents.",
                lambda: True,
            )
        if self._channel_manager:
            registry.register_integration(
                "whatsapp", "WhatsApp",
                "Toggle WhatsApp answering mode — start/stop responding to other people's messages.",
                lambda: bool(self._channel_manager and self._channel_manager.get_channel("whatsapp")),
            )
        if self._finance_ledger or self._merchants or self._ingest:
            registry.register_integration(
                "finance", "Finance",
                "Receipt + invoice ingestion, ledger CRUD, merchant resolution, budget tracking.",
                lambda: bool(self._finance_ledger),
            )

        # Set of finance tool names — used to attach async_handler= for
        # the direct-await fast path (skip thread pool, natural contextvar
        # inheritance per session-78 lesson).
        _FINANCE_TOOLS = {  # arch:deterministic — registry routing label
            "ingest_receipt", "commit_transaction", "list_transactions",
            "update_transaction", "delete_transaction", "query_finance",
            "resolve_merchant", "list_merchants",
        }

        # Register all tools from TOOL_DEFINITIONS
        for tool_def in TOOL_DEFINITIONS:
            name = tool_def["name"]
            handler = getattr(self, f"_exec_{name}", None)
            if not handler:
                continue

            # Determine integration from contract map
            contract_action = TOOL_CONTRACT_MAP.get(name)
            integration = None
            if name in ("read_inbox", "read_email", "save_draft", "send_email", "get_inbox_count"):
                integration = "email"
            elif name in ("search_vault", "read_vault_file"):
                integration = "vault"
            elif name in ("create_task", "list_tasks", "list_goals",
                         "create_goal", "update_goal", "update_goal_status",
                         "delete_goal", "update_task_status", "add_task_progress",
                         "delete_task", "report_goal_progress"):
                integration = "tasks"
            elif name == "journal_write":
                integration = "journal"
            elif name in ("scrape_url", "research_scrape", "web_search", "create_presentation", "ingest_output"):
                integration = "vault"
            elif name in ("recall_memory", "correct_memory", "query_memory_graph", "query_knowledge", "update_ontology", "deep_memory_recall"):
                integration = "memory"
            elif name in ("delegate_to_subagent", "list_subagents"):
                integration = "orchestrator"
            elif name in ("list_projects", "create_project", "update_project", "add_project_milestone", "log_project_progress"):
                integration = "projects"
            elif name == "whatsapp_answering_mode":
                integration = "whatsapp"
            elif name in _FINANCE_TOOLS:
                integration = "finance"

            # Pair sync wrappers that bridge to async with an async_handler
            # so the unified dispatcher (Agent._dispatch_tool) prefers
            # direct await — natural contextvar inheritance, no thread-pool
            # round-trip. Convention: `_async_<name>` method on this class.
            async_handler = getattr(self, f"_async_{name}", None)

            registry.register_tool(
                name=name,
                description=tool_def["description"],
                input_schema=tool_def["input_schema"],
                handler=handler,
                async_handler=async_handler,
                integration=integration,
                contract_action=contract_action,
            )

        registry.set_contract(self._contract)
        log.debug("Registry populated: %d tools, %d integrations",
                 len(registry.list_tools()), len(registry.list_integrations()))

    def __init_confirmed(self) -> None:
        if not hasattr(self, "_confirmed_tools"):
            self._confirmed_tools: set[str] = set()

    def confirm_tool(self, tool_name: str) -> None:
        """Mark a tool as confirmed for this session (user said yes)."""
        self.__init_confirmed()
        self._confirmed_tools.add(tool_name)

    def execute(self, tool_name: str, tool_input: dict) -> dict[str, Any]:
        """Execute a tool call. Returns result dict.

        Checks the contract first. If blocked, returns error.
        If confirmation required, returns confirmation request.
        On second call (after needs_confirmation), auto-confirms.
        """
        self.__init_confirmed()

        # Contract check
        contract_action = TOOL_CONTRACT_MAP.get(tool_name)
        if contract_action:
            result = self._contract.check(contract_action)
            if not result.allowed:
                return {"error": f"Contract blocked: {result.reason}"}
            if result.requires_confirmation and tool_name not in self._confirmed_tools:
                # First time: ask for confirmation. Mark as pending so next call proceeds.
                self._confirmed_tools.add(tool_name)
                return {
                    "needs_confirmation": True,
                    "action": tool_name,
                    "reason": result.reason,
                }

        # Dispatch
        handler = getattr(self, f"_exec_{tool_name}", None)
        if not handler:
            return {"error": f"Unknown tool: {tool_name}"}

        try:
            return handler(tool_input)
        except Exception as e:
            log.exception("Tool execution failed: %s", tool_name)
            return {"error": str(e)}

    # ---- KB slug inference ----

    def _infer_kb_slug(self) -> str:
        """Infer the best KB slug when the user didn't specify one.

        Resolution order:
        1. If only one KB exists (besides 'general'), use it
        2. If a non-general KB was updated most recently, use it
        3. Fall back to 'general'
        """
        if not self._kb:
            return "general"
        try:
            kbs = self._kb.list_knowledge_bases()
            if not kbs:
                return "general"
            # If only one KB, use it
            if len(kbs) == 1:
                return kbs[0].get("slug", "general")
            # Prefer non-general KBs, sorted by most recently updated
            non_general = [k for k in kbs if k.get("slug") != "general"]
            if len(non_general) == 1:
                return non_general[0]["slug"]
            if non_general:
                # Most recently updated non-general KB
                non_general.sort(key=lambda k: k.get("updated", ""), reverse=True)
                return non_general[0]["slug"]
            return "general"
        except Exception:
            return "general"

    # ---- Tool implementations ----

    def _exec_read_inbox(self, inp: dict) -> dict:
        if not self._email or not self._email.is_connected:
            return {"error": "Email not connected. Set up in Settings."}
        emails = self._email.list_emails(
            limit=inp.get("limit", 10),
            unread_only=inp.get("unread_only", False),
        )
        items = [
            {"uid": e.uid, "sender": e.sender, "subject": e.subject, "snippet": e.snippet, "is_read": e.is_read}
            for e in emails
        ]
        unread = sum(1 for e in items if not e["is_read"])
        return {
            "count": len(items),
            "unread_count": unread,
            "summary": f"{len(items)} email(s) found, {unread} unread.",
            "emails": items,
        }

    def _exec_read_email(self, inp: dict) -> dict:
        if not self._email or not self._email.is_connected:
            return {"error": "Email not connected."}
        msg = self._email.read_email(inp["uid"])
        if not msg:
            return {"error": "Email not found."}
        return {"uid": msg.uid, "sender": msg.sender, "subject": msg.subject, "body": msg.body}

    def _exec_save_draft(self, inp: dict) -> dict:
        if not self._email or not self._email.is_connected:
            return {"error": "Email not connected."}
        ok = self._email.save_draft(to=inp["to"], subject=inp["subject"], body=inp["body"])
        return {"saved": ok, "to": inp["to"], "subject": inp["subject"]} if ok else {"error": "Failed to save draft."}

    def _exec_send_email(self, inp: dict) -> dict:
        if not self._email or not self._email.is_connected:
            return {"error": "Email not connected."}
        ok = self._email.send_email(to=inp["to"], subject=inp["subject"], body=inp["body"])
        return {"sent": ok} if ok else {"error": "Failed to send."}

    def _exec_search_vault(self, inp: dict) -> dict:
        if not self._search:
            return {"error": "Vault search not available."}
        results = _run_async(self._search.search(inp["query"], limit=inp.get("limit", 5)))
        return {"results": results}

    def _exec_create_task(self, inp: dict) -> dict:
        if not self._tasks:
            return {"error": "Task service not available."}
        try:
            path = self._tasks.create_task(
                title=inp["title"],
                brief=inp["brief"],
                mode=inp.get("mode", "review"),
                project=inp.get("project"),
            )
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"created": True, "path": path}

    def _exec_list_tasks(self, inp: dict) -> dict:
        if not self._tasks:
            return {"error": "Task service not available."}
        tasks = self._tasks.list_tasks(status_filter=inp.get("status"))
        return {"tasks": tasks}

    def _exec_journal_write(self, inp: dict) -> dict:
        if not self._journal:
            return {"error": "Journal not available."}
        self._journal.append_observation(inp["text"])
        return {"written": True, "section": "Observations"}

    def _exec_list_goals(self, _inp: dict) -> dict:
        if not self._coach:
            return {"error": "Coach not available."}
        return {"goals": self._coach.list_goals(status_filter="active")}

    def _exec_create_goal(self, inp: dict) -> dict:
        if not self._coach:
            return {"error": "Coach not available."}
        path = self._coach.create_goal(
            title=inp["title"],
            description=inp["description"],
            goal_type=inp.get("goal_type", "goal"),
            target_date=inp.get("target_date", ""),
            success_metric=inp.get("success_metric", ""),
            budget=float(inp.get("budget", 0)),
        )
        return {"created": True, "path": path}

    def _exec_update_goal(self, inp: dict) -> dict:
        if not self._coach:
            return {"error": "Coach not available."}
        try:
            self._coach.update_goal(
                slug=inp["slug"],
                description=inp.get("description"),
                target_date=inp.get("target_date"),
            )
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"updated": True, "slug": inp["slug"]}

    def _exec_update_goal_status(self, inp: dict) -> dict:
        if not self._coach:
            return {"error": "Coach not available."}
        try:
            self._coach.update_goal_status(inp["slug"], inp["status"])
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"updated": True, "slug": inp["slug"], "status": inp["status"]}

    def _exec_delete_goal(self, inp: dict) -> dict:
        if not self._coach:
            return {"error": "Coach not available."}
        try:
            result = self._coach.delete_goal(inp["slug"])
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"deleted": True, **result}

    def _exec_update_task_status(self, inp: dict) -> dict:
        if not self._tasks:
            return {"error": "Task service not available."}
        try:
            self._tasks.update_status(inp["slug"], inp["status"], inp.get("project"))
        except (FileNotFoundError, ValueError) as exc:
            return {"error": str(exc)}
        return {"updated": True, "slug": inp["slug"], "status": inp["status"]}

    def _exec_add_task_progress(self, inp: dict) -> dict:
        if not self._tasks:
            return {"error": "Task service not available."}
        try:
            self._tasks.add_progress(inp["slug"], inp["note"], inp.get("project"))
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"added": True, "slug": inp["slug"]}

    def _exec_delete_task(self, inp: dict) -> dict:
        if not self._tasks:
            return {"error": "Task service not available."}
        try:
            project = self._tasks.delete_task(inp["slug"])
        except FileNotFoundError as exc:
            return {"error": str(exc)}
        return {"deleted": True, "slug": inp["slug"], "project": project}

    def _exec_report_goal_progress(self, inp: dict) -> dict:
        """Tool handler for the agent's end-of-dispatch progress note.

        Gap 1 fix (Ship 4): this handler USED to call
        ``record_pursuit_attempt`` with cost=0.0, which double-counted
        every dispatch because ``_wq_pursue_goal`` ALSO records the
        attempt at dispatch completion — with richer fields (actual
        cost, outcome classification, hypothesis/action/observation).
        The WQ handler is the single source of truth for attempt
        recording now. This tool still records the ``completed``
        status transition when the agent self-reports ``metric_achieved``
        (legitimate state change, not double counting).
        """
        if not self._coach:
            return {"error": "Coach not available."}
        slug = inp["goal_slug"]
        note = inp["progress_note"]
        achieved = inp.get("metric_achieved", False)
        if achieved:
            try:
                self._coach.set_pursuit_status(slug, "completed")
            except FileNotFoundError as exc:
                return {"error": str(exc)}
        return {"recorded": True, "goal_slug": slug, "metric_achieved": achieved, "note": note}

    def _exec_read_vault_file(self, inp: dict) -> dict:
        if not self._vault:
            return {"error": "Vault not available."}
        try:
            doc = self._vault.read(inp["path"])
            return {"path": inp["path"], "content": doc.content[:2000]}
        except FileNotFoundError:
            return {"error": f"File not found: {inp['path']}"}

    def _exec_get_inbox_count(self, _inp: dict) -> dict:
        if not self._email or not self._email.is_connected:
            return {"error": "Email not connected."}
        counts = self._email.get_inbox_count()
        counts["summary"] = f"{counts.get('total', 0)} total emails, {counts.get('unread', 0)} unread."
        return counts

    def _exec_recall_memory(self, inp: dict) -> dict:
        """Search memory using knowledge graph + daily memory files.

        Two-layer recall:
        1. Knowledge graph — fast, structured, authority-ranked
        2. Daily memory files — brute-force scan for details not in graph
        """
        if not self._memory_engine:
            return {"error": "Memory engine not available."}

        from datetime import UTC, datetime, timedelta

        query = inp["query"]
        days_back = min(inp.get("days_back", 30), 365)
        result: dict[str, Any] = {"found": False}

        # Layer 1: Knowledge graph query
        if self._knowledge_graph:
            graph_result = self._knowledge_graph.query_entity(query)
            if graph_result:
                result["found"] = True
                result["graph"] = graph_result

        # Layer 2: Daily file scan (fallback / supplement)
        query_lower = query.lower()
        now = datetime.now(UTC)
        file_matches = []

        for days_ago in range(days_back):
            d = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
            content = self._memory_engine.read_daily_memory(d)
            if content and query_lower in content.lower():
                idx = content.lower().index(query_lower)
                start = max(0, idx - 200)
                end = min(len(content), idx + 200)
                excerpt = content[start:end].strip()
                file_matches.append({"date": d, "excerpt": f"...{excerpt}..."})

            if len(file_matches) >= 5:
                break

        if file_matches:
            result["found"] = True
            result["daily_files"] = file_matches

        if not result["found"]:
            result["message"] = f"No memories matching '{query}' in the last {days_back} days."

        return result

    def _exec_deep_memory_recall(self, inp: dict) -> dict:
        """Search memory archives for historical context across time periods."""
        if not self._memory_engine:
            return {"error": "Memory engine not available."}

        query = inp.get("query", "")
        layers = inp.get("layers")
        period = inp.get("period")
        list_archives = inp.get("list_archives", False)

        # Mode 1: List available archives
        if list_archives:
            inventory: dict[str, list] = {}
            for layer in ("week", "month", "quarter", "year"):
                archives = self._memory_engine.list_archives(layer)
                inventory[layer] = [a["period"] for a in archives]
            return {"archives": inventory}

        # Mode 2: Read a specific period directly
        if period:
            # Detect which layer from the period format
            layer = None
            if "-W" in period:
                layer = "week"
            elif "-Q" in period:
                layer = "quarter"
            elif len(period) == 4 and period.isdigit():
                layer = "year"
            elif len(period) == 7:  # YYYY-MM
                layer = "month"

            if not layer:
                return {"error": f"Cannot determine layer from period '{period}'. Use formats: 2026-W15, 2026-04, 2026-Q1, 2026"}

            result = self._memory_engine.read_archive(layer, period)
            if result.get("exists"):
                return {"found": True, "layer": layer, "period": period, "content": result["content"]}
            return {"found": False, "message": f"No archive found for {layer}/{period}"}

        # Mode 3: Search across archives
        matches = self._memory_engine.search_archives(query, layers=layers)
        if matches:
            return {"found": True, "results": matches, "count": len(matches)}
        return {"found": False, "message": f"No matches for '{query}' in memory archives."}

    def _exec_query_memory_graph(self, inp: dict) -> dict:
        """Query the knowledge graph for entity info and relationships."""
        if not self._knowledge_graph:
            return {"error": "Knowledge graph not available."}

        entity = inp["entity"]
        related_to = inp.get("related_to")

        if related_to:
            # Path query: how are these two entities connected?
            path = self._knowledge_graph.query_path(entity, related_to)
            if path is None:
                return {"found": False, "message": f"No connection found between '{entity}' and '{related_to}'."}
            return {"found": True, "path": path, "from": entity, "to": related_to}

        # Entity query: what do we know about this entity?
        result = self._knowledge_graph.query_entity(entity, depth=inp.get("depth", 2))
        if result is None:
            # Try search instead
            matches = self._knowledge_graph.search_entities(entity)
            if matches:
                return {"found": False, "message": f"Entity '{entity}' not found exactly, but similar entities exist.", "suggestions": matches[:5]}
            return {"found": False, "message": f"Entity '{entity}' not found in the knowledge graph."}

        return {"found": True, **result}

    def _exec_query_knowledge(self, inp: dict) -> dict:
        """Unified knowledge query — searches all sources, merges results.

        Priority order:
        1. Knowledge graph (structured facts, relationships)
        2. Vault search (full-text indexed search)
        3. Daily memory files (historical details)

        Returns merged results with source attribution.
        """
        from datetime import UTC, datetime, timedelta

        query = inp["query"]
        scope = inp.get("scope", "all")
        days_back = min(inp.get("days_back", 30), 365)

        results: dict[str, Any] = {"query": query, "found": False, "sources": []}

        # Layer 1: Knowledge graph
        if scope in ("all", "graph") and self._knowledge_graph:
            graph_result = self._knowledge_graph.query_entity(query)
            if graph_result:
                results["found"] = True
                results["graph"] = graph_result
                results["sources"].append("knowledge_graph")
            else:
                # Try search for partial matches
                matches = self._knowledge_graph.search_entities(query)
                if matches:
                    results["found"] = True
                    results["graph_matches"] = matches[:5]
                    results["sources"].append("knowledge_graph")

        # Layer 2: Vault search (FTS5 keyword)
        if scope in ("all", "vault") and self._search:
            try:
                vault_results = _run_async(self._search.search(query, limit=5))
                if vault_results:
                    results["found"] = True
                    results["vault"] = vault_results
                    results["sources"].append("vault_search")
            except Exception:
                pass

        # Layer 2.5: Semantic search (embeddings — finds conceptually similar content)
        if scope in ("all", "vault") and self._embeddings:
            try:
                semantic_results = _run_async(self._embeddings.search(query, limit=5))
                if semantic_results:
                    # Filter to high-similarity results only
                    good = [r for r in semantic_results if r.get("score", 0) > 0.5]
                    if good:
                        results["found"] = True
                        results["semantic"] = good
                        results["sources"].append("semantic_search")
            except Exception:
                pass

        # Layer 3: Daily memory files
        if scope in ("all", "memory") and self._memory_engine:
            query_lower = query.lower()
            now = datetime.now(UTC)
            file_matches = []

            for days_ago in range(days_back):
                d = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
                content = self._memory_engine.read_daily_memory(d)
                if content and query_lower in content.lower():
                    idx = content.lower().index(query_lower)
                    start = max(0, idx - 200)
                    end = min(len(content), idx + 200)
                    excerpt = content[start:end].strip()
                    file_matches.append({"date": d, "excerpt": f"...{excerpt}..."})
                if len(file_matches) >= 5:
                    break

            if file_matches:
                results["found"] = True
                results["memory_files"] = file_matches
                results["sources"].append("daily_memory")

        if not results["found"]:
            results["message"] = f"No results for '{query}' across any knowledge source."

        return results

    def _exec_update_ontology(self, inp: dict) -> dict:
        """Manage the knowledge graph ontology — add predicates, check fallbacks."""
        from agntspace.memory.ontology import (
            get_fallback_report,
            get_loaded_from,
            reload_ontology,
        )

        action = inp["action"]

        if action == "get_fallbacks":
            report = get_fallback_report(min_count=1)
            return {"fallbacks": report, "count": len(report)}

        if action == "get_status":
            from agntspace.memory.ontology import INFERENCE_RULES, PREDICATES
            return {
                "loaded_from": get_loaded_from(),
                "predicate_count": len(PREDICATES),
                "inference_rules": len(INFERENCE_RULES),
                "predicates": sorted(PREDICATES.keys()),
                "fallbacks": get_fallback_report(min_count=1),
            }

        if action == "add_predicate":
            pred = inp.get("predicate")
            if not pred or not pred.get("name"):
                return {"error": "Predicate definition required with at least 'name'"}

            if not self._vault or not self._writer:
                return {"error": "Vault not available"}

            # Read current ontology YAML
            yaml_path = "system/skills/_meta/ontology/config/ontology.yaml"
            try:
                import yaml
            except ImportError:
                return {"error": "PyYAML not installed"}

            try:
                if self._vault.exists(yaml_path):
                    raw = (self._vault.vault_dir / yaml_path).read_text(encoding="utf-8")
                    data = yaml.safe_load(raw)
                else:
                    return {"error": f"Ontology YAML not found at {yaml_path}"}
            except Exception as e:
                return {"error": f"Failed to read ontology YAML: {e}"}

            # Check for duplicate
            existing = [p["name"] for p in data.get("predicates", [])]
            if pred["name"] in existing:
                return {"error": f"Predicate '{pred['name']}' already exists"}

            # Build the new predicate entry
            new_pred: dict[str, Any] = {"name": pred["name"]}
            if pred.get("domain"):
                new_pred["domain"] = pred["domain"]
            if pred.get("range"):
                new_pred["range"] = pred["range"]
            if pred.get("inverse"):
                new_pred["inverse"] = pred["inverse"]
            if pred.get("cardinality"):
                new_pred["cardinality"] = pred["cardinality"]
            if pred.get("knowledge_type"):
                new_pred["knowledge_type"] = pred["knowledge_type"]
            if pred.get("description"):
                new_pred["description"] = pred["description"]

            # Insert before related_to (catch-all should be last)
            predicates = data.get("predicates", [])
            insert_idx = len(predicates)
            for i, p in enumerate(predicates):
                if p.get("name") == "related_to":
                    insert_idx = i
                    break
            predicates.insert(insert_idx, new_pred)
            data["predicates"] = predicates

            # Write back
            try:
                yaml_content = yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
                self._writer.write(yaml_path, yaml_content)
            except Exception as e:
                return {"error": f"Failed to write ontology YAML: {e}"}

            # Reload ontology
            skills_dir = str(self._vault.paths.resolve("system/skills"))
            result = reload_ontology(skills_dir)

            return {
                "added": pred["name"],
                "reload": result,
                "message": f"Predicate '{pred['name']}' added to ontology and reloaded.",
            }

        return {"error": f"Unknown action: {action}"}

    def _exec_correct_memory(self, inp: dict) -> dict:
        """Correct or remove a fact from MEMORY.md.

        Searches for the wrong fact and replaces or removes it.
        """
        if not self._vault or not self._writer:
            return {"error": "Vault not available."}

        wrong_fact = inp["wrong_fact"]
        correction = inp.get("correction", "")
        memory_path = "memory/MEMORY.md"

        if not self._vault.exists(memory_path):
            return {"error": "MEMORY.md not found."}

        doc = self._vault.read(memory_path)
        content = doc.raw

        # Find the fact (case-insensitive search)
        lower_content = content.lower()
        lower_fact = wrong_fact.lower()

        if lower_fact not in lower_content:
            return {"corrected": False, "message": f"Fact not found in MEMORY.md: '{wrong_fact}'"}

        # Find the exact line(s) containing the fact
        lines = content.split("\n")
        new_lines = []
        corrected = False
        for line in lines:
            if lower_fact in line.lower():
                if correction:
                    # Replace with correction
                    new_lines.append(f"- {correction}")
                    corrected = True
                else:
                    # Remove the line entirely
                    corrected = True
                    continue
            else:
                new_lines.append(line)

        if corrected:
            self._writer.write(memory_path, "\n".join(new_lines))
            action = f"replaced with: {correction}" if correction else "removed"
            log.info("Memory corrected: '%s' → %s", wrong_fact, action)
            return {"corrected": True, "action": action}

        return {"corrected": False, "message": "Could not apply correction."}

    def _exec_delegate_to_subagent(self, inp: dict) -> dict:
        """Delegate a task to a subagent via the orchestrator."""
        if not self._orchestrator:
            return {"error": "Orchestrator not available."}

        try:
            result = _run_async(
                self._orchestrator.dispatch(
                    agent_name=inp["agent_name"],
                    task=inp["task"],
                    context=inp.get("context", ""),
                )
            )
            return {
                "agent_name": result.agent_name,
                "content": result.content,
                "input_tokens": result.input_tokens,
                "output_tokens": result.output_tokens,
            }
        except ValueError as e:
            return {"error": str(e)}

    def _exec_list_subagents(self, _inp: dict) -> dict:
        """List available subagents."""
        if not self._orchestrator:
            return {"error": "Orchestrator not available."}
        return {"agents": self._orchestrator.list_agents()}

    def _exec_list_projects(self, inp: dict) -> dict:
        if not self._projects:
            return {"error": "Project service not available."}
        return {"projects": self._projects.list_projects(status_filter=inp.get("status"))}

    def _exec_create_project(self, inp: dict) -> dict:
        if not self._projects:
            return {"error": "Project service not available."}
        path = self._projects.create_project(
            title=inp["title"],
            description=inp["description"],
            target_date=inp.get("target_date", ""),
            related_goals=inp.get("related_goals"),
            assigned_agents=inp.get("assigned_agents"),
            linked_kbs=inp.get("linked_kbs"),
        )
        slug = inp["title"].lower().replace(" ", "-")
        return {"created": True, "path": path, "slug": slug}

    def _exec_update_project(self, inp: dict) -> dict:
        if not self._projects:
            return {"error": "Project service not available."}
        slug = inp["project"]
        updates = {}
        for key in ("title", "description", "target_date", "related_goals", "assigned_agents", "linked_kbs"):
            if key in inp:
                updates[key] = inp[key]
        self._projects.update_project(slug, **updates)
        return {"updated": True, "project": slug}

    def _exec_add_project_milestone(self, inp: dict) -> dict:
        if not self._projects:
            return {"error": "Project service not available."}
        self._projects.add_milestone(inp["project"], inp["milestone"])
        return {"added": True, "project": inp["project"]}

    def _exec_log_project_progress(self, inp: dict) -> dict:
        if not self._projects:
            return {"error": "Project service not available."}
        self._projects.add_log(inp["project"], inp["note"])
        return {"logged": True, "project": inp["project"]}

    def _exec_web_search(self, inp: dict) -> dict:
        try:
            from ddgs import DDGS
        except ImportError:
            return {"error": "ddgs not installed. Run: pip install ddgs"}

        query = inp["query"]
        max_results = inp.get("max_results", 8)
        try:
            results = DDGS().text(query, max_results=max_results)
            return {
                "query": query,
                "results": [
                    {"title": r["title"], "url": r["href"], "snippet": r["body"][:200]}
                    for r in results
                ],
                "count": len(results),
            }
        except Exception as e:
            return {"error": f"Search failed: {e}"}

    def _exec_research_scrape(self, inp: dict) -> dict:
        if not self._vault:
            return {"error": "Vault not available."}

        import re
        from urllib.parse import urlparse

        from agntspace.integrations.browser import (
            agent_browser_health,
            get_scraping_config,
            scrape_many,
        )

        topic = inp["topic"]
        kb = inp["kb"]
        # Page count rules live in browser-automation/SKILL.md (Rule #12):
        # default_pages when the LLM omits max_pages, max_pages as the hard
        # upper clamp. Edit the skill YAML to change them — no code edit.
        _scfg = get_scraping_config()
        # Quality + relevance gates from the same skill YAML (`quality:` +
        # `relevance:` blocks). Loaded with conservative hardcoded defaults
        # so a malformed/missing config never crashes the scraper.
        _quality_cfg, _relevance_cfg = self._load_research_quality_config()
        requested_pages = int(inp.get("max_pages") or _scfg.default_pages)
        max_pages = max(1, min(requested_pages, _scfg.max_pages))
        if requested_pages != max_pages:
            log.info(
                "research_scrape clamped max_pages %d → %d (skill cap=%d)",
                requested_pages, max_pages, _scfg.max_pages,
            )

        # Verify KB exists
        inbox_dir = self._vault.paths.resolve(f"knowledge/{kb}/inbox")
        if not inbox_dir.parent.exists():
            return {"error": f"Knowledge base '{kb}' does not exist."}
        inbox_dir.mkdir(parents=True, exist_ok=True)

        # Step 1: Search
        try:
            from ddgs import DDGS
        except ImportError:
            return {"error": "ddgs not installed. Run: pip install ddgs"}

        try:
            search_results = DDGS().text(topic, max_results=max_pages + 3)
        except Exception as e:
            return {"error": f"Search failed: {e}"}

        if not search_results:
            return {"error": f"No search results for '{topic}'."}

        # Filter: skip PDFs, videos, social media
        skip_domains = {"youtube.com", "twitter.com", "x.com", "facebook.com", "reddit.com", "tiktok.com"}
        filtered = []
        for r in search_results:
            url = r.get("href", "")
            domain = urlparse(url).netloc.replace("www.", "")
            if domain in skip_domains:
                continue
            if url.endswith(".pdf"):
                continue
            filtered.append(r)
            if len(filtered) >= max_pages:
                break

        if not filtered:
            return {"error": f"All {len(search_results)} search results filtered out (PDFs/social)."}

        # Step 2: Parallel scrape through the skill-driven backend chain.
        # scrape_many returns {url, text, error} per input — agent-browser
        # is skipped entirely when marked unhealthy, so blocked setups
        # don't burn time per URL.
        urls = [r["href"] for r in filtered]
        scrape_results = scrape_many(urls, min_len=200)

        scraped = []
        errors = []
        skipped_quality: list[dict] = []
        skipped_relevance: list[dict] = []
        for r, sr in zip(filtered, scrape_results, strict=False):
            url = r["href"]
            content = sr["text"]
            if not content:
                errors.append({"url": url, "reason": sr.get("error") or "empty"})
                continue

            # Quality gate: too few words = not a real article. Cookie walls,
            # JS-only pages, and redirect stubs typically come through with
            # < 100 words. Saving them produces fabricated summaries.
            word_count = len(re.findall(r"\w+", content))
            min_words = int(_quality_cfg.get("min_words_per_source", 150))
            if word_count < min_words:
                skipped_quality.append({
                    "url": url, "words": word_count, "min_required": min_words,
                })
                continue

            # Relevance gate: drop pages with no topic-keyword overlap. Catches
            # the "scraper followed a sidebar to an unrelated page" failure
            # mode — the same root cause that put NASA nebula photos and
            # Kitchen-design articles into the Colombia-floods KB. Pure
            # keyword overlap is fast, deterministic, and works offline.
            is_relevant, relevance_detail = self._check_topic_relevance(
                content, topic, _relevance_cfg,
            )
            if not is_relevant:
                skipped_relevance.append({
                    "url": url,
                    "topic_keywords": relevance_detail["topic_keywords"],
                    "matched": relevance_detail["matched"],
                    "overlap_ratio": relevance_detail["overlap_ratio"],
                    "min_overlap": relevance_detail["min_overlap"],
                })
                continue

            parsed = urlparse(url)
            slug = parsed.netloc.replace("www.", "") + parsed.path
            filename = re.sub(r"[^a-zA-Z0-9]+", "-", slug).strip("-")[:80] or "scraped"

            try:
                out_path = inbox_dir / f"{filename}.md"
                frontmatter = (
                    f"---\ntitle: \"{r['title']}\"\nsource_url: {url}\n"
                    f"author: agent\nagent: researcher\nsearch_query: \"{topic}\"\n"
                    f"word_count: {word_count}\n"
                    f"relevance_overlap: {relevance_detail['overlap_ratio']:.2f}\n"
                    f"---\n\n"
                )
                out_path.write_text(frontmatter + content, encoding="utf-8")
                scraped.append({
                    "title": r["title"], "url": url, "size": len(content),
                    "words": word_count,
                    "relevance": round(relevance_detail["overlap_ratio"], 2),
                    "file": f"{filename}.md",
                })
            except Exception as e:
                errors.append({"url": url, "reason": f"write failed: {e}"})

        backend_health = agent_browser_health()

        # Rule #13: emit a high-importance activity event on total failure
        # so the agent sees "scrape failed" in its relationship context
        # and can adapt, instead of narrating a soft success to the user.
        if scraped == []:
            activity = getattr(self, "_activity", None)
            if activity and hasattr(activity, "log"):
                with suppress(Exception):
                    activity.log(
                        "knowledge",
                        "scrape_failed",
                        f"research_scrape('{topic}' → {kb}) scraped 0/{len(filtered)} pages",
                        {
                            "topic": topic,
                            "kb": kb,
                            "agent_browser": backend_health,
                            "top_errors": errors[:3],
                        },
                        "high",
                    )
            top_errors = ", ".join(
                f"{e['url']} ({e['reason']})" for e in errors[:3]
            )
            return {
                "error": (
                    f"Scraping FAILED: 0 of {len(filtered)} pages could be fetched. "
                    f"agent-browser: {backend_health}. Top errors: {top_errors or 'unknown'}"
                ),
                "topic": topic,
                "kb": kb,
                "searched": len(search_results),
                "scraped": 0,
                "errors": errors,
                "agent_browser": backend_health,
            }

        # IMAGE POLICY (2026-04-19 redesign):
        # Images from web scraping are NOT saved as standalone KB sources —
        # they belong to their parent article and are tracked inline in the
        # source markdown. The previous behavior bulk-downloaded up to 5
        # images per scraped page directly into the KB inbox, where each one
        # was treated as a fresh source by the watcher, vision-LLM-described
        # in isolation, and turned into a wiki "concept" by compile. Result:
        # NASA nebula images and Kitchen-design photos appearing as
        # Colombia-floods wiki articles. Disabled by default; re-enable per-KB
        # via the `images.download_during_scrape` skill YAML setting if the
        # KB is genuinely visual (art / design / product photography).

        result = {
            "topic": topic,
            "kb": kb,
            "searched": len(search_results),
            "attempted": len(filtered),
            "scraped": len(scraped),
            "pages": scraped,
            "errors": errors if errors else None,
            "skipped_quality": skipped_quality if skipped_quality else None,
            "skipped_relevance": skipped_relevance if skipped_relevance else None,
            "agent_browser": backend_health,
            "message": (
                f"Researched '{topic}': scraped {len(scraped)}/{len(filtered)} pages "
                f"into {kb}/inbox/. "
                f"Skipped {len(skipped_quality)} thin-content + "
                f"{len(skipped_relevance)} off-topic. "
                f"Inbox watcher will auto-ingest."
            ),
        }
        return result

    def _load_research_quality_config(self) -> tuple[dict, dict]:
        """Load `quality` + `relevance` blocks from the browser-automation skill.

        Returns ``(quality_cfg, relevance_cfg)`` as plain dicts. Falls back to
        conservative deterministic defaults when the skill or YAML keys are
        missing — the scraper must still function on a fresh vault before any
        custom skill config exists. Tagged ``# arch:deterministic`` because
        these defaults are SAFETY rails, not strategy: the strategy lives in
        the YAML file when present.
        """
        defaults_quality = {  # arch:deterministic — fallback floor when skill YAML absent
            "min_words_per_source": 150,
        }
        defaults_relevance = {  # arch:deterministic — fallback floor when skill YAML absent
            "min_keyword_overlap": 0.3,
            "min_unique_matches": 1,
            "min_word_length": 4,
        }
        loader = self._skill_loader
        if loader is None or not hasattr(loader, "get_skill"):
            return defaults_quality, defaults_relevance
        try:
            skill = loader.get_skill("browser-automation")
        except Exception:
            return defaults_quality, defaults_relevance
        if not skill:
            return defaults_quality, defaults_relevance
        cfg = getattr(skill, "config", {}) or {}
        q_block = cfg.get("quality") or {}
        r_block = cfg.get("relevance") or {}
        return (
            {**defaults_quality, **q_block},
            {**defaults_relevance, **r_block},
        )

    @staticmethod
    def _check_topic_relevance(
        text: str, topic: str, relevance_cfg: dict,
    ) -> tuple[bool, dict]:
        """Pure-function relevance check. Returns ``(is_relevant, detail)``.

        Algorithm: tokenise both `topic` and `text` to lowercased word sets
        (only words >= ``min_word_length``), count distinct topic words that
        appear at least once in the text, compute the overlap ratio against
        the topic's distinct keywords. The page is relevant iff
            ``overlap_ratio >= min_keyword_overlap``
            AND ``matched_unique >= min_unique_matches``

        Returns the detail dict so the caller can record it in the page's
        frontmatter (audit trail) AND in the per-page skip record. Designed
        to be deterministic — same inputs always produce the same verdict,
        no LLM call, no embedding model, no network.
        """
        import re as _re

        min_overlap = float(relevance_cfg.get("min_keyword_overlap", 0.3))
        min_unique = int(relevance_cfg.get("min_unique_matches", 1))
        min_len = int(relevance_cfg.get("min_word_length", 4))

        topic_words = {
            w for w in _re.findall(r"\w+", topic.lower())
            if len(w) >= min_len
        }
        if not topic_words:
            # Nothing to match against — degenerate single-stopword topic.
            # Return relevant=True so we don't reject everything; a single
            # short-word topic was probably the user's exact query so trust
            # the search engine's ranking.
            return True, {
                "topic_keywords": [],
                "matched": [],
                "overlap_ratio": 1.0,
                "min_overlap": min_overlap,
            }

        text_words = {
            w for w in _re.findall(r"\w+", text.lower())
            if len(w) >= min_len
        }
        matched = topic_words & text_words
        overlap_ratio = len(matched) / len(topic_words)
        is_relevant = (
            overlap_ratio >= min_overlap
            and len(matched) >= min_unique
        )
        return is_relevant, {
            "topic_keywords": sorted(topic_words),
            "matched": sorted(matched),
            "overlap_ratio": overlap_ratio,
            "min_overlap": min_overlap,
        }

    def _exec_scrape_url(self, inp: dict) -> dict:
        if not self._vault:
            return {"error": "Vault not available."}

        import re
        from urllib.parse import urlparse

        from agntspace.integrations.browser import scrape_with_images

        url = inp["url"]
        kb = inp.get("kb", "").strip() or self._infer_kb_slug()
        filename = inp.get("filename")

        # Verify KB inbox exists
        inbox_dir = self._vault.paths.resolve(f"knowledge/{kb}/inbox")
        if not inbox_dir.parent.exists():
            return {"error": f"Knowledge base '{kb}' does not exist."}
        inbox_dir.mkdir(parents=True, exist_ok=True)

        # Auto-generate filename from URL if not provided
        if not filename:
            parsed = urlparse(url)
            slug = parsed.netloc.replace("www.", "") + parsed.path
            slug = re.sub(r"[^a-zA-Z0-9]+", "-", slug).strip("-")[:80]
            filename = slug or "scraped-page"

        try:
            import threading

            # Timeout guard — scrape_with_images can hang on slow/blocked sites
            result_box: list = []
            error_box: list = []

            def _scrape():
                try:
                    result_box.append(scrape_with_images(url))
                except Exception as e:
                    error_box.append(str(e))

            t = threading.Thread(target=_scrape, daemon=True)
            t.start()
            t.join(timeout=30)  # 30s hard cap
            if t.is_alive():
                return {"error": f"Scrape timed out after 30s for {url}. The site may be slow or blocked."}
            if error_box:
                return {"error": f"Scrape failed: {error_box[0]}"}
            if not result_box:
                return {"error": "Scrape returned no result."}

            content, _err, image_urls = result_box[0]

            if not content:
                return {"error": "Could not extract content from page. It may require JavaScript rendering or login."}

            # Download extracted images into the KB inbox so the ingest
            # pipeline picks them up alongside the text file.
            images_saved = 0
            if image_urls:
                try:
                    from agntspace.kb.image_resolver import download_images
                    resolved = download_images(image_urls, inbox_dir, max_images=10)
                    images_saved = sum(1 for r in resolved if r.resolved)
                except Exception:
                    log.debug("Image download failed for %s", url, exc_info=True)

            # Save to KB inbox with provenance tag so the ingest pipeline
            # knows this content came from an untrusted web source and
            # applies full prompt-injection sanitization (threat-model §9.1).
            out_path = inbox_dir / f"{filename}.md"
            frontmatter = (
                f"---\ntitle: {filename}\nsource_url: {url}\n"
                f"source_trust: untrusted_web\n"
                f"author: agent\nagent: researcher\n---\n\n"
            )
            out_path.write_text(frontmatter + content, encoding="utf-8")

            result = {
                "scraped": True,
                "url": url,
                "kb": kb,
                "saved_to": self._vault.paths.to_logical(out_path) if self._vault.paths else str(out_path),
                "size": len(content),
                "message": f"Scraped {url} → {kb}/inbox/{filename}.md ({len(content)} chars). Inbox watcher will auto-ingest.",
            }
            if images_saved:
                result["images_saved"] = images_saved
                result["message"] += f" Also saved {images_saved} image(s)."
            return result
        except Exception as e:
            return {"error": f"Scrape failed: {e}"}

    def _exec_create_presentation(self, inp: dict) -> dict:
        if not self._vault:
            return {"error": "Vault not available."}

        import re
        from datetime import date

        try:
            from pptx import Presentation  # type: ignore[import-untyped]
            from pptx.util import Inches, Pt  # type: ignore[import-untyped]
        except ImportError:
            return {"error": "python-pptx is not installed. Run: pip install python-pptx"}

        title = inp["title"]
        slides_data = inp.get("slides", [])
        subtitle = inp.get("subtitle", "")
        project_slug = inp.get("project") or "personal"
        theme_name = (inp.get("theme") or "").strip().lower()
        show_on_canvas = bool(inp.get("show_on_canvas", False))

        if not slides_data:
            return {"error": "No slides provided."}

        # Resolve theme from the presentation-themes skill YAML. Missing /
        # unknown / "default" → fall through to the legacy unthemed path
        # (zero regression for callers that don't pass a theme).
        theme_cfg = self._resolve_presentation_theme(theme_name) if theme_name and theme_name != "default" else None

        prs = Presentation()
        if theme_cfg and isinstance(theme_cfg.get("canvas"), dict):
            canvas = theme_cfg["canvas"]
            prs.slide_width = Inches(float(canvas.get("width_inches", 13.333)))
            prs.slide_height = Inches(float(canvas.get("height_inches", 7.5)))
        else:
            prs.slide_width = Inches(13.333)
            prs.slide_height = Inches(7.5)

        if theme_cfg is not None:
            # Themed rendering path — deterministic, no placeholder layouts.
            # Notes are attached after the shapes are drawn.
            self._render_themed_deck(prs, theme_cfg, title, subtitle, slides_data)
        else:
            # Legacy path — unchanged from pre-theme behavior.
            title_layout = prs.slide_layouts[0]  # Title Slide
            slide = prs.slides.add_slide(title_layout)
            slide.shapes.title.text = title
            if subtitle and slide.placeholders[1]:
                slide.placeholders[1].text = subtitle

            content_layout = prs.slide_layouts[1]  # Title and Content
            for s in slides_data:
                slide = prs.slides.add_slide(content_layout)
                slide.shapes.title.text = s.get("title", "")

                bullets = s.get("bullets", [])
                if bullets and len(slide.placeholders) > 1:
                    tf = slide.placeholders[1].text_frame
                    tf.clear()
                    for i, bullet in enumerate(bullets[:5]):
                        if i == 0:
                            tf.paragraphs[0].text = bullet
                            tf.paragraphs[0].font.size = Pt(18)
                        else:
                            p = tf.add_paragraph()
                            p.text = bullet
                            p.font.size = Pt(18)

                notes = s.get("notes", "")
                if notes:
                    slide.notes_slide.notes_text_frame.text = notes

        # --- Save to project output/ (default: personal) ---
        slug = re.sub(r"[^a-zA-Z0-9]+", "-", title).strip("-")[:80].lower()
        filename = f"{slug}.pptx"

        out_dir = self._vault.paths.resolve(f"projects/{project_slug}/output")
        out_dir.mkdir(parents=True, exist_ok=True)
        out_path = out_dir / filename

        try:
            prs.save(str(out_path))
        except Exception as e:
            return {"error": f"Failed to save presentation: {e}"}

        # --- Write companion .md summary (ingestible on user request) ---
        today = date.today().isoformat()
        companion_lines = [
            "---",
            f"title: \"{title}\"",
            "author: agent",
            "agent: document-producer",
            "output_type: presentation",
            f"source_file: {filename}",
            f"project: {project_slug}",
            f"date: {today}",
            "ingested: false",
            "---",
            "",
            f"# {title}",
            "",
        ]
        if subtitle:
            companion_lines.append(f"*{subtitle}*\n")
        for s in slides_data:
            companion_lines.append(f"## {s.get('title', '')}")
            for b in s.get("bullets", []):
                companion_lines.append(f"- {b}")
            notes = s.get("notes", "")
            if notes:
                companion_lines.append(f"\n> *Speaker notes:* {notes}")
            companion_lines.append("")

        companion_path = out_dir / f"{slug}.pptx.md"
        companion_path.write_text("\n".join(companion_lines), encoding="utf-8")

        # Use the vault root (parent of agntspace/ and agntspace-projects/)
        vault_root = self._vault.paths.root_dir if self._vault.paths else self._vault.vault_dir.parent
        try:
            rel_path = str(out_path.relative_to(vault_root))
        except ValueError:
            rel_path = str(out_path)
        try:
            companion_rel = str(companion_path.relative_to(vault_root))
        except ValueError:
            companion_rel = str(companion_path)
        theme_label = "default" if theme_cfg is None else (theme_cfg.get("label") or theme_name or "themed")

        canvas_status: dict | None = None
        if show_on_canvas:
            canvas_status = self._push_deck_to_canvas(theme_cfg, title, subtitle, slides_data)

        canvas_suffix = ""
        if canvas_status is not None:
            pushed = canvas_status.get("pushed", 0)
            skipped = canvas_status.get("skipped")
            if pushed > 0 and not skipped:
                canvas_suffix = f" Pushed {pushed} slide card(s) to the canvas."
            elif pushed > 0 and skipped:
                canvas_suffix = f" Pushed {pushed} slide card(s) to the canvas ({skipped})."
            else:
                canvas_suffix = f" Canvas preview unavailable ({skipped or 'unknown'})."

        return {
            "created": True,
            "path": rel_path,
            "companion": companion_rel,
            "project": project_slug,
            "slides": len(slides_data) + 1,  # +1 for title slide
            "theme": theme_label,
            "canvas": canvas_status,
            "message": (
                f"Created {filename} with {len(slides_data) + 1} slides "
                f"(theme: {theme_label}) "
                f"→ {rel_path}. Companion summary at {companion_rel}."
                f"{canvas_suffix}"
                f" Say 'ingest this into <kb>' to add it to a knowledge base."
            ),
        }

    # ── Presentation theming helpers (Rule #12: strategy in YAML, engine here) ──

    def _resolve_presentation_theme(self, name: str) -> dict | None:
        """Look up a theme by name in the presentation-themes skill config.

        Returns the theme dict on success, None on any failure (unknown
        name, malformed YAML, missing required keys). Callers MUST
        fall back to the legacy unthemed rendering path on None.
        """
        loader = self._skill_loader
        if loader is None or not hasattr(loader, "get_skill_config"):
            return None
        try:
            block = loader.get_skill_config("presentation-themes", "themes")
        except Exception:
            return None
        if not isinstance(block, dict):
            return None
        theme = block.get(name)
        if not isinstance(theme, dict):
            return None
        # The 'default' sentinel is declared in YAML but intentionally
        # carries no render data — caller should pick legacy path.
        if not theme.get("colors") or not theme.get("fonts") or not theme.get("sizes"):
            return None
        return theme

    @staticmethod
    def _hex_to_rgb(hex_str: str, fallback: tuple[int, int, int] = (0, 0, 0)) -> tuple[int, int, int]:
        """Parse '3B82F6' → (59, 130, 246). Never raises."""
        if not isinstance(hex_str, str):
            return fallback
        h = hex_str.strip().lstrip("#")
        if len(h) != 6:
            return fallback
        try:
            return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
        except ValueError:
            return fallback

    def _render_themed_deck(
        self,
        prs: object,
        theme: dict,
        title: str,
        subtitle: str,
        slides_data: list[dict],
    ) -> None:
        """Render a themed deck onto an already-sized Presentation.

        Deterministic: every shape/position/color comes from the theme
        dict. No fallback heuristics — if the theme is well-formed this
        produces the same output every time. Notes are attached per
        slide (python-pptx notes_slide API).
        """
        from pptx.dml.color import RGBColor  # type: ignore[import-untyped]
        from pptx.enum.shapes import MSO_SHAPE  # type: ignore[import-untyped]
        from pptx.util import Inches, Pt  # type: ignore[import-untyped]

        colors = theme.get("colors", {})
        fonts = theme.get("fonts", {})
        sizes = theme.get("sizes", {})
        layout = theme.get("layout", {})

        c_bg = RGBColor(*self._hex_to_rgb(colors.get("bg", "FFFFFF"), (255, 255, 255)))
        c_text = RGBColor(*self._hex_to_rgb(colors.get("text", "0A0A0F"), (10, 10, 15)))
        c_text2 = RGBColor(*self._hex_to_rgb(colors.get("text_secondary", "6B7280"), (107, 114, 128)))
        c_accent = RGBColor(*self._hex_to_rgb(colors.get("accent", "3B82F6"), (59, 130, 246)))
        c_band_bg = RGBColor(*self._hex_to_rgb(colors.get("header_band_bg", "1E3A5F"), (30, 58, 95)))
        c_band_text = RGBColor(*self._hex_to_rgb(colors.get("header_band_text", "FFFFFF"), (255, 255, 255)))
        c_footer = RGBColor(*self._hex_to_rgb(colors.get("footer_text", "6B7280"), (107, 114, 128)))

        font_title = str(fonts.get("title", "Calibri Light"))
        font_body = str(fonts.get("body", "Calibri"))

        size_title = int(sizes.get("title_slide_title", 48))
        size_sub = int(sizes.get("title_slide_subtitle", 20))
        size_slide_title = int(sizes.get("slide_title", 32))
        size_bullet = int(sizes.get("bullet", 20))
        size_caption = int(sizes.get("caption", 10))

        header_band = bool(layout.get("header_band", False))
        header_band_h = float(layout.get("header_band_height_inches", 1.1))
        accent_rule = bool(layout.get("accent_rule_under_title", False))
        footer_on = bool(layout.get("footer", False))

        slide_w = prs.slide_width
        slide_h = prs.slide_height
        margin = Inches(0.6)

        # Use BLANK layout so placeholders don't interfere with our drawing.
        try:
            blank_layout = prs.slide_layouts[6]  # standard "Blank" index
        except Exception:
            blank_layout = prs.slide_layouts[0]

        def paint_bg(slide: object) -> None:
            bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, slide_w, slide_h)
            bg.fill.solid()
            bg.fill.fore_color.rgb = c_bg
            bg.line.fill.background()
            # Send background behind everything else (spRef manipulation).
            spTree = bg._element.getparent()
            spTree.remove(bg._element)
            spTree.insert(2, bg._element)

        def add_text(
            slide: object,
            left: int,
            top: int,
            width: int,
            height: int,
            text: str,
            *,
            font_name: str,
            font_size: int,
            color: object,
            bold: bool = False,
        ) -> object:
            box = slide.shapes.add_textbox(left, top, width, height)
            tf = box.text_frame
            tf.word_wrap = True
            p = tf.paragraphs[0]
            p.text = text
            run = p.runs[0] if p.runs else p.add_run()
            run.font.name = font_name
            run.font.size = Pt(font_size)
            run.font.color.rgb = color
            run.font.bold = bold
            return box

        # ── Title slide ──
        slide = prs.slides.add_slide(blank_layout)
        paint_bg(slide)
        title_top = Inches(2.8)
        title_height = Inches(1.4)
        title_width = slide_w - 2 * margin
        add_text(slide, margin, title_top, title_width, title_height, title,
                 font_name=font_title, font_size=size_title, color=c_text, bold=True)
        if subtitle:
            add_text(slide, margin, Inches(2.8 + 1.2), title_width, Inches(0.8), subtitle,
                     font_name=font_body, font_size=size_sub, color=c_text2)
        if accent_rule:
            rule = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, margin, Inches(2.55),
                                          Inches(1.6), Inches(0.06))
            rule.fill.solid()
            rule.fill.fore_color.rgb = c_accent
            rule.line.fill.background()

        total_content_slides = len(slides_data)

        # ── Content slides ──
        for idx, s in enumerate(slides_data, start=1):
            slide = prs.slides.add_slide(blank_layout)
            paint_bg(slide)
            s_title = s.get("title", "")
            bullets = s.get("bullets", []) or []
            notes = s.get("notes", "")

            if header_band:
                band = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0,
                                              slide_w, Inches(header_band_h))
                band.fill.solid()
                band.fill.fore_color.rgb = c_band_bg
                band.line.fill.background()
                add_text(slide, margin, Inches(0.18), slide_w - 2 * margin,
                         Inches(header_band_h - 0.24), s_title,
                         font_name=font_title, font_size=size_slide_title,
                         color=c_band_text, bold=True)
                body_top = Inches(header_band_h + 0.4)
            else:
                add_text(slide, margin, Inches(0.55), slide_w - 2 * margin,
                         Inches(0.9), s_title,
                         font_name=font_title, font_size=size_slide_title,
                         color=c_text, bold=True)
                if accent_rule:
                    rule = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, margin,
                                                  Inches(1.55), Inches(1.2),
                                                  Inches(0.05))
                    rule.fill.solid()
                    rule.fill.fore_color.rgb = c_accent
                    rule.line.fill.background()
                body_top = Inches(1.95)

            if bullets:
                body_h = slide_h - body_top - (Inches(0.6) if footer_on else Inches(0.4))
                box = slide.shapes.add_textbox(margin, body_top,
                                               slide_w - 2 * margin, body_h)
                tf = box.text_frame
                tf.word_wrap = True
                for i, bullet in enumerate(bullets[:6]):
                    p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
                    p.text = f"•  {bullet}"
                    p.space_after = Pt(8)
                    run = p.runs[0] if p.runs else p.add_run()
                    run.font.name = font_body
                    run.font.size = Pt(size_bullet)
                    run.font.color.rgb = c_text

            if footer_on:
                from datetime import date as _date
                today_str = _date.today().isoformat()
                footer_text = f"{today_str}    ·    {idx} / {total_content_slides}    ·    {title}"
                add_text(slide, margin,
                         slide_h - Inches(0.45),
                         slide_w - 2 * margin, Inches(0.3),
                         footer_text,
                         font_name=font_body, font_size=size_caption,
                         color=c_footer)

            if notes:
                slide.notes_slide.notes_text_frame.text = notes

    # ── Canvas-side themed HTML rendering ───────────────────────────────
    #
    # Produces one self-contained HTML card per slide, inline-styled with
    # the same colors/fonts that the pptx engine uses — so editing a theme
    # in themes.yaml updates both surfaces from one source of truth.
    # Every dynamic string (slide title, bullets, notes, deck title) is
    # HTML-escaped before interpolation; the only attacker surface is
    # the LLM, but we still treat its output as untrusted at the render
    # boundary.

    @staticmethod
    def _html_escape(s: object) -> str:
        import html as _html
        return _html.escape("" if s is None else str(s), quote=True)

    @staticmethod
    def _hex_to_css(hex_str: str, fallback: str) -> str:
        """Return '#RRGGBB' from a 6-char hex (no-#). Falls back cleanly."""
        if not isinstance(hex_str, str):
            return fallback
        h = hex_str.strip().lstrip("#")
        if len(h) != 6:
            return fallback
        try:
            int(h, 16)
        except ValueError:
            return fallback
        return f"#{h.upper()}"

    def _render_title_slide_html(self, theme: dict | None, deck_title: str, subtitle: str) -> str:
        """Render the opening title card. Theme=None → default plain look."""
        t = self._html_escape(deck_title)
        sub = self._html_escape(subtitle)
        if not theme:
            return (
                f'<div style="background:#FFFFFF;color:#0A0A0F;font-family:Calibri,Arial,sans-serif;'
                f'padding:56px 48px;min-height:240px;border:1px solid #E5E7EB;border-radius:8px;'
                f'text-align:left;">'
                f'<div style="font-size:32px;font-weight:bold;margin:0 0 12px 0;line-height:1.2;">{t}</div>'
                + (f'<div style="font-size:18px;color:#6B7280;">{sub}</div>' if sub else "")
                + "</div>"
            )
        colors = theme.get("colors", {})
        fonts = theme.get("fonts", {})
        sizes = theme.get("sizes", {})
        layout = theme.get("layout", {})

        bg = self._hex_to_css(colors.get("bg"), "#FFFFFF")
        text = self._hex_to_css(colors.get("text"), "#0A0A0F")
        text2 = self._hex_to_css(colors.get("text_secondary"), "#6B7280")
        accent = self._hex_to_css(colors.get("accent"), "#3B82F6")
        rule = self._hex_to_css(colors.get("rule"), "#E5E7EB")

        font_title = self._html_escape(fonts.get("title") or "Calibri")
        font_body = self._html_escape(fonts.get("body") or "Calibri")
        sz_title = int(sizes.get("title_slide_title", 40)) - 8  # browser-scaled
        sz_sub = int(sizes.get("title_slide_subtitle", 20)) - 2
        accent_rule = bool(layout.get("accent_rule_under_title", False))

        accent_block = (
            f'<div style="width:64px;height:4px;background:{accent};margin-bottom:20px;"></div>'
            if accent_rule else ""
        )
        return (
            f'<div style="background:{bg};color:{text};font-family:\'{font_title}\',Calibri,Arial,sans-serif;'
            f'padding:56px 48px;min-height:260px;border:1px solid {rule};border-radius:8px;text-align:left;">'
            f'{accent_block}'
            f'<div style="font-size:{sz_title}px;font-weight:bold;line-height:1.15;margin:0 0 16px 0;">{t}</div>'
            + (f'<div style="font-family:\'{font_body}\',Calibri,Arial,sans-serif;font-size:{sz_sub}px;color:{text2};">{sub}</div>' if sub else "")
            + "</div>"
        )

    def _render_slide_html(
        self,
        theme: dict | None,
        slide: dict,
        *,
        slide_idx: int,
        total_content: int,
        deck_title: str,
    ) -> str:
        """Render one content slide card. Theme=None → default plain look."""
        from datetime import date as _date

        s_title = self._html_escape(slide.get("title", ""))
        bullets = slide.get("bullets", []) or []
        notes = slide.get("notes", "") or ""

        bullet_html = ""
        if bullets:
            items = "".join(f"<li style=\"margin-bottom:8px;\">{self._html_escape(b)}</li>" for b in bullets[:6])
            bullet_html = f'<ul style="padding-left:22px;margin:0;">{items}</ul>'

        notes_html = ""
        if notes:
            notes_html = (
                f'<div style="margin-top:20px;padding-top:12px;border-top:1px dashed #D1D5DB;'
                f'font-size:11px;color:#6B7280;font-style:italic;">'
                f'<span style="font-weight:bold;font-style:normal;">Speaker notes:</span> {self._html_escape(notes)}</div>'
            )

        if not theme:
            return (
                f'<div style="background:#FFFFFF;color:#0A0A0F;font-family:Calibri,Arial,sans-serif;'
                f'padding:32px 36px;min-height:220px;border:1px solid #E5E7EB;border-radius:8px;">'
                f'<div style="font-size:22px;font-weight:bold;margin:0 0 18px 0;line-height:1.25;">{s_title}</div>'
                f'<div style="font-size:15px;line-height:1.55;color:#0A0A0F;">{bullet_html}</div>'
                f'{notes_html}</div>'
            )

        colors = theme.get("colors", {})
        fonts = theme.get("fonts", {})
        sizes = theme.get("sizes", {})
        layout = theme.get("layout", {})

        bg = self._hex_to_css(colors.get("bg"), "#FFFFFF")
        text = self._hex_to_css(colors.get("text"), "#0A0A0F")
        accent = self._hex_to_css(colors.get("accent"), "#3B82F6")
        rule = self._hex_to_css(colors.get("rule"), "#E5E7EB")
        band_bg = self._hex_to_css(colors.get("header_band_bg"), "#1E3A5F")
        band_text = self._hex_to_css(colors.get("header_band_text"), "#FFFFFF")
        footer_color = self._hex_to_css(colors.get("footer_text"), "#6B7280")

        font_title = self._html_escape(fonts.get("title") or "Calibri")
        font_body = self._html_escape(fonts.get("body") or "Calibri")
        sz_title = int(sizes.get("slide_title", 28)) - 4
        sz_bullet = int(sizes.get("bullet", 18)) - 2

        header_band = bool(layout.get("header_band", False))
        accent_rule = bool(layout.get("accent_rule_under_title", False))
        footer_on = bool(layout.get("footer", False))

        footer_html = ""
        if footer_on:
            today = _date.today().isoformat()
            footer_html = (
                f'<div style="padding:10px 28px;font-size:10px;color:{footer_color};'
                f'font-family:\'{font_body}\',Calibri,Arial,sans-serif;border-top:1px solid {rule};'
                f'display:flex;justify-content:space-between;">'
                f'<span>{today}</span>'
                f'<span>{slide_idx} / {total_content}</span>'
                f'<span>{self._html_escape(deck_title)}</span>'
                f'</div>'
            )

        if header_band:
            body = (
                f'<div style="background:{bg};border:1px solid {rule};border-radius:8px;overflow:hidden;'
                f'font-family:\'{font_body}\',Calibri,Arial,sans-serif;color:{text};">'
                f'<div style="background:{band_bg};color:{band_text};padding:16px 28px;'
                f'font-family:\'{font_title}\',Georgia,serif;font-size:{sz_title}px;font-weight:bold;'
                f'line-height:1.25;">{s_title}</div>'
                f'<div style="padding:22px 28px 18px 28px;font-size:{sz_bullet}px;line-height:1.55;">'
                f'{bullet_html}{notes_html}</div>'
                f'{footer_html}'
                f'</div>'
            )
        else:
            accent_block = (
                f'<div style="width:40px;height:3px;background:{accent};margin:6px 0 16px 0;"></div>'
                if accent_rule else ""
            )
            body = (
                f'<div style="background:{bg};color:{text};font-family:\'{font_body}\',Calibri,Arial,sans-serif;'
                f'padding:32px 36px;min-height:220px;border:1px solid {rule};border-radius:8px;">'
                f'<div style="font-family:\'{font_title}\',Calibri,Arial,sans-serif;'
                f'font-size:{sz_title}px;font-weight:bold;line-height:1.2;margin:0;">{s_title}</div>'
                f'{accent_block}'
                f'<div style="font-size:{sz_bullet}px;line-height:1.55;">{bullet_html}</div>'
                f'{notes_html}'
                f'{footer_html}'
                f'</div>'
            )
        return body

    def _push_deck_to_canvas(
        self,
        theme: dict | None,
        deck_title: str,
        subtitle: str,
        slides_data: list[dict],
    ) -> dict:
        """Push themed HTML cards to canvas from a worker thread.

        Returns {'pushed': N, 'skipped': reason?}. Never raises — a
        broken canvas push must NOT fail the pptx-save return contract.
        """
        canvas = self._canvas
        loop = self._main_loop
        if canvas is None or loop is None:
            return {"pushed": 0, "skipped": "canvas_not_wired"}
        if not hasattr(canvas, "push"):
            return {"pushed": 0, "skipped": "canvas_missing_push"}

        total = len(slides_data)
        cards: list[tuple[str, str, str]] = []
        # Title card
        cards.append((
            self._render_title_slide_html(theme, deck_title, subtitle),
            "html",
            f"{deck_title} — Title",
        ))
        for idx, s in enumerate(slides_data, start=1):
            cards.append((
                self._render_slide_html(theme, s, slide_idx=idx, total_content=total, deck_title=deck_title),
                "html",
                f"{deck_title} — Slide {idx}: {s.get('title', '')}",
            ))

        import asyncio as _aio

        async def _push_all() -> int:
            pushed = 0
            for content, content_type, title in cards:
                try:
                    await canvas.push(content, content_type, title)
                    pushed += 1
                except Exception:
                    log.exception("canvas push failed for slide; continuing")
            return pushed

        try:
            fut = _aio.run_coroutine_threadsafe(_push_all(), loop)
            pushed = fut.result(timeout=15)
            return {"pushed": pushed, "skipped": None if pushed == len(cards) else "partial"}
        except Exception as exc:
            log.exception("scheduling canvas pushes failed")
            return {"pushed": 0, "skipped": f"schedule_error: {exc.__class__.__name__}"}

    def _exec_update_presentation(self, inp: dict) -> dict:
        """Update an existing presentation — add, remove, or modify slides.

        Supports:
        - add_slide: {position, title, bullets, notes}
        - remove_slide: {position}  (1-based, excludes title slide)
        - update_slide: {position, title?, bullets?, notes?}
        - reorder: not supported yet
        """
        if not self._vault:
            return {"error": "Vault not available."}

        try:
            from pptx import Presentation
            from pptx.util import Pt
        except ImportError:
            return {"error": "python-pptx is not installed. Run: pip install python-pptx"}

        path = inp.get("path", "").replace("\\", "/")
        if not path:
            return {"error": "path is required — the vault-relative path to the .pptx file."}

        # Resolve path — try multiple strategies since the path could be:
        # 1. A logical path (projects/personal/output/file.pptx)
        # 2. A physical path (agntspace-projects/personal/output/file.pptx)
        # 3. An absolute path
        from pathlib import Path as _Path
        full_path = None
        vault_root = self._vault.paths.root_dir if self._vault.paths else self._vault.vault_dir.parent

        # Strategy 1: VaultPaths logical resolution
        try:
            candidate = self._vault.paths.resolve(path)
            if candidate.is_file():
                full_path = candidate
        except Exception:
            pass

        # Strategy 2: relative to vault root (handles agntspace-projects/...)
        if not full_path:
            candidate = vault_root / path
            if candidate.is_file():
                full_path = candidate

        # Strategy 3: strip agntspace- prefix and try as logical path
        if not full_path and "agntspace-" in path:
            logical = path.replace("agntspace-", "")
            try:
                candidate = self._vault.paths.resolve(logical)
                if candidate.is_file():
                    full_path = candidate
            except Exception:
                pass

        # Strategy 4: absolute path
        if not full_path and _Path(path).is_file():
            full_path = _Path(path)

        if not full_path:
            return {"error": f"File not found: {path}"}

        prs = Presentation(str(full_path))
        action = inp.get("action", "add_slide")
        position = int(inp.get("position", len(prs.slides)))  # 1-based content index

        if action == "remove_slide":
            # Position is 1-based content slide index (slide 0 = title)
            slide_idx = position  # 0=title, 1=first content
            if slide_idx < 0 or slide_idx >= len(prs.slides):
                return {"error": f"Invalid slide position {position}. Presentation has {len(prs.slides)} slides."}
            # python-pptx doesn't have a direct remove — use XML manipulation
            rId = prs.slides._sldIdLst[slide_idx].rId
            prs.part.drop_rel(rId)
            del prs.slides._sldIdLst[slide_idx]

        elif action == "update_slide":
            slide_idx = position
            if slide_idx < 0 or slide_idx >= len(prs.slides):
                return {"error": f"Invalid slide position {position}."}
            slide = prs.slides[slide_idx]
            if inp.get("title") and slide.shapes.title:
                slide.shapes.title.text = inp["title"]
            bullets = inp.get("bullets")
            if bullets and len(slide.placeholders) > 1:
                tf = slide.placeholders[1].text_frame
                tf.clear()
                for i, bullet in enumerate(bullets[:5]):
                    if i == 0:
                        tf.paragraphs[0].text = bullet
                        tf.paragraphs[0].font.size = Pt(18)
                    else:
                        p = tf.add_paragraph()
                        p.text = bullet
                        p.font.size = Pt(18)
            notes = inp.get("notes")
            if notes:
                slide.notes_slide.notes_text_frame.text = notes

        elif action == "add_slide":
            content_layout = prs.slide_layouts[1]
            slide = prs.slides.add_slide(content_layout)
            slide.shapes.title.text = inp.get("title", "New Slide")
            bullets = inp.get("bullets", [])
            if bullets and len(slide.placeholders) > 1:
                tf = slide.placeholders[1].text_frame
                tf.clear()
                for i, bullet in enumerate(bullets[:5]):
                    if i == 0:
                        tf.paragraphs[0].text = bullet
                        tf.paragraphs[0].font.size = Pt(18)
                    else:
                        p = tf.add_paragraph()
                        p.text = bullet
                        p.font.size = Pt(18)
            notes = inp.get("notes")
            if notes:
                slide.notes_slide.notes_text_frame.text = notes
        else:
            return {"error": f"Unknown action: {action}. Use add_slide, remove_slide, or update_slide."}

        prs.save(str(full_path))
        return {
            "updated": True,
            "path": path,
            "action": action,
            "slides": len(prs.slides),
            "message": f"Updated presentation ({action}): {len(prs.slides)} slides → {path}",
        }

    @staticmethod
    def _extract_slide_bullets(content: str, max_bullets: int = 5) -> list[str]:
        """Extract the best bullet points from a wiki article for slides.

        Checks multiple section types in priority order:
        1. Key Takeaways / Key Points sections (explicit highlights)
        2. Summary / Overview sections (concise prose)
        3. Any bullet lists in the body
        4. First meaningful sentences as fallback
        """
        bullets: list[str] = []
        lines = content.split("\n")

        # Priority 1: Key Takeaways / Key Points sections
        priority_headings = ("key takeaway", "key point", "highlights", "main finding")
        in_priority = False
        for line in lines:
            lower = line.lower().strip()
            if any(h in lower for h in priority_headings) and lower.startswith("#"):
                in_priority = True
                continue
            if in_priority:
                if line.strip().startswith("#"):
                    break
                if line.strip().startswith("- ") and len(line.strip()) > 5:
                    bullets.append(line.strip().lstrip("- ").strip()[:100])
                    if len(bullets) >= max_bullets:
                        return bullets

        if bullets:
            return bullets[:max_bullets]

        # Priority 2: Summary / Overview sections — extract sentences
        summary_headings = ("summary", "overview", "description", "about")
        in_summary = False
        summary_text = ""
        for line in lines:
            lower = line.lower().strip()
            if any(lower.startswith(f"## {h}") or lower.startswith(f"# {h}") for h in summary_headings):
                in_summary = True
                continue
            if in_summary:
                if line.strip().startswith("#"):
                    break
                summary_text += " " + line.strip()

        if summary_text.strip():
            sentences = [s.strip() + "." for s in summary_text.split(".") if len(s.strip()) > 15]
            bullets = [s[:100] for s in sentences[:max_bullets]]
            if bullets:
                return bullets

        # Priority 3: Any bullet lines in the body
        for line in lines:
            stripped = line.strip()
            if stripped.startswith("- ") and len(stripped) > 8:
                bullet = stripped.lstrip("- ").strip()[:100]
                if bullet.lower() not in ("todo", "n/a", "none"):
                    bullets.append(bullet)
                    if len(bullets) >= max_bullets:
                        return bullets
        if bullets:
            return bullets

        # Priority 4: First meaningful sentences from entire content
        full_text = " ".join(l.strip() for l in lines if l.strip() and not l.startswith("#"))
        sentences = [s.strip() + "." for s in full_text.split(".") if len(s.strip()) > 15]
        return [s[:100] for s in sentences[:max_bullets]]

    def _exec_generate_kb_presentation(self, inp: dict) -> dict:
        """Auto-generate a presentation from KB articles (local-mode helper).

        Reads source summaries and concept articles from a KB, structures
        them into slides, and delegates to _exec_create_presentation.
        This bypasses the need for the LLM to produce a structured slides
        array — the LLM just needs to say "make a presentation about X".
        """
        if not self._vault:
            return {"error": "Vault not available."}

        kb = inp.get("kb", inp.get("topic", "")).strip() or self._infer_kb_slug()
        title = inp.get("title", "").strip()

        # Read articles from the KB
        import frontmatter

        kb_wiki = self._vault.paths.resolve(f"knowledge/{kb}/wiki")
        if not kb_wiki.is_dir():
            return {"error": f"Knowledge base '{kb}' has no wiki articles."}

        articles = []
        for md_file in kb_wiki.rglob("*.md"):
            if md_file.name.startswith(("_", ".")) or ".history" in str(md_file):
                continue
            try:
                post = frontmatter.load(str(md_file))
                meta = dict(post.metadata)
                entity_type = meta.get("entity_type", "")
                content = post.content or ""
                file_title = meta.get("title", md_file.stem)

                # Extract bullets from multiple sections for richer slides
                bullets = self._extract_slide_bullets(content)
                if bullets:
                    articles.append({
                        "title": file_title,
                        "bullets": bullets,
                        "type": entity_type or "concept",
                    })
            except Exception:
                continue

        if not articles:
            return {"error": f"No articles found in '{kb}' wiki."}

        if not title:
            title = f"{kb.replace('-', ' ').title()} — Overview"

        # Build slides from articles
        slides = []

        # Agenda slide
        slides.append({
            "title": "What We'll Cover",
            "bullets": [a["title"] for a in articles[:7]],
        })

        # One slide per article
        for article in articles[:8]:
            slides.append({
                "title": article["title"],
                "bullets": article["bullets"],
                "notes": f"Source: {article['type']} article from {kb} knowledge base",
            })

        # Summary slide
        slides.append({
            "title": "Key Takeaways",
            "bullets": [f"{a['title']}: {a['bullets'][0]}" if a['bullets'] else a['title'] for a in articles[:5]],
        })

        return self._exec_create_presentation({
            "title": title,
            "subtitle": f"Generated from {kb} knowledge base",
            "slides": slides,
            "project": inp.get("project", "personal"),
        })

    def _exec_research_and_present(self, inp: dict) -> dict:
        """Full research-to-presentation pipeline (local-mode multi-step).

        Chains: create/find KB → web search → scrape top results →
        trigger ingest+compile → generate presentation from wiki articles.
        """
        if not self._vault:
            return {"error": "Vault not available."}

        import re as _re
        topic = inp.get("topic", "").strip()
        if not topic:
            return {"error": "topic is required — what to research."}

        steps_done: list[str] = []
        errors: list[str] = []

        # Step 1: Find or create KB
        kb_slug = _re.sub(r"[^a-z0-9]+", "-", topic.lower()).strip("-")[:40]
        kb_exists = False
        if self._kb:
            try:
                kbs = self._kb.list_knowledge_bases()
                kb_exists = any(k["slug"] == kb_slug for k in kbs)
            except Exception:
                pass

        if not kb_exists:
            try:
                self._kb.create_kb(kb_slug, description=f"Research: {topic}")
                steps_done.append(f"Created KB '{kb_slug}'")
            except Exception as e:
                if "already exists" in str(e).lower() or "exist" in str(e).lower():
                    steps_done.append(f"Using existing KB '{kb_slug}'")
                else:
                    errors.append(f"KB creation: {str(e)[:60]}")
                    steps_done.append(f"Using KB '{kb_slug}' (creation note: {str(e)[:40]})")
        else:
            steps_done.append(f"Using existing KB '{kb_slug}'")

        # Step 2: Web search for sources, with fallback to research_scrape tool
        scraped_count = 0

        # Try the research_scrape tool first (it handles search + scrape in one call)
        try:
            research_result = self._exec_research_scrape({
                "topic": topic,
                "kb": kb_slug,
                "max_pages": 5,
            })
            scraped_count = research_result.get("scraped", 0)
            if scraped_count > 0:
                steps_done.append(
                    f"Researched '{topic}': scraped {scraped_count} pages into {kb_slug}"
                )
            else:
                err = research_result.get("error", "no results")
                steps_done.append(f"Web research returned no content: {err[:60]}")
        except Exception as e:
            steps_done.append(f"Research step skipped: {str(e)[:60]}")

        # Step 3: Trigger ingest + compile synchronously so articles exist
        # before we build the presentation. Uses _run_async to bridge sync→async.
        if scraped_count > 0 and self._kb:
            try:
                ingest_result = _run_async(self._kb.ingest(kb_slug))
                ingested = ingest_result.get("count", 0) if isinstance(ingest_result, dict) else 0
                steps_done.append(f"Ingested {ingested} source(s)")
            except Exception as e:
                steps_done.append(f"Ingest note: {str(e)[:60]}")

            try:
                compile_result = _run_async(self._kb.compile_wiki(kb_slug))
                compiled = compile_result.get("compiled", 0) if isinstance(compile_result, dict) else 0
                steps_done.append(f"Compiled {compiled} wiki article(s)")
            except Exception as e:
                steps_done.append(f"Compile note: {str(e)[:60]}")

        # Step 4: Generate presentation from whatever articles exist
        pres_result = self._exec_generate_kb_presentation({
            "kb": kb_slug,
            "title": topic.title(),
        })

        if pres_result.get("created"):
            steps_done.append(f"Created presentation: {pres_result.get('path', '')}")
        elif pres_result.get("error"):
            errors.append(f"Presentation: {pres_result['error']}")

        return {
            "topic": topic,
            "kb": kb_slug,
            "steps_completed": steps_done,
            "scraped": scraped_count,
            "presentation": pres_result.get("path", ""),
            "errors": errors if errors else None,
            "message": (
                f"Research pipeline for '{topic}': "
                + " → ".join(steps_done)
                + (f". {len(errors)} warning(s)." if errors else ".")
            ),
        }

    def _exec_create_video(self, inp: dict) -> dict:
        """Kick off a narrated video render. Returns a job_id immediately.

        Enqueues the render as an asyncio task (fire-and-forget) and
        returns `{job_id, progress_url, state}` so the agent can report
        instantly and the user can poll for progress. The actual render
        runs in the background and can take several minutes.

        Requires the [video] extra (playwright + edge-tts + moviepy).
        Returns `{error}` with a clear install hint if deps are missing.
        """

        target = (inp.get("target") or "").strip()
        if not target:
            return {"error": "Missing 'target' — provide e.g. 'wiki:slug' or 'url:https://...'"}

        # Parse + validate basic target shape; the engine does full validation.
        if ":" not in target:
            return {"error": f"Invalid target {target!r}; expected 'wiki:slug' / 'kb:slug' / 'project:slug' / 'journal:YYYY-MM-DD' / 'url:https://...'"}

        duration = int(inp.get("duration_seconds") or 60)
        voice = (inp.get("voice") or "liam").strip()
        style = (inp.get("style") or "walkthrough").strip()

        # Lazy imports — fail fast with an actionable message if [video] extra missing.
        try:
            from agntspace.video import engine as video_engine
            from agntspace.video import jobs as video_jobs
        except Exception as exc:  # noqa: BLE001
            return {"error": f"Video subsystem unavailable: {exc}"}

        registry = video_jobs.get_registry()
        job = video_jobs.VideoJobState(
            job_id=video_jobs.new_job_id(),
            state="pending",
            started_at=video_jobs._iso_now(),
            target=target,
            duration_seconds=duration,
            voice=voice,
            style=style,
        )

        # Register synchronously via asyncio.run on a short-lived task — the
        # registry lock needs an event loop. If we're inside the main loop
        # (usually yes, since tool dispatch goes through Agent), schedule
        # via create_task; otherwise spin one up briefly.
        main_loop = getattr(self, "_main_loop", None)
        if main_loop is None:
            try:
                import asyncio as _asyncio
                main_loop = _asyncio.get_running_loop()
            except RuntimeError:
                main_loop = None

        if main_loop is None:
            return {"error": "No event loop available for video render scheduling"}

        async def _register_and_run() -> None:
            await registry.register(job)
            await registry.update(job.job_id, state="running", phase="adapter")
            try:
                vault_root = getattr(self._vault, "vault_dir", None) if self._vault else None
                result = await video_engine.create_video(
                    target=target,
                    duration_seconds=duration,
                    voice=voice,
                    style=style,
                    base_url="http://localhost:8000",
                    skill_loader=self._skill_loader,
                    vault_reader=self._vault,
                    llm=getattr(self, "_llm", None),
                    vault_root=vault_root,
                )
                await registry.finish(
                    job.job_id,
                    state="completed",
                    path=str(result.path),
                    duration_actual=result.duration_seconds,
                    narration_word_count=result.narration_word_count,
                    tts_provider_used=result.tts_provider_used,
                    warnings=list(result.warnings),
                    scenes_total=result.scene_count,
                    scenes_recorded=result.scene_count,
                    phase="",
                )
            except Exception as exc:  # noqa: BLE001
                log.exception("Video render failed for job %s", job.job_id)
                await registry.finish(job.job_id, state="failed", error=str(exc), phase="")

        import asyncio as _asyncio
        _asyncio.run_coroutine_threadsafe(_register_and_run(), main_loop)

        return {
            "message": f"Video render queued. Target: {target}. Poll /api/video/jobs/{job.job_id} for progress.",
            "job_id": job.job_id,
            "state": "pending",
            "target": target,
            "progress_url": f"/api/video/jobs/{job.job_id}",
        }

    def _exec_ingest_output(self, inp: dict) -> dict:
        """Copy an agent-generated output file into a KB inbox for ingestion."""
        if not self._vault:
            return {"error": "Vault not available."}

        import shutil

        file_path = inp.get("file")
        kb = inp.get("kb")
        if not file_path or not kb:
            return {"error": "Both 'file' and 'kb' are required."}

        # Resolve the source — accept both logical vault paths and absolute.
        # Try logical resolution first (honors sibling dirs like
        # agntspace-knowledge/); fall back to absolute path if the caller
        # passed one directly.
        source = self._vault.paths.resolve(file_path) if self._vault.paths else self._vault.vault_dir / file_path
        if not source.exists():
            candidate = Path(file_path)
            if candidate.is_absolute() and candidate.exists():
                source = candidate
        if not source.exists():
            return {"error": f"File not found: {file_path}"}

        # Prefer the companion .md if the user pointed at the binary
        if source.suffix != ".md" and source.with_suffix(source.suffix + ".md").exists():
            source = source.with_suffix(source.suffix + ".md")

        # Verify KB inbox exists
        inbox_dir = self._vault.paths.resolve(f"knowledge/{kb}/inbox")
        if not inbox_dir.parent.exists():
            return {"error": f"Knowledge base '{kb}' does not exist."}
        inbox_dir.mkdir(parents=True, exist_ok=True)

        dest = inbox_dir / source.name
        try:
            shutil.copy2(str(source), str(dest))
        except Exception as e:
            return {"error": f"Failed to copy file: {e}"}

        # Mark the companion as ingested
        companion = source if source.suffix == ".md" else source.with_suffix(source.suffix + ".md")
        if companion.exists():
            try:
                text = companion.read_text(encoding="utf-8")
                text = text.replace("ingested: false", "ingested: true")
                companion.write_text(text, encoding="utf-8")
            except Exception:
                log.debug("Failed to update ingested flag on %s", companion)

        to_logical = self._vault.paths.to_logical if self._vault.paths else str
        rel_dest = to_logical(dest)
        return {
            "ingested": True,
            "source": to_logical(source),
            "destination": rel_dest,
            "kb": kb,
            "message": f"Copied to {rel_dest}. The inbox watcher will auto-ingest it into the {kb} wiki.",
        }

    def _exec_whatsapp_answering_mode(self, inp: dict) -> dict:
        if not self._channel_manager:
            return {"error": "Channel manager not available."}
        wa = self._channel_manager.get_channel("whatsapp")
        if not wa:
            return {"error": "WhatsApp is not connected."}
        enabled = inp.get("enabled", False)
        mode = "dedicated" if enabled else "personal"
        new_mode = _run_async(wa.set_mode(mode))
        if enabled:
            return {"mode": new_mode, "message": "Answering mode ON — I'll now respond to other people's WhatsApp messages."}
        return {"mode": new_mode, "message": "Answering mode OFF — back to self-chat only."}

    # ────────────────────────────────────────────────────────────────────
    # Finance subsystem (Phase 1)
    # ────────────────────────────────────────────────────────────────────
    # All handlers expose a sync surface (registry pattern) plus an
    # ``async_handler`` registered alongside in ``populate_registry`` per
    # session-78 lesson — the sync wrapper uses ``_run_async`` which can
    # deadlock on ``asyncio.get_event_loop()`` in worker threads.

    def _finance_unavailable(self, feature: str = "finance") -> dict:
        return {
            "error": (
                "Finance feature not yet enabled in this vault. "
                "Drop a receipt in agntspace-finance/inbox/ to initialise, "
                "or call POST /api/finance/init."
            ),
            "feature": feature,
        }

    def _exec_ingest_receipt(self, inp: dict) -> dict:
        return _run_async(self._async_ingest_receipt(inp))

    async def _async_ingest_receipt(self, inp: dict) -> dict:
        if self._ingest is None:
            return self._finance_unavailable("receipt_ingest")
        from pathlib import Path
        raw_path = inp.get("path", "")
        if not raw_path:
            return {"error": "path is required"}
        # Resolve logical paths via VaultPaths if the writer has one
        physical: Path
        if self._writer and getattr(self._writer, "_paths", None) is not None:
            physical = self._writer._paths.resolve(raw_path)  # type: ignore[attr-defined]
        else:
            physical = Path(raw_path)
        if not physical.is_absolute():
            # Relative paths are resolved against the writer's vault root
            base = getattr(self._writer, "_vault_dir", None)
            if base is not None:
                physical = base / raw_path
        result = await self._ingest.ingest(physical, source_logical_path=raw_path)
        return result.to_dict()

    def _exec_commit_transaction(self, inp: dict) -> dict:
        return _run_async(self._async_commit_transaction(inp))

    async def _async_commit_transaction(self, inp: dict) -> dict:
        if self._finance_ledger is None or self._writer is None:
            return self._finance_unavailable("ledger")
        from datetime import UTC, datetime

        from agntspace.finance.storage import read_draft, write_transaction
        from agntspace.finance.types import TransactionStatus

        draft_id = (inp.get("draft_id") or "").strip()
        if not draft_id:
            return {"error": "draft_id is required"}
        # Use VaultReader companion if we have one
        if self._vault is None:
            return self._finance_unavailable("vault_reader")
        draft = read_draft(self._vault, draft_id)
        if draft is None:
            return {"error": f"draft not found: {draft_id}"}

        # Promote draft → verified row in the ledger.
        # The ledger's commit_transaction uses content_hash dedup and
        # idempotently re-uses the existing row when status flips.
        ok = await self._finance_ledger.update_transaction_status(
            draft_id, TransactionStatus.VERIFIED,
        )
        if not ok:
            return {"error": "ledger update failed"}

        # Materialise the canonical transactions/YYYY-MM/ markdown file
        # by re-fetching the now-verified row from the ledger and writing it.
        txn = await self._finance_ledger.get_transaction(draft_id)
        if txn is not None:
            try:
                write_transaction(self._writer, txn)
            except Exception as exc:
                log.exception("Failed to write verified transaction markdown %s", draft_id)
                return {"error": f"verified row written but markdown failed: {exc}"}
        return {
            "ok": True,
            "txn_id": draft_id,
            "status": "verified",
            "verified_at": datetime.now(UTC).isoformat(timespec="seconds"),
        }

    def _exec_list_transactions(self, inp: dict) -> dict:
        return _run_async(self._async_list_transactions(inp))

    async def _async_list_transactions(self, inp: dict) -> dict:
        if self._finance_ledger is None:
            return self._finance_unavailable("ledger")
        rows = await self._finance_ledger.list_transactions(
            since=str(inp.get("since") or ""),
            until=str(inp.get("until") or ""),
            merchant_slug=str(inp.get("merchant_slug") or ""),
            project_slug=str(inp.get("project_slug") or ""),
            category_slug=str(inp.get("category_slug") or ""),
            currency=str(inp.get("currency") or ""),
            status=str(inp.get("status") or ""),
            limit=int(inp.get("limit") or 50),
            offset=int(inp.get("offset") or 0),
        )
        return {
            "count": len(rows),
            "transactions": [
                {
                    "txn_id": t.txn_id,
                    "txn_date": t.txn_date,
                    "merchant_slug": t.merchant_slug,
                    "currency": t.currency,
                    "total": t.total,
                    "tax": t.tax,
                    "project_slug": t.project_slug,
                    "status": t.status.value,
                    "item_count": len(t.items),
                    "source_file": t.source_file,
                }
                for t in rows
            ],
        }

    def _exec_update_transaction(self, inp: dict) -> dict:
        return _run_async(self._async_update_transaction(inp))

    async def _async_update_transaction(self, inp: dict) -> dict:
        if self._finance_ledger is None:
            return self._finance_unavailable("ledger")
        txn_id = (inp.get("txn_id") or "").strip()
        if not txn_id:
            return {"error": "txn_id is required"}
        # Per-line-item updates
        items = inp.get("items") or []
        item_updates = 0
        if isinstance(items, list):
            for entry in items:
                if not isinstance(entry, dict):
                    continue
                item_id = entry.get("item_id")
                if not item_id:
                    continue
                ok = await self._finance_ledger.update_line_item(
                    item_id,
                    category_slug=entry.get("category_slug"),
                    project_slug=entry.get("project_slug"),
                    tax_deductible=entry.get("tax_deductible"),
                    notes=entry.get("notes"),
                )
                if ok:
                    item_updates += 1
        # Transaction-level project_slug — not yet exposed by the ledger
        # service as a standalone setter; defer until Phase 1.5 along with
        # transaction-level notes. For now, surface the shape so callers
        # know the API but warn if they tried to set top-level fields.
        warned: list[str] = []
        if "project_slug" in inp:
            warned.append("transaction-level project_slug update is Phase 1.5")
        if "notes" in inp:
            warned.append("transaction-level notes update is Phase 1.5")
        return {
            "ok": True,
            "txn_id": txn_id,
            "items_updated": item_updates,
            "warnings": warned,
        }

    def _exec_delete_transaction(self, inp: dict) -> dict:
        return _run_async(self._async_delete_transaction(inp))

    async def _async_delete_transaction(self, inp: dict) -> dict:
        if self._finance_ledger is None:
            return self._finance_unavailable("ledger")
        txn_id = (inp.get("txn_id") or "").strip()
        if not txn_id:
            return {"error": "txn_id is required"}
        ok = await self._finance_ledger.soft_delete_transaction(txn_id)
        if not ok:
            return {"error": f"transaction not found: {txn_id}"}
        return {"ok": True, "txn_id": txn_id, "status": "archived"}

    def _exec_query_finance(self, inp: dict) -> dict:
        return _run_async(self._async_query_finance(inp))

    async def _async_query_finance(self, inp: dict) -> dict:
        if self._finance_ledger is None:
            return self._finance_unavailable("ledger")
        # Accept either txn_id or draft_id (same ID space)
        txn_id = (inp.get("txn_id") or inp.get("draft_id") or "").strip()
        if not txn_id:
            return {"error": "txn_id or draft_id is required"}
        txn = await self._finance_ledger.get_transaction(txn_id)
        if txn is None:
            return {"error": f"not found: {txn_id}"}
        return {
            "txn_id": txn.txn_id,
            "txn_date": txn.txn_date,
            "merchant_slug": txn.merchant_slug,
            "currency": txn.currency,
            "total": txn.total,
            "tax": txn.tax,
            "project_slug": txn.project_slug,
            "status": txn.status.value,
            "items": [
                {
                    "item_id": i.item_id,
                    "description": i.description,
                    "amount": i.amount,
                    "category_slug": i.category_slug,
                    "project_slug": i.project_slug,
                    "tax_deductible": i.tax_deductible,
                }
                for i in txn.items
            ],
            "payment_method": txn.payment_method,
            "source_file": txn.source_file,
            "correlation_id": txn.correlation_id,
        }

    def _exec_resolve_merchant(self, inp: dict) -> dict:
        return _run_async(self._async_resolve_merchant(inp))

    async def _async_resolve_merchant(self, inp: dict) -> dict:
        if self._merchants is None:
            return self._finance_unavailable("merchants")
        raw = (inp.get("raw_text") or "").strip()
        if not raw:
            return {"error": "raw_text is required"}
        m = await self._merchants.resolve(raw)
        if m is None:
            return {"merchant": None, "raw_text": raw}
        return {
            "merchant": {
                "merchant_slug": m.merchant_slug,
                "canonical_name": m.canonical_name,
                "country": m.country,
                "default_category": m.default_category,
                "aliases": list(m.aliases),
            },
            "raw_text": raw,
        }

    def _exec_list_merchants(self, inp: dict) -> dict:
        return _run_async(self._async_list_merchants(inp))

    async def _async_list_merchants(self, inp: dict) -> dict:
        if self._merchants is None:
            return self._finance_unavailable("merchants")
        rows = await self._merchants.list_merchants(
            limit=int(inp.get("limit") or 50),
            offset=int(inp.get("offset") or 0),
        )
        return {
            "count": len(rows),
            "merchants": [
                {
                    "merchant_slug": m.merchant_slug,
                    "canonical_name": m.canonical_name,
                    "country": m.country,
                    "default_category": m.default_category,
                    "last_seen": m.last_seen,
                    "alias_count": len(m.aliases),
                }
                for m in rows
            ],
        }
