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.
This commit is contained in:
Andrew Lauppe 2026-01-24 17:55:51 -05:00
parent 6859e1e6a6
commit 0570558bd4
2 changed files with 248 additions and 46 deletions

View File

@ -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: 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 1. **Reads the full session transcript** - Parses the JSONL session file
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session 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 based on conversation content 3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md` 4. **Summarizes via LLM** - Creates a structured summary of the session content
5. **Sends confirmation** - Notifies you with the file path 5. **Saves to memory** - Creates a new file at `<workspace>/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 ## Output Format
Memory files are created with the following format: Memory files are created with the following structure:
```markdown ```markdown
# Session: 2026-01-16 14:30:00 UTC # 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 Key**: agent:main:main
- **Session ID**: abc123def456 - **Session ID**: abc123def456
- **Source**: telegram - **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 ## 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-bug-fix.md` - Debugging session
- `2026-01-16-1430.md` - Fallback timestamp if slug generation fails - `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 ## Requirements
- **Config**: `workspace.dir` must be set (automatically configured during onboarding) - **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 ## Configuration
No additional configuration required. The hook automatically: No additional configuration required. The hook automatically:
- Uses your workspace directory (`~/clawd` by default) - Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation - Uses your configured LLM for slug and summary generation
- Falls back to timestamp slugs if LLM is unavailable - Falls back to raw content if LLM summarization fails
- Falls back to timestamp slugs if slug generation fails
## Disabling ## 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.

View File

@ -2,31 +2,57 @@
* Session memory hook handler * Session memory hook handler
* *
* Saves session context to memory when /new command is triggered * 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 fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import type { ClawdbotConfig } from "../../../config/config.js"; 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 { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
import type { HookHandler } from "../../hooks.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<string | null> { 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 { try {
const content = await fs.readFile(sessionFilePath, "utf-8"); const content = await fs.readFile(sessionFilePath, "utf-8");
const lines = content.trim().split("\n"); 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[] = []; const messages: string[] = [];
for (const line of recentLines) { const userMessages: string[] = [];
const assistantMessages: string[] = [];
for (const line of lines) {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object // Session files have entries with type="message" containing a nested message object
@ -35,11 +61,32 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
const role = msg.role; const role = msg.role;
if ((role === "user" || role === "assistant") && msg.content) { if ((role === "user" || role === "assistant") && msg.content) {
// Extract text content // Extract text content
const text = Array.isArray(msg.content) let text: string | undefined;
? msg.content.find((c: any) => c.type === "text")?.text if (Array.isArray(msg.content)) {
: msg.content; // Find text content blocks, skip tool_use/tool_result
if (text && !text.startsWith("/")) { const textBlock = msg.content.find(
messages.push(`${role}: ${text}`); (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<string
} }
} }
return messages.join("\n"); return { messages, userMessages, assistantMessages };
} catch { } catch {
return null; return null;
} }
} }
/**
* Generate LLM summary of session content
*/
async function generateSummaryViaLLM(params: {
sessionContent: string;
cfg: ClawdbotConfig;
agentId: string;
workspaceDir: string;
}): Promise<string | null> {
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 * 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 now = new Date(event.timestamp);
const dateStr = now.toISOString().split("T")[0]; // YYYY-MM-DD 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< const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record<
string, string,
unknown unknown
@ -94,28 +222,47 @@ const saveSessionToMemory: HookHandler = async (event) => {
const sessionFile = currentSessionFile || undefined; const sessionFile = currentSessionFile || undefined;
let slug: string | null = null; let slug: string | null = null;
let sessionContent: string | null = null; let summary: string | null = null;
let rawContent: string | null = null;
if (sessionFile) { if (sessionFile) {
// Get recent conversation content // Get full conversation content (filtered)
sessionContent = await getRecentSessionContent(sessionFile); const parsed = await getFullSessionContent(sessionFile);
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0); console.log("[session-memory] Parsed messages:", parsed?.messages.length || 0);
if (sessionContent && cfg) { if (parsed && parsed.messages.length > 0) {
console.log("[session-memory] Calling generateSlugViaLLM..."); // Prepare content for LLM (cap at max chars)
// Dynamically import the LLM slug generator (avoids module caching issues) const fullContent = parsed.messages.join("\n\n");
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js rawContent = fullContent.slice(0, MAX_CONTENT_CHARS);
// 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);
// Use LLM to generate a descriptive slug if (cfg) {
slug = await generateSlugViaLLM({ sessionContent, cfg }); // Generate slug from recent content (smaller context)
console.log("[session-memory] Generated slug:", slug); 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 // Include LLM-generated summary if available
if (sessionContent) { if (summary) {
entryParts.push("## Conversation Summary", "", sessionContent, ""); 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"); const entry = entryParts.join("\n");