fix: prevent trivial conversations from overwriting meaningful state

Short conversations (< 2 real user messages) no longer overwrite
existing meaningful state. This prevents the "recovery death spiral"
where:
1. Secret told → state saved 
2. /new → state injected → model ignores it → "I don't remember"
3. agent_end overwrites good state with "I don't remember" 
4. All subsequent recoveries fail permanently

Now step 3 is blocked: a 1-message "what was my secret?" conversation
won't overwrite the previously saved secret.

Also strips channel metadata and /new-style short commands from
the real message count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 17:25:04 +10:00
co-authored by Claude Opus 4.6
parent 2fc42661cf
commit 24221ea749
+30 -5
View File
@@ -238,23 +238,48 @@ const plugin = {
const statePath = resolveStatePath(ws); const statePath = resolveStatePath(ws);
if (!statePath) return; if (!statePath) return;
// Always extract from the latest conversation — the whole point is to const messages = event?.messages;
// capture what happened *this* session so the next session can recover. if (!messages || messages.length === 0) {
const newState = extractStateFromMessages(event?.messages); log.info?.("[memory-continuity] No messages in session, skipping");
return;
}
// Count real user messages (exclude system, metadata-only, short commands)
const realUserMsgs = messages.filter(m => {
if (m?.role !== "user") return false;
const text = typeof m?.content === "string" ? m.content
: Array.isArray(m?.content) ? m.content.filter(b => b?.type === "text").map(b => b.text).join("\n")
: "";
const cleaned = text
.replace(/^Conversation info \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.replace(/^Sender \(untrusted metadata\):[\s\S]*?\n\n/m, "")
.trim();
// Skip very short messages like "/new", "/status", single-word queries
return cleaned.length > 10;
});
// Don't overwrite meaningful state with trivial conversations
// (e.g., "/new" then "what was my secret?" — only 1 real message)
const existing = readFile(statePath);
if (existing && buildSnapshot(existing) && realUserMsgs.length < 2) {
log.info?.("[memory-continuity] Conversation too short to overwrite existing state (" + realUserMsgs.length + " real msgs)");
return;
}
const newState = extractStateFromMessages(messages);
if (!newState) { if (!newState) {
log.info?.("[memory-continuity] No extractable state from conversation"); log.info?.("[memory-continuity] No extractable state from conversation");
return; return;
} }
// Archive previous state if it exists // Archive previous state if it exists
const existing = readFile(statePath);
if (existing) { if (existing) {
const archivePath = statePath.replace(/CURRENT_STATE\.md$/, `STATE_ARCHIVE_${Date.now()}.md`); const archivePath = statePath.replace(/CURRENT_STATE\.md$/, `STATE_ARCHIVE_${Date.now()}.md`);
writeFile(archivePath, existing); writeFile(archivePath, existing);
} }
writeFile(statePath, newState); writeFile(statePath, newState);
log.info?.("[memory-continuity] Updated state from conversation"); log.info?.("[memory-continuity] Updated state from conversation (" + realUserMsgs.length + " real msgs)");
}, { priority: 90 }); // low priority, run after other hooks }, { priority: 90 }); // low priority, run after other hooks
// ------------------------------------------------------------------ // ------------------------------------------------------------------