/** * Proactive Feedback Loop (kb #125) — closes the loop DESIGN-proactive- * prioritization.md (kb #123) needs: suggested -> got a rating -> took it * into account -> became more accurate. * * Producer/consumer split with kb #123 (not built yet, design-only): * THIS plugin owns the log (schema = that design's §5 `proactive_outcome`) * and the two capture paths (text reply, best-effort emoji reaction). * kb #123's future gate is a *reader* of `get_proactive_feedback_stats` and * a *writer* of `log_proactive_action` for suppressed/deferred candidates * (outcome: "not_sent") once it exists. Until then, Adolf itself is the * only writer/reader: it calls `log_proactive_action` right after drafting * a proactive send (same generation pass, no extra LLM call — matching the * design's cost discipline) and can call `get_proactive_feedback_stats` * before deciding whether a class of nudge is worth sending again. * * Storage decision (flagged explicitly, per kb #125's brief): this is NOT a * Hindsight bank. kb #123 needs per-class *counts and decayed rates* — a * tabular aggregate, not semantic recall — and Hindsight's recall/reflect * endpoints have no "give me accepted_count for class X" primitive; getting * one out would mean re-deriving a SQL-shaped answer from ranked free-text * memories, which is strictly worse than just keeping the rows. This plugin * is also NOT eligible for OpenClaw's own trusted plugin-state SQLite * (`api.state.openKeyedStore` throws "only available for trusted plugins in * this release" for any installed plugin that isn't bundled or * trustedOfficialInstall — verified against src/plugins/registry.ts — and * this plugin, like its hindsight-memory/quota-command siblings, is a local * bind-mounted install, neither). So: a small JSON array file via the public * `openclaw/plugin-sdk/json-store` helpers (atomic, 0o600), sized for * homelab volume (dozens/day, capped at maxRecords). If plugin-state SQLite * ever opens up to installed plugins, this is the one file to migrate. * * Capture paths: * * 1) TEXT (primary, robust) — `message_received` (observation-only, fires * pre-agent-turn, zero marginal Kimi cost since the user's message was * already going to produce a turn regardless): matches short exact * replies ("+", "-"/"−", "неактуально", etc.) against the pending record * correlated by `event.replyToId` (an explicit Matrix "reply to" quoting * Adolf's proactive message) or, absent that, the sender's single newest * still-pending record within `replyFallbackWindowMs` (never guessed if * more than one candidate is pending — see resolvePendingTarget below). * * 2) EMOJI REACTION (secondary, best-effort, flagged low-confidence) — there * is NO public plugin hook for inbound Matrix reactions in this OpenClaw * version (checked docs/plugins/hooks.md's full hook catalog and * extensions/matrix/src/matrix/monitor/reaction-events.ts directly). * Reactions are handled entirely inside the bundled matrix extension: a * reaction that targets a pending *approval* resolves through a private * target store (extensions/matrix/src/approval-reactions.ts) a * third-party plugin cannot register into; a reaction on any other * message (the case that matters here — reacting to a proactive send) * falls through to `core.system.enqueueSystemEvent(...)`, which queues * free text ("Matrix reaction added: by on msg ") * to be prefixed onto the *next* prompt for that session — i.e. the * model would have to read and interpret it, at whatever future turn * happens to occur next, which could be a long delay and is not a * deterministic capture. `openclaw/plugin-sdk/system-event-runtime` * exports `peekSystemEventEntries` (read-only, non-consuming) as a public * surface, so this plugin opportunistically peeks the queue in * `before_prompt_build` and regex-matches that exact line format against * pending records by message id — a side effect that costs nothing extra * (the turn was already about to happen) and never removes/mutates the * queue entry core itself will still drain normally. This is explicitly a * best-effort enhancement, not the load-bearing mechanism: whether * `before_prompt_build` fires before or after core's own queue drain for * the *same* turn is unverified (would need a live-fire trace), so a * reaction and the turn that would have surfaced it to this hook can, in * the worst case, race. Text replies remain the mechanism kb #123 should * trust; treat reaction-derived rows as a bonus signal only. */ import crypto from "node:crypto"; import path from "node:path"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime"; const DEFAULTS = { enabled: true, maxRecords: 5000, ignoreAfterMs: 24 * 60 * 60 * 1000, replyFallbackWindowMs: 24 * 60 * 60 * 1000, acceptedTextPatterns: ["+", "+1"], dismissedTextPatterns: ["-", "−", "-1"], // hyphen-minus and Unicode minus sign (U+2212, what "−" often renders as) irrelevantTextPatterns: ["неактуально", "не актуально", "irrelevant", "not relevant"], acceptedEmoji: ["\u{1F44D}"], // 👍 dismissedEmoji: ["\u{1F44E}"], // 👎 irrelevantEmoji: ["\u{1F937}"], // 🤷 statsTrailingN: 50, }; function normalizeConfig(raw) { const c = raw && typeof raw === "object" ? raw : {}; const int = (v, d, min) => (Number.isFinite(v) && v >= min ? Math.floor(v) : d); const strArr = (v, d) => Array.isArray(v) && v.length ? v.filter((s) => typeof s === "string" && s.trim()) : d; return { enabled: c.enabled !== false, maxRecords: int(c.maxRecords, DEFAULTS.maxRecords, 50), ignoreAfterMs: int(c.ignoreAfterMs, DEFAULTS.ignoreAfterMs, 60000), replyFallbackWindowMs: int(c.replyFallbackWindowMs, DEFAULTS.replyFallbackWindowMs, 60000), acceptedTextPatterns: strArr(c.acceptedTextPatterns, DEFAULTS.acceptedTextPatterns), dismissedTextPatterns: strArr(c.dismissedTextPatterns, DEFAULTS.dismissedTextPatterns), irrelevantTextPatterns: strArr(c.irrelevantTextPatterns, DEFAULTS.irrelevantTextPatterns), acceptedEmoji: strArr(c.acceptedEmoji, DEFAULTS.acceptedEmoji), dismissedEmoji: strArr(c.dismissedEmoji, DEFAULTS.dismissedEmoji), irrelevantEmoji: strArr(c.irrelevantEmoji, DEFAULTS.irrelevantEmoji), statsTrailingN: int(c.statsTrailingN, DEFAULTS.statsTrailingN, 5), }; } // --- log file ----------------------------------------------------------- function logFilePath() { // Writable adolf-state volume (/home/node/.openclaw), NOT the read-only // bind-mounted plugin source dir — see docker-compose.yml's adolf.volumes. return path.join(resolveStateDir(), "plugins", "feedback-loop", "proactive-feedback.json"); } // Tiny in-process sequential lock so overlapping hook/tool invocations // (message_sent racing a text reply racing a reaction peek) always // read-modify-write the log file one at a time instead of clobbering each // other's writes. File-level, not cross-process — fine for a single Adolf // gateway process owning one log file. let chain = Promise.resolve(); function withLogLock(fn) { const run = chain.then(fn, fn); chain = run.then( () => undefined, () => undefined, ); return run; } async function loadRecordsRaw() { const { value } = await readJsonFileWithFallback(logFilePath(), { records: [] }); return Array.isArray(value?.records) ? value.records : []; } async function saveRecordsRaw(records) { await writeJsonFileAtomically(logFilePath(), { records }); } // Settle stale pending (outcome == null, sent, no response) rows to // "ignored" — the design's required distinction from an explicit "-" // (dismissed): an ignored item is a weaker negative signal and should not // decay the acceptance rate as aggressively as an explicit rejection. function settleStale(records, cfg, nowMs) { let changed = false; for (const r of records) { if (r.outcome == null && r.sent !== false) { const sentAtMs = Date.parse(r.sent_at); if (Number.isFinite(sentAtMs) && nowMs - sentAtMs >= cfg.ignoreAfterMs) { r.outcome = "ignored"; changed = true; } } } return changed; } function pruneToCap(records, cap) { if (records.length <= cap) return records; return records.slice(records.length - cap); } async function withRecords(cfg, mutate) { return withLogLock(async () => { const records = await loadRecordsRaw(); const changedByStale = settleStale(records, cfg, Date.now()); const result = await mutate(records); const pruned = pruneToCap(records, cfg.maxRecords); if (changedByStale || pruned !== records || result?.dirty) { await saveRecordsRaw(pruned); } return result?.value; }); } // --- feedback text/emoji matching --------------------------------------- function classifyText(text, cfg) { const t = (text ?? "").trim(); if (!t) return null; const lower = t.toLowerCase(); if (cfg.acceptedTextPatterns.some((p) => lower === p.toLowerCase())) return "accepted"; if (cfg.dismissedTextPatterns.some((p) => lower === p.toLowerCase())) return "dismissed"; if (cfg.irrelevantTextPatterns.some((p) => lower === p.toLowerCase())) return "irrelevant"; return null; } function classifyEmoji(emoji, cfg) { if (!emoji) return null; if (cfg.acceptedEmoji.includes(emoji)) return "accepted"; if (cfg.dismissedEmoji.includes(emoji)) return "dismissed"; if (cfg.irrelevantEmoji.includes(emoji)) return "irrelevant"; return null; } // Find the record a feedback event should attach to. Prefers an explicit // reply-to match (deterministic); falls back to "the sender's one and only // still-pending record in the window" and refuses to guess when more than // one candidate exists, per the design's "never guess" discipline (kb#153 // applies the same rule to bank resolution; feedback attribution is the // same shape of problem). function resolvePendingTarget(records, { messageIds, senderId, nowMs, windowMs }) { for (const messageId of messageIds || []) { if (!messageId) continue; const byId = records.find((r) => r.message_id === messageId && r.outcome == null); if (byId) return byId; } if (!senderId) return null; const candidates = records.filter((r) => { if (r.outcome != null) return false; if (r.sender_id && r.sender_id !== senderId) return false; const sentAtMs = Date.parse(r.sent_at); return Number.isFinite(sentAtMs) && nowMs - sentAtMs <= windowMs; }); return candidates.length === 1 ? candidates[0] : null; } const REACTION_LINE_RE = /^Matrix reaction added: (.+) by (.+) on msg (\S+)$/; function extractReactionsFromSystemEvents(entries) { const out = []; for (const e of entries) { const text = typeof e?.text === "string" ? e.text : ""; const m = REACTION_LINE_RE.exec(text.trim()); if (m) out.push({ emoji: m[1].trim(), sender: m[2].trim(), eventId: m[3].trim() }); } return out; } // --- stats --------------------------------------------------------------- function laplaceRate(accepted, total) { return (accepted + 1) / (total + 2); } function computeStats(records, statsTrailingN) { const byClass = new Map(); for (const r of records) { if (!r.action_class) continue; if (!byClass.has(r.action_class)) byClass.set(r.action_class, []); byClass.get(r.action_class).push(r); } const out = []; for (const [action_class, rows] of byClass) { // Recency-weighted: trailing N most recent settled (non-pending, // non-not_sent) rows, per DESIGN-proactive-prioritization.md §3.3. const settled = rows .filter((r) => r.outcome && r.outcome !== "not_sent") .sort((a, b) => Date.parse(b.sent_at) - Date.parse(a.sent_at)) .slice(0, statsTrailingN); const counts = { accepted: 0, dismissed: 0, ignored: 0, irrelevant: 0 }; for (const r of settled) { if (counts[r.outcome] != null) counts[r.outcome] += 1; } const total = settled.length; out.push({ action_class, total_settled: total, total_all_time: rows.length, pending: rows.filter((r) => r.outcome == null).length, not_sent: rows.filter((r) => r.outcome === "not_sent").length, ...counts, accept_prob: laplaceRate(counts.accepted, total), }); } out.sort((a, b) => a.action_class.localeCompare(b.action_class)); return out; } // --------------------------------------------------------------------------- export default definePluginEntry({ id: "feedback-loop", name: "Proactive Feedback Loop", description: "Logs proactive sends and their outcomes (kb #125), captures +/-/неактуально replies and best-effort emoji reactions, and exposes per-class acceptance-rate stats for kb #123's prioritization gate.", register(api) { const cfg = normalizeConfig(api.pluginConfig); if (!cfg.enabled) return; // 1) TOOL — record a proactive send (or a suppressed/deferred // candidate the future kb#123 gate decided NOT to send). Called in the // same generation pass Adolf drafts the candidate in, matching the // design's "no separate LLM call" cost constraint. api.registerTool( (toolCtx) => ({ name: "log_proactive_action", label: "Log Proactive Action", description: "Record a proactive action for feedback tracking (kb #125). Call this right when you decide to send (or suppress/defer) a proactive nudge/reminder/digest item — pass the same action_class/benefit/urgency/cost you used to decide, so kb #123's gate can later learn from the outcome. Do not call this for ordinary replies to a direct user question.", parameters: { type: "object", additionalProperties: false, properties: { action_class: { type: "string", description: "Coarse category, e.g. calendar_reminder, task_overdue, ha_anomaly, family_wiki_gap, digest_item. One row is kept per exact class, not per message text.", }, sent: { type: "boolean", description: "true if the message was actually sent to the user just now; false if this candidate was suppressed/deferred instead (logs outcome: not_sent immediately, no feedback expected).", }, benefit_band: { type: "number", description: "Optional: the benefit(a) value used at send time (0/0.15/0.4/0.7/1.0 band).", }, cost_tokens: { type: "integer", description: "Optional: estimated or actual marginal token cost of this send.", }, urgency_at_send: { type: "number", description: "Optional: the urgency(a) value (0-1) used at send time.", }, note: { type: "string", description: "Optional short free-text snippet of the candidate, for audit only (not scored).", }, }, required: ["action_class", "sent"], }, execute: async (_toolCallId, params) => { const actionClass = String(params?.action_class || "").trim(); if (!actionClass) { return { content: [{ type: "text", text: "log_proactive_action: action_class is required." }], details: { ok: false }, }; } const sent = params?.sent !== false; const id = crypto.randomUUID(); const record = { id, action_class: actionClass, sent_at: new Date().toISOString(), sent, benefit_band: Number.isFinite(params?.benefit_band) ? params.benefit_band : null, cost_tokens: Number.isFinite(params?.cost_tokens) ? Math.floor(params.cost_tokens) : null, urgency_at_send: Number.isFinite(params?.urgency_at_send) ? params.urgency_at_send : null, note: typeof params?.note === "string" ? params.note.slice(0, 300) : null, outcome: sent ? null : "not_sent", responded_at: null, response_kind: null, message_id: null, // sessionKey lets the message_sent hook below attach the // resulting outbound message id to THIS record without a // second tool round-trip; sender_id lets text/reaction // attribution scope to the right human (kb#153-style // discipline, lower stakes here but kept consistent). session_key: toolCtx?.sessionKey || null, sender_id: toolCtx?.requesterSenderId || null, }; await withRecords(cfg, (records) => { records.push(record); return { dirty: true }; }); return { content: [{ type: "text", text: `Logged proactive action ${id} (${actionClass}, sent=${sent}).` }], details: { ok: true, id }, }; }, }), { name: "log_proactive_action" }, ); // 2) TOOL — read back per-class acceptance stats. Usable today by // Adolf itself (no kb#123 gate exists yet) to self-moderate proactive // sends, and by kb#123's gate once built. api.registerTool( { name: "get_proactive_feedback_stats", label: "Get Proactive Feedback Stats", description: "Read Laplace-smoothed per-class acceptance rates from the proactive-action feedback log (kb #125), trailing-window recency-weighted per kb #123 §3.3. Use before sending a proactive nudge of a class that has a history of being dismissed/ignored.", parameters: { type: "object", additionalProperties: false, properties: {} }, execute: async () => { const stats = await withRecords(cfg, (records) => ({ dirty: false, value: computeStats(records, cfg.statsTrailingN), })); return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }], details: { ok: true, stats } }; }, }, { name: "get_proactive_feedback_stats" }, ); // 3) HOOK — message_sent: attach the outbound message id to the most // recent still-open record from this same turn's session, so a later // reply-to or reaction can find it. Best-effort correlation by // sessionKey (message_sent does not carry runId reliably — see // PluginHookMessageContext's doc comment in hook-message.types.ts); // assumes at most one proactive send per turn, a known v1 limitation. api.on("message_sent", async (event) => { if (!event?.success || !event?.messageId || !event?.sessionKey) return; await withRecords(cfg, (records) => { for (let i = records.length - 1; i >= 0; i--) { const r = records[i]; if (r.session_key === event.sessionKey && r.outcome == null && !r.message_id) { r.message_id = event.messageId; return { dirty: true }; } } return { dirty: false }; }); }); // 4) HOOK — message_received: the primary, deterministic feedback // capture path. Observation-only (never blocks/rewrites the turn), so // this never changes normal chat behavior and never spends an extra // Kimi call — the user's message was already going to produce a turn. api.on("message_received", async (event) => { // Classify only the inbound message's OWN text — replyToBody (when // present) is Adolf's original proactive message being quoted, not // the user's feedback. const feedbackKind = classifyText(event?.content, cfg); if (!feedbackKind) return; await withRecords(cfg, (records) => { const target = resolvePendingTarget(records, { // Try both id forms — Matrix inbound reply metadata may carry a // normalized replyToId and/or the full event id, and message_sent // above only ever stores whatever `messageId` that hook received. messageIds: [event?.replyToId, event?.replyToIdFull], senderId: event?.senderId, nowMs: Date.now(), windowMs: cfg.replyFallbackWindowMs, }); if (!target) return { dirty: false }; target.outcome = feedbackKind; target.responded_at = new Date().toISOString(); target.response_kind = "text"; return { dirty: true }; }); }); // 5) HOOK — before_prompt_build: best-effort emoji-reaction peek (see // the file-header note on why this is secondary/unverified-timing, not // the load-bearing path). Pure side effect: returns nothing, never // mutates the prompt, so no allowPromptInjection/allowConversationAccess // opt-in is needed for this plugin. api.on("before_prompt_build", async (_event, ctx) => { if (!ctx?.sessionKey) return; let entries; try { entries = peekSystemEventEntries(ctx.sessionKey); } catch { return; // best-effort only; never fail a turn over this } const reactions = extractReactionsFromSystemEvents(entries || []); if (reactions.length === 0) return; await withRecords(cfg, (records) => { let dirty = false; for (const { emoji, eventId } of reactions) { const outcome = classifyEmoji(emoji, cfg); if (!outcome) continue; const target = records.find((r) => r.message_id === eventId && r.outcome == null); if (!target) continue; target.outcome = outcome; target.responded_at = new Date().toISOString(); target.response_kind = "reaction"; dirty = true; } return { dirty }; }); // No return value: this hook only observes, never mutates the prompt. }); }, });