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:
120
workspace/llm-monitor.js
Normal file
120
workspace/llm-monitor.js
Normal 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); });
|
||||
Reference in New Issue
Block a user