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:
parent
6859e1e6a6
commit
0570558bd4
@ -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 `<workspace>/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 `<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
|
||||
|
||||
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.
|
||||
|
||||
@ -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<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 {
|
||||
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<string
|
||||
const role = msg.role;
|
||||
if ((role === "user" || role === "assistant") && msg.content) {
|
||||
// Extract text content
|
||||
const text = Array.isArray(msg.content)
|
||||
? msg.content.find((c: any) => 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<string
|
||||
}
|
||||
}
|
||||
|
||||
return messages.join("\n");
|
||||
return { messages, userMessages, assistantMessages };
|
||||
} catch {
|
||||
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
|
||||
*/
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user