fix: pair capture on message sending

This commit is contained in:
2026-04-12 23:39:46 +00:00
parent f2ec5d43de
commit 0371739877
3 changed files with 41 additions and 19 deletions

View File

@@ -152,6 +152,7 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
## Session Log
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass 3)** changed turn pairing from `llm_output` to `message_sending`. The plugin now caches the human prompt at `before_dispatch` and posts to AtoCore only when OpenClaw emits the real outbound assistant message. This should restore reliability while keeping prompt cleanliness. Awaiting one more post-restart validation turn.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass 2)** switched prompt capture from `before_agent_reply.cleanedBody` to `before_dispatch.body` / `content`, because the earlier path still stored Discord wrapper metadata. This should bind capture to the dispatch-stage human message instead of the prompt-builder artifact. Awaiting one more post-restart turn to verify on Dalidou.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`, polish pass)** tightened the OpenClaw capture plugin to use `before_agent_reply.cleanedBody` instead of the raw prompt-build input, which should prevent Discord wrapper metadata from being stored as the interaction prompt. Added `agent_end` cleanup and updated plugin docs. A fresh post-restart user turn is still needed to verify prompt cleanliness on Dalidou.
- **2026-04-12 Codex (branch `codex/openclaw-capture-plugin`)** added a minimal external OpenClaw plugin at `openclaw-plugins/atocore-capture/` that mirrors Claude Code capture semantics: user-triggered assistant turns are POSTed to AtoCore `/interactions` with `client="openclaw"` and `reinforce=true`, fail-open, no extraction in-path. For live verification, temporarily added the local plugin load path to OpenClaw config and restarted the gateway so the plugin can load. Branch truth is ready; end-to-end verification still needs one fresh post-restart OpenClaw user turn to confirm new `client=openclaw` interactions appear on Dalidou.

View File

@@ -3,7 +3,7 @@
Minimal OpenClaw plugin that mirrors Claude Code's `capture_stop.py` behavior:
- watches user-triggered assistant turns
- uses OpenClaw's dispatch-stage message body (`before_dispatch.body`) so AtoCore stores the real prompt instead of the full inbound wrapper
- uses OpenClaw's dispatch-stage message body (`before_dispatch.body`) for the human prompt, then pairs it with the actual outbound assistant message on `message_sending`
- POSTs `prompt` + `response` to `POST /interactions`
- sets `client="openclaw"`
- sets `reinforce=true`
@@ -27,5 +27,6 @@ If `baseUrl` is omitted, the plugin uses `ATOCORE_BASE_URL` or defaults to `http
- Project detection is intentionally left empty for now. Unscoped capture is acceptable because AtoCore's extraction pipeline handles unscoped interactions.
- Prompt cleaning is done inside the plugin by reading OpenClaw's dispatch-stage message body instead of the raw prompt-build input.
- Turn pairing is done by caching the prompt on dispatch and posting only when OpenClaw emits the outbound assistant message, which is more reliable than pairing against raw model output events.
- Extraction is **not** part of the capture path. This plugin only records interactions and lets AtoCore reinforcement run automatically.
- The plugin captures only user-triggered turns, not heartbeats or system-only runs.

View File

@@ -20,6 +20,30 @@ function shouldCapturePrompt(prompt, minLength) {
return text.length >= minLength;
}
function buildKeys(...values) {
return [...new Set(values.map((v) => trimText(v)).filter(Boolean))];
}
function rememberPending(store, keys, payload) {
for (const key of keys) store.set(key, payload);
}
function takePending(store, keys) {
for (const key of keys) {
const value = store.get(key);
if (value) {
for (const k of keys) store.delete(k);
store.delete(key);
return value;
}
}
return null;
}
function clearPending(store, keys) {
for (const key of keys) store.delete(key);
}
async function postInteraction(baseUrl, payload, logger) {
try {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/interactions`, {
@@ -50,29 +74,28 @@ export default definePluginEntry({
const config = api.getConfig?.() || {};
const minPromptLength = Number(config.minPromptLength || DEFAULT_MIN_PROMPT_LENGTH);
const prompt = trimText(event?.body || event?.content || "");
const key = ctx?.sessionKey || event?.sessionKey;
if (!key) return;
const keys = buildKeys(ctx?.sessionKey, ctx?.sessionId, event?.sessionKey, event?.sessionId, ctx?.conversationId, event?.conversationId);
if (!keys.length) return;
if (!shouldCapturePrompt(prompt, minPromptLength)) {
pendingBySession.delete(key);
clearPending(pendingBySession, keys);
return;
}
pendingBySession.set(key, {
rememberPending(pendingBySession, keys, {
prompt,
sessionId: key,
sessionKey: key,
sessionId: trimText(ctx?.sessionId || event?.sessionId || ""),
sessionKey: trimText(ctx?.sessionKey || event?.sessionKey || ""),
conversationId: trimText(ctx?.conversationId || event?.conversationId || ""),
project: ""
});
});
api.on("llm_output", async (event, ctx) => {
if (ctx?.trigger && ctx.trigger !== "user") return;
const key = ctx.sessionKey || ctx.sessionId;
const pending = pendingBySession.get(key);
api.on("message_sending", async (event, ctx) => {
const keys = buildKeys(ctx?.sessionKey, ctx?.sessionId, ctx?.conversationId);
const pending = takePending(pendingBySession, keys);
if (!pending) return;
const assistantTexts = Array.isArray(event?.assistantTexts) ? event.assistantTexts : [];
const response = truncateResponse(
trimText(assistantTexts.join("\n\n")),
trimText(event?.content || ""),
Number((api.getConfig?.() || {}).maxResponseLength || DEFAULT_MAX_RESPONSE_LENGTH)
);
if (!response) return;
@@ -83,23 +106,20 @@ export default definePluginEntry({
prompt: pending.prompt,
response,
client: "openclaw",
session_id: pending.sessionKey || pending.sessionId,
session_id: pending.sessionKey || pending.sessionId || pending.conversationId,
project: pending.project || "",
reinforce: true
};
await postInteraction(baseUrl, payload, logger);
pendingBySession.delete(key);
});
api.on("agent_end", async (event) => {
const key = event?.sessionKey || event?.sessionId;
if (key) pendingBySession.delete(key);
clearPending(pendingBySession, buildKeys(event?.sessionKey, event?.sessionId));
});
api.on("session_end", async (event) => {
const key = event?.sessionKey || event?.sessionId;
if (key) pendingBySession.delete(key);
clearPending(pendingBySession, buildKeys(event?.sessionKey, event?.sessionId));
});
}
});