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:
|
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.
|
||||||
|
|||||||
@ -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
|
|
||||||
|
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(
|
const clawdbotRoot = path.resolve(
|
||||||
path.dirname(import.meta.url.replace("file://", "")),
|
path.dirname(import.meta.url.replace("file://", "")),
|
||||||
"../..",
|
"../..",
|
||||||
);
|
);
|
||||||
const slugGenPath = path.join(clawdbotRoot, "llm-slug-generator.js");
|
const slugGenPath = path.join(clawdbotRoot, "llm-slug-generator.js");
|
||||||
const { generateSlugViaLLM } = await import(slugGenPath);
|
const { generateSlugViaLLM } = await import(slugGenPath);
|
||||||
|
slug = await generateSlugViaLLM({ sessionContent: slugContent, cfg });
|
||||||
// Use LLM to generate a descriptive slug
|
|
||||||
slug = await generateSlugViaLLM({ sessionContent, cfg });
|
|
||||||
console.log("[session-memory] Generated slug:", slug);
|
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");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user