Files
AgapHost/agap-mcp/src/classifier.js
alvis fc4e1c75ed agap-mcp: authenticate the :3100 listener (kb#180), pin bw CLI, add capture/classifier
Listener auth (kb#180, DESIGN-a2a-agents.md §4)
-----------------------------------------------
agap-mcp binds :3100 on every interface (network_mode: host) and the LAN
carries VPN-terminated peers, so an unauthenticated JSON-RPC listener handed
ha_call_service / gitea_wiki_write / wiki_edit / radicale+todoist writes and
POST /capture-idea to any LAN peer. Only vw_* was gated before (kb#147), and
only at ENFORCE=1.

src/listener-auth.js now requires `Authorization: Bearer <token>` resolving to
a known agent id on every route except /health, which stays open so a
misconfigured token map is still diagnosable. Two gates stay deliberately
layered and independently switchable: "are you an agent at all?" (this file)
vs "are you trusted enough for the vault?" (trust-gate.js), both reading the
same token map.

Also closes an SSE session-hijack hole: /messages previously trusted any
sessionId with no credential, so a guessed or leaked id was full tool access.
Sessions are now pinned to the caller identity captured at the /sse handshake,
comparing agent id *and* token.

Auth defaults ON, and boot fails loudly if the token map is empty rather than
serving 401 to everyone while /health reports ok. Rollback is
AGAP_MCP_REQUIRE_AUTH=0.

Verified live: unauthenticated and bad-token /mcp -> 401, unauthenticated
/capture-idea -> 401, /health -> 200, both real agent tokens -> 200 with 36
tools, including from inside the adolf container.

Pin the bw CLI
--------------
The Dockerfile installed @bitwarden/cli unpinned. Rebuilding jumped
2026.2.0 -> 2026.7.0, whose WASM cipher deserializer rejects any stored login
carrying `"uri": null` ("invalid type: JsValue(Object({...})), expected a
string") -- 33 of 49 items in this vault have that shape. `bw list` then exits
1, server init fails, and the container crash-loops. Pinned to 2026.2.0.

Do not unpin: 2026.7.0 cannot authenticate against this Vaultwarden
(2025.12.0) at all -- it refuses plain HTTP outright and 404s on the identity
endpoint over HTTPS. Updating the CLI requires upgrading Vaultwarden first.

capture / classifier
--------------------
Adds the POST /capture-idea REST endpoint and the idea classifier behind it
(consumed by the todoist-capture plugin), with tests. Carried in the same
commit because server.js wires both this and the auth boot path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 04:40:51 +00:00

229 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };