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>
This commit is contained in:
2026-07-30 04:40:51 +00:00
parent 5b649123a8
commit fc4e1c75ed
11 changed files with 1089 additions and 79 deletions

228
agap-mcp/src/classifier.js Normal file
View File

@@ -0,0 +1,228 @@
// 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 };