Concierge au branchement + moniteur LLM/RAM + docs

Concierge: declenche au BRANCHEMENT (transition on de evenement) au lieu de la session ;
helper porte 'branchement' (ts::text verbatim) ; fraicheur [on-10, min(now, on+30)].
Moniteur LLM/RAM (llm-monitor.js) : endpoint spark + RAM, alerte WhatsApp sur changement
d'etat, publie l'etat dans HA (last_seen a chaque passage) + interrupteur de pilotage.
Docs: SPEC a jour, MONITORING-LLM.md, brouillons de config HA.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZsbhyGmRwZogdoBwT6C6y
This commit is contained in:
2026-09-15 04:59:15 +02:00
parent 534ae0d663
commit b834e3bf75
9 changed files with 345 additions and 94 deletions

120
workspace/llm-monitor.js Normal file
View File

@@ -0,0 +1,120 @@
// llm-monitor.js — tourne côté OpenClaw (.17), payload command d'un cron.
// « Le spark mesure, OpenClaw notifie » : lit le relevé publié par le spark
// (.spark-health.json) + teste l'endpoint LLM à distance. Deux sorties :
// 1) WhatsApp (announce) UNIQUEMENT sur changement d'état, et seulement si les
// alertes ne sont pas coupées par l'interrupteur HA (pilotage).
// 2) Publication de l'état LIVE dans Home Assistant. En particulier llm_monitor_last_seen
// est écrit À CHAQUE PASSAGE (même sain) : sans ça, un moniteur mort est
// indiscernable d'un système sain (les autres entités gardent leur dernière valeur).
// HA ne rafraîchit PAS le last_updated d'un helper réécrit à l'identique → il faut
// un horodatage explicite. Best-effort : n'échoue pas si une entité manque.
// Déterministe, sans LLM : l'alerte part même si le modèle est mort.
'use strict';
const fs = require('fs');
const path = require('path');
const HEALTH = path.join(__dirname, '.spark-health.json');
const STATE = path.join(__dirname, '.llm-monitor-state.json');
const SPARK = process.env.SPARK_BASE || 'http://10.10.10.8:8000';
const HA_BASE = process.env.HA_BASE || 'http://10.10.10.17:8123';
const MEM_ALERT_PCT = 4, MEM_RECOVER_PCT = 6;
const ENDPOINT_TIMEOUT_MS = 15000, ENDPOINT_SLOW_MS = 10000, HEALTH_STALE_S = 300;
const HA_ENABLED = 'input_boolean.llm_monitor_enabled'; // interrupteur alertes on/off
const HA_MEM = 'input_number.llm_mem_avail_pct'; // RAM dispo %
const HA_STATUS = 'input_text.llm_monitor_status'; // libellé d'état
const HA_LASTSEEN = 'input_datetime.llm_monitor_last_seen'; // battement de cœur (chaque cycle)
const HA_MODEL = 'input_text.llm_monitor_model'; // modèle réellement chargé
const load = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const save = (f, o) => fs.writeFileSync(f, JSON.stringify(o, null, 2));
const haToken = () => { try { return fs.readFileSync(path.join(__dirname, '.ha-token'), 'utf8').trim(); } catch { return null; } };
async function endpointCheck() {
const t0 = Date.now();
try {
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), ENDPOINT_TIMEOUT_MS);
const r = await fetch(`${SPARK}/health`, { signal: ac.signal });
clearTimeout(to);
return { ok: r.ok, ms: Date.now() - t0 };
} catch (e) { return { ok: false, ms: Date.now() - t0, err: e.name }; }
}
async function modelId() {
try {
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), 5000);
const r = await fetch(`${SPARK}/v1/models`, { signal: ac.signal });
clearTimeout(to);
if (!r.ok) return null;
const j = await r.json();
return j?.data?.[0]?.id || null;
} catch { return null; }
}
// --- Home Assistant, best-effort (ne jamais faire échouer le moniteur) ---
async function haGet(entity) {
const tok = haToken(); if (!tok) return null;
try {
const r = await fetch(`${HA_BASE}/api/states/${entity}`, { headers: { Authorization: `Bearer ${tok}` } });
return r.ok ? r.json() : null;
} catch { return null; }
}
async function haCall(domain, service, data) {
const tok = haToken(); if (!tok) return;
try {
await fetch(`${HA_BASE}/api/services/${domain}/${service}`, {
method: 'POST',
headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
} catch { /* best-effort */ }
}
(async () => {
const prev = load(STATE, { status: 'ok', mem_low: false });
const health = load(HEALTH, null);
const ep = await endpointCheck();
const problems = [];
if (!ep.ok) problems.push(`LLM injoignable (${SPARK}/health, ${ep.err || 'HTTP≠200'}, ${ep.ms} ms)`);
else if (ep.ms > ENDPOINT_SLOW_MS) problems.push(`LLM très lent (${ep.ms} ms)`);
let memLow = false, memPct = null;
if (!health) {
problems.push('pas de relevé RAM du spark (.spark-health.json absent — writer jamais lancé ?)');
} else {
memPct = Number(health.mem_avail_pct);
const ageS = (Date.now() - Date.parse(health.ts)) / 1000;
if (ageS > HEALTH_STALE_S) problems.push(`relevé spark figé (${Math.round(ageS / 60)} min) — health-writer arrêté ou spark KO ?`);
else {
const thr = (prev.status === 'alert' && prev.mem_low) ? MEM_RECOVER_PCT : MEM_ALERT_PCT;
if (memPct < thr) { memLow = true; problems.push(`RAM spark basse : ${memPct}% (${health.mem_avail_gib} Gi) — earlyoom tue le modèle à ~2%`); }
}
}
const status = problems.length ? 'alert' : 'ok';
const hhmm = new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
// 2) Publication LIVE dans HA (best-effort). last_seen À CHAQUE PASSAGE.
await haCall('input_datetime', 'set_datetime', { entity_id: HA_LASTSEEN, timestamp: Math.floor(Date.now() / 1000) });
if (memPct != null) await haCall('input_number', 'set_value', { entity_id: HA_MEM, value: memPct });
const label = (status === 'ok' ? `OK · RAM ${memPct ?? '?'}%` : `⚠️ ${problems.join(' | ')}`).slice(0, 250);
await haCall('input_text', 'set_value', { entity_id: HA_STATUS, value: `${label} · ${hhmm}` });
const mid = await modelId();
if (mid) await haCall('input_text', 'set_value', { entity_id: HA_MODEL, value: mid });
// Pilotage : interrupteur HA OFF → pas d'alerte WhatsApp (l'affichage reste à jour).
const enabled = await haGet(HA_ENABLED);
const muted = enabled && enabled.state === 'off';
let out = '';
if (status === 'alert' && prev.status !== 'alert') out = `⚠️ Spark/LLM — ${problems.join(' | ')}`;
else if (status === 'ok' && prev.status === 'alert') out = '✅ Spark/LLM — retour à la normale.';
save(STATE, { status, mem_low: memLow, at: new Date().toISOString(), problems });
if (out && !muted) console.log(out);
})().catch(e => { console.error('[llm-monitor]', e.message); process.exit(1); });