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:
228
agap-mcp/src/classifier.js
Normal file
228
agap-mcp/src/classifier.js
Normal 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 };
|
||||
Reference in New Issue
Block a user