/** * Todoist Idea Capture (kb#170 component 1) — registers `/idea ` on * Adolf's Matrix channel. * * Same reasoning as quota-command-openclaw-plugin (kb#62): OpenClaw's * native-command dispatch (`api.registerCommand`) runs BEFORE the agent * turn, so this never spends a Kimi turn. That property is not incidental * here — it's the whole point of kb#170's "encoder-only, not an LLM call" * design: classification runs on bge-m3 (agap-mcp/src/classifier.js), and * routing the capture through a native command means the ENTIRE * capture -> classify -> Todoist path costs zero model tokens, not just the * classification step. * * This plugin does no classification itself — it POSTs the raw text to * agap-mcp's /capture-idea endpoint (same container agap-mcp already * exposes at :3100 for the MCP tool surface; this is a second, plain-REST * entry point to the same todoistCaptureIdea() function, added because a * native command handler is simplest calling plain JSON over HTTP rather * than speaking MCP JSON-RPC to invoke its own tool). See agap-mcp/src/ * capture.js for the classify+create logic and agap-mcp/src/server.js for * the /capture-idea route. * * Gating: requireAuth: true (the registerCommand default) restricts the * command to the same Matrix DM allowlist (channels.matrix.dm.allowFrom in * openclaw.json) that already gates every other interaction with Adolf — * no separate tier needed, this creates a task in the operator's own * Todoist inbox, not a privileged/destructive action. */ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; // agap-mcp is a sibling reached via host.docker.internal, same mapping // openclaw.json's mcp.servers.agap.url already uses for this container. const CAPTURE_URL = "http://host.docker.internal:3100/capture-idea"; const FETCH_TIMEOUT_MS = 15000; // bge-m3 embed + Todoist create can take a few seconds // kb#180: agap-mcp's :3100 listener is authenticated now — /capture-idea is // no longer an open REST endpoint (it never should have been: it reaches // Todoist writes from any LAN peer). This plugin runs inside the adolf // container, so it presents Adolf's own agap-mcp bearer token, injected as // AGAP_MCP_TOKEN by openai/docker-compose.yml from .env (never inlined // here). If the var is unset the request goes out unauthenticated and // agap-mcp answers 401 — a visible failure of /idea, not a silent one. const AGAP_MCP_TOKEN = process.env.AGAP_MCP_TOKEN || ""; async function captureIdea(text) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(CAPTURE_URL, { method: "POST", headers: { "Content-Type": "application/json", ...(AGAP_MCP_TOKEN ? { Authorization: `Bearer ${AGAP_MCP_TOKEN}` } : {}), }, body: JSON.stringify({ text }), signal: controller.signal, }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body?.error || `agap-mcp /capture-idea HTTP ${res.status}`); return body; } finally { clearTimeout(timer); } } function formatReply({ task, classification }) { const bits = [ `area=${classification.area.label}`, `urgency=${classification.urgency.label}`, ]; if (classification.decompose.label === "needs-decomposition") bits.push("требует декомпозиции в Kanboard"); if (classification.area.ambiguous) bits.push("область — неточно, уточни при ревью"); return `Записал в Todoist: «${task.content}» (${bits.join(", ")}).`; } export default definePluginEntry({ id: "todoist-capture", name: "Todoist Idea Capture", description: "LLM-free /idea command: classifies free text via agap-mcp (local bge-m3, no Kimi call) and creates a labelled Todoist task.", register(api) { api.registerCommand({ name: "idea", description: "Capture an idea/quick task -> classified (area/urgency/decompose) and filed in Todoist. No Kimi call.", acceptsArgs: true, requireAuth: true, handler: async (ctx) => { const text = (ctx.args || "").trim(); if (!text) { return { text: "Использование: /idea <текст идеи>", suppressReply: true }; } try { const result = await captureIdea(text); return { text: formatReply(result), suppressReply: true }; } catch (e) { api.logger?.warn?.(`todoist-capture: capture failed (${e?.message || e})`); return { text: `Не удалось захватить идею: ${e?.message || e}`, suppressReply: true }; } }, }); }, });