ai: migrate LLM backbone from Kimi CLI to Codex CLI
Retires the Moonshot/Kimi subscription in favour of the already-paid ChatGPT plan. Both CLI wrappers now run `codex exec`; the kimi-agent container is gone. adolf-llm + hindsight-llm: - runKimi -> runCodex (`codex exec --json --skip-git-repo-check`), resume via `codex exec resume <thread_id>`. - MCP moves from a per-session .mcp.json (a workaround for Kimi having no --mcp-config-file flag) to a $CODEX_HOME/config.toml generated once at startup from shared-mcp.json. Field translation is load-bearing: bearerTokenEnvVar -> bearer_token_env_var, enabledTools -> enabled_tools. - approval_policy="never" + sandbox_mode required, or unattended turns block on an approval prompt nobody can answer. kimi-agent removed. It was the ONLY large-tier deployment behind LiteLLM, so deleting it outright would have silently degraded every large-tier request to the local 4B model via the existing fallbacks. tier-large, the auto_router complex-reasoning route and their fallbacks now point at the codex-backed adolf-llm wrapper (model_name: codex-agent). Three environment blockers fixed along the way: - OpenAI geo-blocks this host (403 unsupported_country_region_territory). Both containers now egress via the host xray proxy, with NO_PROXY keeping MCP and *.alogins.net traffic off the tunnel. - node:22-slim ships no system CA store; the Rust codex binary validates TLS against it, so every HTTPS call failed with a generic transport error while Node's own fetch worked. ca-certificates added to both images. - `codex exec resume` rejects -C/--cd (plain `codex exec` accepts it), which broke follow-up turns while first turns succeeded. Known regression: Kimi's managed-usage API has no Codex equivalent, so the /usage route returns 501 and there is no quota probe for the codex model. The two quota plugins degrade quietly to no output. Also: stop tracking cognee.env (live LLM + JWT secrets) and gitignore it. The secrets remain in earlier history and should be rotated. Verified live: plain turn, SSE streaming, session resume, MCP tool call, bearer-token MCP call, and completions through both LiteLLM routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
This commit is contained in:
474
ai/feedback-loop-openclaw-plugin/index.js
Normal file
474
ai/feedback-loop-openclaw-plugin/index.js
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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.
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user