Plugins for the Adolf gateway:
- hindsight-openclaw-plugin: expanded memory recall/retain surface for the
Cognee -> Hindsight migration
- todoist-capture-plugin: posts captured ideas to agap-mcp's /capture-idea,
sending the kb#180 bearer token when AGAP_MCP_TOKEN is present
- feedback-loop-openclaw-plugin, kimi-quota-footer-plugin, cognee-mcp,
cognee-openclaw-plugin
Plus migrate-adolf-memory-banks.mjs for the memory-bank split,
backup-hindsight-adolf.sh / backup-llm-dbs.sh (the Hindsight and adolf-state
backups that were previously missing), and gpu_preload_check.sh for the
GTX 1070 residency checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
22 KiB
JavaScript
475 lines
22 KiB
JavaScript
/**
|
||
* 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: <emoji> by <sender> on msg <id>")
|
||
* 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.
|
||
});
|
||
},
|
||
});
|