From 0570558bd4a258c9b8200bac14d0cfbcc102d13d Mon Sep 17 00:00:00 2001 From: Andrew Lauppe Date: Sat, 24 Jan 2026 17:55:51 -0500 Subject: [PATCH] feat(hooks): improve session-memory with LLM summarization The session-memory hook now generates proper summaries instead of capturing just 15 lines of raw content. Changes: - Read full session transcript instead of last 15 lines - Filter out noise (heartbeats, NO_REPLY, tool blocks, system messages) - Use LLM to generate structured summary with: - Topics discussed - Decisions made - Outcomes achieved - Open items remaining - Fall back to raw content if LLM summarization fails - Update HOOK.md documentation This makes /new actually preserve useful session context for future memory recall, rather than capturing mostly heartbeat noise. --- src/hooks/bundled/session-memory/HOOK.md | 63 +++++- src/hooks/bundled/session-memory/handler.ts | 231 ++++++++++++++++---- 2 files changed, 248 insertions(+), 46 deletions(-) diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md index cc3eab0a2..ecc8b6d7a 100644 --- a/src/hooks/bundled/session-memory/HOOK.md +++ b/src/hooks/bundled/session-memory/HOOK.md @@ -22,15 +22,24 @@ Automatically saves session context to your workspace memory when you issue the When you run `/new` to start a fresh session: -1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript -2. **Extracts conversation** - Reads the last 15 lines of conversation from the session -3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content -4. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md` -5. **Sends confirmation** - Notifies you with the file path +1. **Reads the full session transcript** - Parses the JSONL session file +2. **Filters out noise** - Removes heartbeats, NO_REPLY, tool blocks, and system messages +3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug +4. **Summarizes via LLM** - Creates a structured summary of the session content +5. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md` + +## Summary Format + +The LLM generates a structured summary including: + +- **Topics**: Main subjects discussed +- **Decisions**: Key decisions or conclusions reached +- **Outcomes**: What was accomplished or resolved +- **Open Items**: Unfinished tasks or questions (if any) ## Output Format -Memory files are created with the following format: +Memory files are created with the following structure: ```markdown # Session: 2026-01-16 14:30:00 UTC @@ -38,6 +47,21 @@ Memory files are created with the following format: - **Session Key**: agent:main:main - **Session ID**: abc123def456 - **Source**: telegram + +## Summary + +**Topics**: API integration, deployment pipeline + +**Decisions**: +- Use REST over GraphQL for the initial implementation +- Deploy to staging before production + +**Outcomes**: +- Created initial endpoint scaffolding +- Configured CI/CD workflow + +**Open Items**: +- Need to finalize auth strategy with the team ``` ## Filename Examples @@ -49,19 +73,31 @@ The LLM generates descriptive slugs based on your conversation: - `2026-01-16-bug-fix.md` - Debugging session - `2026-01-16-1430.md` - Fallback timestamp if slug generation fails +## Noise Filtering + +The following are automatically filtered out: + +- Heartbeat prompts (`Read HEARTBEAT.md...`) +- Heartbeat responses (`HEARTBEAT_OK`) +- Silent replies (`NO_REPLY`) +- System messages +- Slash commands +- Empty messages + ## Requirements - **Config**: `workspace.dir` must be set (automatically configured during onboarding) -The hook uses your configured LLM provider to generate slugs, so it works with any provider (Anthropic, OpenAI, etc.). +The hook uses your configured LLM provider to generate summaries, so it works with any provider (Anthropic, OpenAI, etc.). ## Configuration No additional configuration required. The hook automatically: - Uses your workspace directory (`~/clawd` by default) -- Uses your configured LLM for slug generation -- Falls back to timestamp slugs if LLM is unavailable +- Uses your configured LLM for slug and summary generation +- Falls back to raw content if LLM summarization fails +- Falls back to timestamp slugs if slug generation fails ## Disabling @@ -84,3 +120,12 @@ Or remove it from your config: } } ``` + +## Token Usage + +This hook makes two LLM calls when `/new` is issued: + +1. **Slug generation**: ~2k token context, small output +2. **Summary generation**: Up to 50k token context, ~500 token output + +If you want to minimize token usage, you can disable the hook and manually run `/compact` before `/new` instead. diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index 14b486fcc..ef113eba3 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -2,31 +2,57 @@ * Session memory hook handler * * Saves session context to memory when /new command is triggered - * Creates a new dated memory file with LLM-generated slug + * Creates a new dated memory file with LLM-generated slug and summary */ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import type { ClawdbotConfig } from "../../../config/config.js"; -import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveAgentDir } from "../../../agents/agent-scope.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import type { HookHandler } from "../../hooks.js"; +/** Patterns to filter out noise from session content */ +const NOISE_PATTERNS = [ + /^Read HEARTBEAT\.md/i, + /^HEARTBEAT_OK$/i, + /^NO_REPLY$/i, + /^\s*$/, + /^System:/, +]; + +/** Maximum characters to send to LLM for summarization */ +const MAX_CONTENT_CHARS = 50000; + +/** Maximum characters for slug generation (smaller context) */ +const MAX_SLUG_CHARS = 2000; + /** - * Read recent messages from session file for slug generation + * Check if a message should be filtered out as noise */ -async function getRecentSessionContent(sessionFilePath: string): Promise { +function isNoise(text: string): boolean { + return NOISE_PATTERNS.some((pattern) => pattern.test(text.trim())); +} + +/** + * Extract all meaningful messages from session file + * Filters out heartbeats, NO_REPLY, tool blocks, and other noise + */ +async function getFullSessionContent(sessionFilePath: string): Promise<{ + messages: string[]; + userMessages: string[]; + assistantMessages: string[]; +} | null> { try { const content = await fs.readFile(sessionFilePath, "utf-8"); const lines = content.trim().split("\n"); - // Get last 15 lines (recent conversation) - const recentLines = lines.slice(-15); - - // Parse JSONL and extract messages const messages: string[] = []; - for (const line of recentLines) { + const userMessages: string[] = []; + const assistantMessages: string[] = []; + + for (const line of lines) { try { const entry = JSON.parse(line); // Session files have entries with type="message" containing a nested message object @@ -35,11 +61,32 @@ async function getRecentSessionContent(sessionFilePath: string): Promise c.type === "text")?.text - : msg.content; - if (text && !text.startsWith("/")) { - messages.push(`${role}: ${text}`); + let text: string | undefined; + if (Array.isArray(msg.content)) { + // Find text content blocks, skip tool_use/tool_result + const textBlock = msg.content.find( + (c: any) => c.type === "text" && typeof c.text === "string", + ); + text = textBlock?.text; + } else if (typeof msg.content === "string") { + text = msg.content; + } + + // Skip if no text, starts with slash command, or is noise + if (!text || text.startsWith("/") || isNoise(text)) { + continue; + } + + // Truncate very long messages to avoid blowing up context + const truncated = text.length > 2000 ? text.slice(0, 2000) + "..." : text; + + const formatted = `${role}: ${truncated}`; + messages.push(formatted); + + if (role === "user") { + userMessages.push(truncated); + } else { + assistantMessages.push(truncated); } } } @@ -48,12 +95,93 @@ async function getRecentSessionContent(sessionFilePath: string): Promise { + let tempSessionFile: string | null = null; + + try { + const { runEmbeddedPiAgent } = await import("../../../agents/pi-embedded.js"); + const agentDir = resolveAgentDir(params.cfg, params.agentId); + + // Create a temporary session file for this one-off LLM call + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-summary-")); + tempSessionFile = path.join(tempDir, "session.jsonl"); + + const prompt = `Summarize this session for future memory recall. Be concise but complete. + +Include: +- **Topics**: Main subjects discussed +- **Decisions**: Key decisions or conclusions reached +- **Outcomes**: What was accomplished or resolved +- **Open Items**: Any unfinished tasks or questions (if applicable) + +Skip routine/administrative messages. Focus on substance. + +Session transcript: +${params.sessionContent} + +Write the summary in Markdown format, suitable for a memory file.`; + + const result = await runEmbeddedPiAgent({ + sessionId: `summary-generator-${Date.now()}`, + sessionKey: "temp:summary-generator", + sessionFile: tempSessionFile, + workspaceDir: params.workspaceDir, + agentDir, + config: params.cfg, + prompt, + timeoutMs: 30_000, // 30 second timeout for summary + runId: `summary-gen-${Date.now()}`, + }); + + // Clean up temp files + try { + await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + + // Extract text from payloads + if (result.payloads && result.payloads.length > 0) { + const text = result.payloads[0]?.text; + if (text) { + return text.trim(); + } + } + + console.error("[session-memory] LLM summary returned no content"); + return null; + } catch (err) { + console.error( + "[session-memory] Summary generation error:", + err instanceof Error ? err.message : String(err), + ); + return null; + } finally { + // Ensure cleanup + if (tempSessionFile) { + try { + await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true }); + } catch { + // Ignore + } + } + } +} + /** * Save session context to memory when /new command is triggered */ @@ -79,7 +207,7 @@ const saveSessionToMemory: HookHandler = async (event) => { const now = new Date(event.timestamp); const dateStr = now.toISOString().split("T")[0]; // YYYY-MM-DD - // Generate descriptive slug from session using LLM + // Get session entry info const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record< string, unknown @@ -94,28 +222,47 @@ const saveSessionToMemory: HookHandler = async (event) => { const sessionFile = currentSessionFile || undefined; let slug: string | null = null; - let sessionContent: string | null = null; + let summary: string | null = null; + let rawContent: string | null = null; if (sessionFile) { - // Get recent conversation content - sessionContent = await getRecentSessionContent(sessionFile); - console.log("[session-memory] sessionContent length:", sessionContent?.length || 0); + // Get full conversation content (filtered) + const parsed = await getFullSessionContent(sessionFile); + console.log("[session-memory] Parsed messages:", parsed?.messages.length || 0); - if (sessionContent && cfg) { - console.log("[session-memory] Calling generateSlugViaLLM..."); - // Dynamically import the LLM slug generator (avoids module caching issues) - // When compiled, handler is at dist/hooks/bundled/session-memory/handler.js - // Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js - const clawdbotRoot = path.resolve( - path.dirname(import.meta.url.replace("file://", "")), - "../..", - ); - const slugGenPath = path.join(clawdbotRoot, "llm-slug-generator.js"); - const { generateSlugViaLLM } = await import(slugGenPath); + if (parsed && parsed.messages.length > 0) { + // Prepare content for LLM (cap at max chars) + const fullContent = parsed.messages.join("\n\n"); + rawContent = fullContent.slice(0, MAX_CONTENT_CHARS); - // Use LLM to generate a descriptive slug - slug = await generateSlugViaLLM({ sessionContent, cfg }); - console.log("[session-memory] Generated slug:", slug); + if (cfg) { + // Generate slug from recent content (smaller context) + const slugContent = parsed.messages.slice(-10).join("\n").slice(0, MAX_SLUG_CHARS); + + console.log("[session-memory] Generating slug..."); + try { + const clawdbotRoot = path.resolve( + path.dirname(import.meta.url.replace("file://", "")), + "../..", + ); + const slugGenPath = path.join(clawdbotRoot, "llm-slug-generator.js"); + const { generateSlugViaLLM } = await import(slugGenPath); + slug = await generateSlugViaLLM({ sessionContent: slugContent, cfg }); + console.log("[session-memory] Generated slug:", slug); + } catch (err) { + console.error("[session-memory] Slug generation failed:", err); + } + + // Generate full summary via LLM + console.log("[session-memory] Generating summary..."); + summary = await generateSummaryViaLLM({ + sessionContent: rawContent, + cfg, + agentId, + workspaceDir, + }); + console.log("[session-memory] Summary generated:", summary ? "yes" : "no"); + } } } @@ -149,9 +296,19 @@ const saveSessionToMemory: HookHandler = async (event) => { "", ]; - // Include conversation content if available - if (sessionContent) { - entryParts.push("## Conversation Summary", "", sessionContent, ""); + // Include LLM-generated summary if available + if (summary) { + entryParts.push("## Summary", "", summary, ""); + } else if (rawContent) { + // Fallback to raw content if summary generation failed + entryParts.push( + "## Conversation Excerpt", + "", + "_Note: LLM summary unavailable, showing raw content_", + "", + rawContent.slice(0, 5000), + "", + ); } const entry = entryParts.join("\n");