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>
229 lines
9.5 KiB
JavaScript
229 lines
9.5 KiB
JavaScript
// 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 };
|