Plugins for the Adolf gateway:
- hindsight-openclaw-plugin: expanded memory recall/retain surface for the
Cognee -> Hindsight migration
- todoist-capture-plugin: posts captured ideas to agap-mcp's /capture-idea,
sending the kb#180 bearer token when AGAP_MCP_TOKEN is present
- feedback-loop-openclaw-plugin, kimi-quota-footer-plugin, cognee-mcp,
cognee-openclaw-plugin
Plus migrate-adolf-memory-banks.mjs for the memory-bank split,
backup-hindsight-adolf.sh / backup-llm-dbs.sh (the Hindsight and adolf-state
backups that were previously missing), and gpu_preload_check.sh for the
GTX 1070 residency checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
4.6 KiB
JavaScript
103 lines
4.6 KiB
JavaScript
/**
|
||
* Todoist Idea Capture (kb#170 component 1) — registers `/idea <text>` 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 };
|
||
}
|
||
},
|
||
});
|
||
},
|
||
});
|