From d870cb0d821e420b72d0cd667791dd2078aee81d Mon Sep 17 00:00:00 2001 From: lollen Date: Sun, 17 May 2026 19:17:51 +0200 Subject: [PATCH] Initial voice_history addon --- .gitignore | 3 + repository.yaml | 3 + voice_history/Dockerfile | 10 ++ voice_history/build.yaml | 3 + voice_history/config.yaml | 18 ++++ voice_history/run.sh | 11 +++ voice_history/voice-history.mjs | 162 ++++++++++++++++++++++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 .gitignore create mode 100644 repository.yaml create mode 100644 voice_history/Dockerfile create mode 100644 voice_history/build.yaml create mode 100644 voice_history/config.yaml create mode 100644 voice_history/run.sh create mode 100755 voice_history/voice-history.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a86324 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +token +*.log +node_modules/ diff --git a/repository.yaml b/repository.yaml new file mode 100644 index 0000000..df01529 --- /dev/null +++ b/repository.yaml @@ -0,0 +1,3 @@ +name: lollen's HA Addons +url: "" +maintainer: lollen diff --git a/voice_history/Dockerfile b/voice_history/Dockerfile new file mode 100644 index 0000000..b4c3f2b --- /dev/null +++ b/voice_history/Dockerfile @@ -0,0 +1,10 @@ +ARG BUILD_FROM +FROM $BUILD_FROM + +RUN apk add --no-cache nodejs + +WORKDIR /app +COPY voice-history.mjs run.sh ./ +RUN chmod +x /app/run.sh + +CMD ["/app/run.sh"] diff --git a/voice_history/build.yaml b/voice_history/build.yaml new file mode 100644 index 0000000..3e9c240 --- /dev/null +++ b/voice_history/build.yaml @@ -0,0 +1,3 @@ +build_from: + aarch64: ghcr.io/home-assistant/aarch64-base:latest + amd64: ghcr.io/home-assistant/amd64-base:latest diff --git a/voice_history/config.yaml b/voice_history/config.yaml new file mode 100644 index 0000000..5a07117 --- /dev/null +++ b/voice_history/config.yaml @@ -0,0 +1,18 @@ +name: Voice History +description: Capture HA voice pipeline transcripts + responses into input_text helpers +version: "0.1.0" +slug: voice_history +init: false +arch: + - aarch64 + - amd64 +startup: services +boot: auto +hassio_api: true +homeassistant_api: true +options: + ha_url: "http://supervisor/core" + pipeline_id: "01krvc3027p3fxaemtfz512tfm" +schema: + ha_url: str + pipeline_id: str diff --git a/voice_history/run.sh b/voice_history/run.sh new file mode 100644 index 0000000..6326182 --- /dev/null +++ b/voice_history/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/with-contenv bashio + +CONFIG_URL=$(bashio::config 'ha_url') +CONFIG_PIPELINE=$(bashio::config 'pipeline_id') + +export HA_URL="$CONFIG_URL" +export PIPELINE_ID="$CONFIG_PIPELINE" +# SUPERVISOR_TOKEN is auto-injected by supervisor when homeassistant_api: true + +bashio::log.info "Voice History starting against $HA_URL, pipeline $PIPELINE_ID" +exec node /app/voice-history.mjs diff --git a/voice_history/voice-history.mjs b/voice_history/voice-history.mjs new file mode 100755 index 0000000..0aff29a --- /dev/null +++ b/voice_history/voice-history.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Subscribes to HA conversation entity state changes, fetches latest +// pipeline_debug run, extracts STT transcript + final response, and +// writes a rolling window of last 5 exchanges to input_text.voice_h1..h5. + +import { readFileSync } from "node:fs"; + +const HA_URL = process.env.HA_URL || "https://assistant.home.lolcat.dev"; +const TOKEN = process.env.SUPERVISOR_TOKEN || process.env.HA_TOKEN || (() => { + try { return readFileSync(new URL("./token", import.meta.url), "utf8").trim(); } + catch { throw new Error("Set HA_TOKEN env var or write token to ./token"); } +})(); +// Inside addon container, HA_URL = http://supervisor/core ; that hosts /api/websocket too +const WS_URL = (HA_URL.startsWith("http://supervisor") + ? HA_URL.replace(/^http/, "ws") + : HA_URL.replace(/^http/, "ws")) + "/api/websocket"; +const PIPELINE_ID = process.env.PIPELINE_ID || "01krvc3027p3fxaemtfz512tfm"; +const SLOTS = 5; + +const seenRuns = new Set(); +let ws; +let nextId = 1; +const pending = new Map(); +const send = (msg) => { + const id = nextId++; + msg.id = id; + ws.send(JSON.stringify(msg)); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); +}; + +async function setInputText(entity_id, value) { + const truncated = value.length > 255 ? value.slice(0, 252) + "…" : value; + const res = await fetch(`${HA_URL}/api/services/input_text/set_value`, { + method: "POST", + headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify({ entity_id, value: truncated }), + }); + if (!res.ok) console.error(`set_value ${entity_id} failed:`, res.status, await res.text()); +} + +async function rotateAndPush(line) { + for (let i = SLOTS; i > 1; i--) { + const fromRes = await fetch(`${HA_URL}/api/states/input_text.voice_h${i - 1}`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + const fromVal = (await fromRes.json()).state || ""; + await setInputText(`input_text.voice_h${i}`, fromVal); + } + await setInputText("input_text.voice_h1", line); +} + +function summarizeRun(events) { + let transcript = null; + let response = null; + let engine = null; + let timestamp = null; + let toolCalls = []; + for (const e of events) { + if (e.type === "run-start") timestamp = e.timestamp; + if (e.type === "stt-end") transcript = e.data?.stt_output?.text || null; + if (e.type === "intent-start") engine = e.data?.engine || null; + if (e.type === "intent-progress") { + const calls = e.data?.chat_log_delta?.tool_calls; + if (Array.isArray(calls)) { + for (const c of calls) { + const name = c.tool_args?.name; + if (name) toolCalls.push(name); + } + } + } + if (e.type === "intent-end") { + response = e.data?.intent_output?.response?.speech?.plain?.speech || null; + } + } + return { transcript, response, engine, timestamp, toolCalls }; +} + +async function processRun(runId) { + if (seenRuns.has(runId)) return; + seenRuns.add(runId); + const res = await send({ + type: "assist_pipeline/pipeline_debug/get", + pipeline_id: PIPELINE_ID, + pipeline_run_id: runId, + }); + const { transcript, response, engine, timestamp, toolCalls } = summarizeRun(res.events || []); + if (!transcript && !response) return; // nothing useful + const t = timestamp ? new Date(timestamp).toLocaleTimeString("sv-SE", { hour: "2-digit", minute: "2-digit" }) : "--:--"; + const agent = engine?.includes("openai") ? "gpt" : engine?.includes("home_assistant") ? "lokal" : (engine || "?"); + const tools = toolCalls.length ? ` [${toolCalls.join(", ")}]` : ""; + const u = transcript || "(intet)"; + const r = response || "(intet svar)"; + const line = `${t} ${agent}${tools} • ${u} → ${r}`; + console.log(`[push] ${line}`); + await rotateAndPush(line); +} + +async function fetchLatestRuns() { + const res = await send({ + type: "assist_pipeline/pipeline_debug/list", + pipeline_id: PIPELINE_ID, + }); + const runs = res.pipeline_runs || []; + // newest last; process in chronological order + for (const r of runs) { + if (!seenRuns.has(r.pipeline_run_id)) { + await processRun(r.pipeline_run_id); + } + } +} + +function connect() { + ws = new WebSocket(WS_URL); + ws.onopen = () => console.log("[ws] connected"); + ws.onerror = (e) => console.error("[ws] error", e.message); + ws.onclose = () => { + console.error("[ws] disconnected, reconnecting in 5s…"); + setTimeout(connect, 5000); + }; + ws.onmessage = async (e) => { + const m = JSON.parse(e.data); + if (m.type === "auth_required") { + ws.send(JSON.stringify({ type: "auth", access_token: TOKEN })); + return; + } + if (m.type === "auth_ok") { + console.log("[ws] authed"); + // Seed known runs so we don't backfill on startup + const res = await send({ + type: "assist_pipeline/pipeline_debug/list", + pipeline_id: PIPELINE_ID, + }); + for (const r of res.pipeline_runs || []) seenRuns.add(r.pipeline_run_id); + console.log(`[seed] tracking ${seenRuns.size} existing runs; new ones from here on`); + // Subscribe to conversation entity state changes as the wake signal + await send({ + type: "subscribe_trigger", + trigger: { + platform: "state", + entity_id: ["conversation.openai_conversation_2", "conversation.home_assistant"], + }, + }); + console.log("[sub] listening for new conversation runs"); + return; + } + if (m.type === "result") { + const slot = pending.get(m.id); + if (slot) { + pending.delete(m.id); + if (m.success) slot.resolve(m.result); + else slot.reject(new Error(JSON.stringify(m.error))); + } + return; + } + if (m.type === "event") { + // Wait briefly so the pipeline_debug recorder has the events + setTimeout(() => fetchLatestRuns().catch(err => console.error("fetch err:", err.message)), 1000); + } + }; +} + +connect();