// Todoist idea classifier (kb#170, component 2) — encoder-only, NOT a // classifier LLM call. Per DESIGN-a2a-agents.md v2.1 §3a/§3.1/theorem 24: // "routing classification is embedding-based on the local bge-m3 ... no // classifier LLM, no API spend". This module applies that same idea to // Todoist-capture classification: embed the idea text with bge-m3 (already // GPU-resident, never-evict per model-registry.yaml) and classify by // nearest-centroid against a small hand-labelled exemplar set — no Kimi/ // gemma call, ~0 marginal cost, no metered API. // // Three independent classification axes (each idea gets one label per axis, // not a single combined class): // area — which part of life the idea belongs to (kb#170 spec) // urgency — how soon it stops being actionable // decompose — is this a single atomic action, or a multi-step project // that should eventually become a Kanboard task graph // // This is deliberately a NEAREST-CENTROID classifier, not a trained model: // no labelled training set exists (kb#170 orchestrator note — inventing one // would be guessing), so the "training data" IS the exemplar list below, // reviewed/editable in code (git-controlled, per DESIGN-a2a-agents.md // "Personas and Cards are code"). Extending accuracy later means adding // exemplars here, not retraining a model. const DEFAULT_BGE_URL = process.env.BGE_M3_URL || 'http://host.docker.internal:11436/v1/embeddings'; // --- Exemplars ------------------------------------------------------------- // Kept short and idiomatic (the kind of one-line idea a person actually // captures), Russian-first since that's the capture language (kb#170 desc). // Centroids are the mean of these exemplars' embeddings — adding more // exemplars per class only requires appending strings here. const AREA_EXEMPLARS = { adolf: [ 'починить квоту Kimi у Adolf', 'настроить cron задачу в Kanboard', 'добавить новую MCP команду', 'проверить логи agap-mcp контейнера', 'написать воркер для очереди задач', 'обновить конфиг openclaw.json', ], welfare: [ 'продумать еженедельный ревью задач', 'настроить трекер настроения и энергии', 'сделать ежедневный брифинг по утрам', 'завести журнал решений', 'придумать систему напоминаний о важных вещах', 'разобраться с личной продуктивностью', 'спроектировать proactive-секретаря для себя', 'построить систему, которая сама напоминает и планирует', 'придумать, как автоматизировать личный распорядок дня', ], 'дом': [ 'купить новый пылесос', 'почистить фильтр кондиционера', 'вызвать сантехника починить кран', 'заказать доставку воды', 'разобрать кладовку', 'поменять лампочку в коридоре', 'оплатить счёт за квартиру', 'оплатить интернет и коммуналку', ], 'семья': [ 'позвонить маме', 'поздравить сестру с днём рождения', 'купить подарок жене', 'спланировать поездку с семьёй', 'написать бабушке', 'забрать детей из школы', ], 'здоровье': [ 'записаться к врачу', 'сдать анализы крови', 'начать бегать по утрам', 'купить витамины', 'сходить к стоматологу', 'записаться на массаж', ], }; const URGENCY_EXEMPLARS = { high: [ 'сделать это сегодня, срочно', 'дедлайн завтра утром', 'оплатить штраф до пятницы, иначе пени', 'нужно решить прямо сейчас', ], medium: [ 'сделать на этой неделе', 'стоит сделать в ближайшие дни', 'через пару дней надо разобраться', 'неплохо бы успеть до конца месяца', ], low: [ 'когда-нибудь было бы неплохо', 'не к спеху, просто идея на будущее', 'если будет время', 'мысль про потом, без срока', ], }; const DECOMPOSE_EXEMPLARS = { 'needs-decomposition': [ 'организовать переезд на новую квартиру', 'спроектировать и запустить новый сервис на сервере', 'спланировать отпуск в другую страну', 'построить систему проактивного секретаря', 'провести ремонт в квартире', 'подготовить и провести презентацию проекта', ], 'simple-task': [ 'позвонить маме', 'купить хлеб', 'оплатить счёт за интернет', 'отправить один email', 'поставить будильник', 'записать одну мысль в заметки', ], }; // --- Embeddings + cosine similarity ----------------------------------------- async function embed(text, bgeUrl = DEFAULT_BGE_URL) { const res = await fetch(bgeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'bge-m3', input: text }), }); if (!res.ok) { throw new Error(`bge-m3 embeddings ${res.status}: ${(await res.text()).slice(0, 300)}`); } const body = await res.json(); const vec = body?.data?.[0]?.embedding; if (!Array.isArray(vec)) throw new Error('bge-m3 embeddings: no vector in response'); return vec; } function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; } function norm(a) { return Math.sqrt(dot(a, a)); } function normalize(a) { const n = norm(a) || 1; return a.map((x) => x / n); } function mean(vectors) { const dim = vectors[0].length; const out = new Array(dim).fill(0); for (const v of vectors) for (let i = 0; i < dim; i++) out[i] += v[i]; return out.map((x) => x / vectors.length); } function cosine(a, b) { return dot(a, b) / ((norm(a) || 1) * (norm(b) || 1)); } // --- Centroid cache ---------------------------------------------------------- // Computed once per process (exemplars are static, embedding a few dozen // short strings at startup is cheap and happens lazily on first classify() // call, not at import time — keeps agap-mcp's init() path unaffected). let _centroidsPromise = null; async function buildCentroidSet(exemplarMap, bgeUrl) { const labels = Object.keys(exemplarMap); const centroids = {}; for (const label of labels) { const vectors = await Promise.all(exemplarMap[label].map((t) => embed(t, bgeUrl).then(normalize))); centroids[label] = normalize(mean(vectors)); } return centroids; } async function getCentroids(bgeUrl = DEFAULT_BGE_URL) { if (!_centroidsPromise) { _centroidsPromise = Promise.all([ buildCentroidSet(AREA_EXEMPLARS, bgeUrl), buildCentroidSet(URGENCY_EXEMPLARS, bgeUrl), buildCentroidSet(DECOMPOSE_EXEMPLARS, bgeUrl), ]).then(([area, urgency, decompose]) => ({ area, urgency, decompose })); } return _centroidsPromise; } // Test-only: let tests reset the cache (e.g. to inject a different BGE_URL). export function _resetCentroidCacheForTests() { _centroidsPromise = null; } // nearestLabel: pick argmax cosine similarity; also report the runner-up // and the margin between them. A small margin means the idea sits between // two classes — surfaced as `ambiguous: true` rather than silently forced, // so the periodic-review pass (kb#170 component 4) can have Adolf confirm // instead of trusting a low-confidence auto-tag. function nearestLabel(vec, centroidMap) { const scored = Object.entries(centroidMap) .map(([label, centroid]) => ({ label, score: cosine(vec, centroid) })) .sort((a, b) => b.score - a.score); const [top, second] = scored; const margin = second ? top.score - second.score : 1; return { label: top.label, score: Number(top.score.toFixed(4)), margin: Number(margin.toFixed(4)), ambiguous: margin < 0.03, // empirical starting threshold — revisit once real captures accumulate (same posture as DESIGN-proactive-prioritization.md's tunable constants) }; } // classify: the one entry point. Embeds the idea text ONCE, reuses it // across all three axes (one bge-m3 call, not three) — consistent with // the "no metered/needless calls" cost discipline in DESIGN-a2a-agents.md. export async function classifyIdea(text, { bgeUrl = DEFAULT_BGE_URL } = {}) { if (!text || !text.trim()) throw new Error('text is required'); const [vec, centroids] = await Promise.all([embed(text, bgeUrl).then(normalize), getCentroids(bgeUrl)]); return { area: nearestLabel(vec, centroids.area), urgency: nearestLabel(vec, centroids.urgency), decompose: nearestLabel(vec, centroids.decompose), }; } export const _internal = { AREA_EXEMPLARS, URGENCY_EXEMPLARS, DECOMPOSE_EXEMPLARS, cosine, embed, getCentroids };