From 73c94a9b3f36d790a96da35b8b9018e92f2e8314 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Sat, 28 Mar 2026 14:07:45 +0300 Subject: [PATCH] feat: pre-edit guardrails + session summarization on start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 2: Pre-edit guardrails — pre-tool-use.js now separates warnings (bugfix, guidance, anti-pattern, gotcha, security) from general context. Warnings appear first with clear header so agent reviews them before editing. Wave 3: Session summarization — session-start.js triggers summarization of the most recent unsummarized session (fire-and-forget, 1 per start). Workaround for CC bug #19225 (stop hook doesn't fire) so summaries accumulate and appear on the Dashboard Summaries tab. --- plugin/engram/hooks/pre-tool-use.js | 62 +++++++++++++++++++++------- plugin/engram/hooks/session-start.js | 34 +++++++++++++++ 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/plugin/engram/hooks/pre-tool-use.js b/plugin/engram/hooks/pre-tool-use.js index fbfbdca8..3cd56db7 100644 --- a/plugin/engram/hooks/pre-tool-use.js +++ b/plugin/engram/hooks/pre-tool-use.js @@ -52,37 +52,69 @@ async function handlePreToolUse(ctx, input) { return ''; } - // Build block for systemMessage injection - let context = '\n'; - context += `# Known Context for ${escapeXmlTags(filePath)}\n`; - context += `Found ${observations.length} relevant observation(s) about this file.\n\n`; + // Separate warnings (bugfix, guidance, anti-pattern) from general context + const warnings = []; + const contextObs = []; + const warningTypes = { bugfix: true, guidance: true }; + const warningConcepts = { 'anti-pattern': true, gotcha: true, 'error-handling': true, security: true }; for (const obs of observations) { if (!obs || typeof obs !== 'object') continue; - const title = escapeXmlTags(getString(obs.title)); - const obsType = escapeXmlTags(getString(obs.type)).toUpperCase(); - const narrative = escapeXmlTags(getString(obs.narrative)); + const obsType = getString(obs.type).toLowerCase(); + const concepts = Array.isArray(obs.concepts) ? obs.concepts : []; + const isWarning = warningTypes[obsType] || concepts.some((c) => warningConcepts[c]); + if (isWarning) { + warnings.push(obs); + } else { + contextObs.push(obs); + } + } - context += `## [${obsType}] ${title}\n`; - if (narrative) { - context += `${narrative}\n`; + // Build block for systemMessage injection + let context = '\n'; + context += `# Known Context for ${escapeXmlTags(filePath)}\n`; + + // Warnings first — guardrails the agent must respect before editing + if (warnings.length > 0) { + context += `\n## WARNINGS (${warnings.length}) — review before editing\n\n`; + for (const obs of warnings) { + const title = escapeXmlTags(getString(obs.title)); + const type = escapeXmlTags(getString(obs.type)).toUpperCase(); + const narrative = escapeXmlTags(getString(obs.narrative)); + context += `### [${type}] ${title}\n`; + if (narrative) context += `${narrative}\n`; + const facts = Array.isArray(obs.facts) ? obs.facts : []; + for (const fact of facts) { + if (typeof fact === 'string' && fact !== '') { + context += `- ${escapeXmlTags(fact)}\n`; + } + } + context += '\n'; } + } - const facts = Array.isArray(obs.facts) ? obs.facts : []; - if (facts.length > 0) { - context += 'Key facts:\n'; + // General context observations + if (contextObs.length > 0) { + context += `\n## Context (${contextObs.length} observations)\n\n`; + for (const obs of contextObs) { + const title = escapeXmlTags(getString(obs.title)); + const type = escapeXmlTags(getString(obs.type)).toUpperCase(); + const narrative = escapeXmlTags(getString(obs.narrative)); + context += `### [${type}] ${title}\n`; + if (narrative) context += `${narrative}\n`; + const facts = Array.isArray(obs.facts) ? obs.facts : []; for (const fact of facts) { if (typeof fact === 'string' && fact !== '') { context += `- ${escapeXmlTags(fact)}\n`; } } + context += '\n'; } - context += '\n'; } context += ''; - console.error(`[pre-tool-use] Injecting ${observations.length} file-context observations for ${filePath}`); + console.error(`[pre-tool-use] Injecting ${warnings.length} warnings + ${contextObs.length} context for ${filePath}`); // Return systemMessage — no decision field needed (approve by default) return JSON.stringify({ systemMessage: context }); diff --git a/plugin/engram/hooks/session-start.js b/plugin/engram/hooks/session-start.js index be07a34e..e9ee6108 100644 --- a/plugin/engram/hooks/session-start.js +++ b/plugin/engram/hooks/session-start.js @@ -200,6 +200,40 @@ async function handleSessionStart(ctx, input) { } } + // Trigger summarization of recent unsummarized sessions (fire-and-forget). + // Stop hook doesn't fire reliably (CC bug #19225), so we summarize here instead. + // Limited to 1 session per start to avoid flooding the LLM. + if (dbSessionId) { + try { + const sessionsResp = await lib.requestGet('/api/sessions/list?limit=5', 3000); + const sessions = sessionsResp && Array.isArray(sessionsResp.sessions) + ? sessionsResp.sessions + : []; + + for (const sess of sessions) { + if (!sess || typeof sess.id !== 'number') continue; + // Skip current session + if (sess.id === dbSessionId) continue; + // Skip empty sessions (0 prompts) + if (typeof sess.prompt_counter === 'number' && sess.prompt_counter === 0) continue; + // Skip sessions that already have a summary + if (sess.summary && typeof sess.summary === 'string' && sess.summary.length > 0) continue; + + // Fire-and-forget summarization (5s timeout) + lib.requestPost(`/api/sessions/${sess.id}/summarize`, { + lastUserMessage: '', + lastAssistant: '', + }, 5000).catch((err) => { + console.error(`[engram] session ${sess.id} summarize failed: ${err.message}`); + }); + console.error(`[engram] Triggered summarization for session ${sess.id}`); + break; // Only 1 per session-start + } + } catch (err) { + console.error(`[engram] Unsummarized session check failed: ${err.message}`); + } + } + return contextBuilder; }