// 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); });