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:
2026-08-01 06:13:27 +00:00
parent a27bae828a
commit 9094d71e2f
66 changed files with 653 additions and 851 deletions

View 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.
});
},
});

View File

@@ -0,0 +1,74 @@
{
"id": "feedback-loop",
"name": "Proactive Feedback Loop",
"description": "Logs every proactive send (kb #125) and its outcome — accepted/dismissed/irrelevant/ignored/not_sent — using the DESIGN-proactive-prioritization.md (kb #123) §5 schema. Captures feedback via short text replies (+/-/неактуально) observed on message_received, and via a best-effort peek at Matrix emoji-reaction system-event text on before_prompt_build (no dedicated reaction hook exists in OpenClaw today — see plugin README/report). Exposes log_proactive_action and get_proactive_feedback_stats tools so Adolf (and later kb #123's gate) can record sends and read back Laplace-smoothed per-class acceptance rates. No conversation-content hooks used — no allowConversationAccess/allowPromptInjection opt-in required.",
"activation": {
"onStartup": true
},
"contracts": {
"tools": ["log_proactive_action", "get_proactive_feedback_stats"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"maxRecords": { "type": "integer", "minimum": 50, "maximum": 50000 },
"ignoreAfterMs": { "type": "integer", "minimum": 60000, "maximum": 2592000000 },
"replyFallbackWindowMs": { "type": "integer", "minimum": 60000, "maximum": 2592000000 },
"acceptedTextPatterns": { "type": "array", "items": { "type": "string" } },
"dismissedTextPatterns": { "type": "array", "items": { "type": "string" } },
"irrelevantTextPatterns": { "type": "array", "items": { "type": "string" } },
"acceptedEmoji": { "type": "array", "items": { "type": "string" } },
"dismissedEmoji": { "type": "array", "items": { "type": "string" } },
"irrelevantEmoji": { "type": "array", "items": { "type": "string" } },
"statsTrailingN": { "type": "integer", "minimum": 5, "maximum": 1000 }
}
},
"uiHints": {
"enabled": {
"label": "Feedback Loop",
"help": "Enable proactive-action feedback logging and capture."
},
"maxRecords": {
"label": "Max Log Records",
"help": "Oldest records are pruned FIFO once the log exceeds this many rows (default 5000 — homelab scale, not a hard requirement)."
},
"ignoreAfterMs": {
"label": "Ignore-After (ms)",
"help": "A sent proactive action with no response by this age is settled to outcome=ignored (weaker negative signal than an explicit dismiss). Default 24h."
},
"replyFallbackWindowMs": {
"label": "Reply Fallback Window (ms)",
"help": "When an inbound feedback reply does not quote a specific message (no replyToId), fall back to the sender's single newest pending record within this window. If more than one pending record exists, the reply is left unattributed rather than guessed. Default 24h."
},
"acceptedTextPatterns": {
"label": "Accepted Text Patterns",
"help": "Exact (case-insensitive, trimmed) reply texts that mark the correlated proactive action accepted. Default: [\"+\", \"+1\"]."
},
"dismissedTextPatterns": {
"label": "Dismissed Text Patterns",
"help": "Exact reply texts that mark the correlated action dismissed. Default: [\"-\", \"\", \"-1\"] (both hyphen-minus and Unicode minus sign)."
},
"irrelevantTextPatterns": {
"label": "Irrelevant Text Patterns",
"help": "Exact reply texts that mark the correlated action irrelevant. Default: [\"неактуально\", \"не актуально\", \"irrelevant\", \"not relevant\"]."
},
"acceptedEmoji": {
"label": "Accepted Emoji",
"help": "Reaction emoji mapped to accepted when opportunistically matched from queued system-event text. Default: [\"👍\"]."
},
"dismissedEmoji": {
"label": "Dismissed Emoji",
"help": "Reaction emoji mapped to dismissed. Default: [\"👎\"]."
},
"irrelevantEmoji": {
"label": "Irrelevant Emoji",
"help": "Reaction emoji mapped to irrelevant. Default: [\"🤷\"]."
},
"statsTrailingN": {
"label": "Stats Trailing N",
"help": "get_proactive_feedback_stats computes each class's acceptance rate over at most this many of its most recent settled records (recency-weighted per DESIGN-proactive-prioritization.md §3.3). Default 50."
}
}
}

View File

@@ -0,0 +1,18 @@
{
"name": "openclaw-feedback-loop",
"version": "1.0.0",
"description": "Proactive-action feedback loop for Adolf (kb #125): logs every proactive send, captures text (+/-/неактуально) and best-effort emoji-reaction feedback, and exposes a per-class acceptance-rate readout for kb #123's prioritization gate.",
"type": "module",
"private": true,
"main": "./index.js",
"peerDependencies": {
"openclaw": ">=2026.3.0"
},
"openclaw": {
"extensions": ["./index.js"],
"compat": {
"pluginApi": ">=2026.0.0",
"minGatewayVersion": "2026.0.0"
}
}
}