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