diff --git a/src/hooks/bundled/context-aggregator/HOOK.md b/src/hooks/bundled/context-aggregator/HOOK.md new file mode 100644 index 000000000..03bc6716c --- /dev/null +++ b/src/hooks/bundled/context-aggregator/HOOK.md @@ -0,0 +1,78 @@ +--- +name: context-aggregator +description: "Aggregates memory context from multiple sources into CONTEXT.md for session continuity" +homepage: https://docs.clawd.bot/hooks#context-aggregator +metadata: + { + "clawdbot": + { + "emoji": "🔄", + "events": ["gateway:startup", "command:new"], + "requires": { "config": ["workspace.dir"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }], + }, + } +--- + +# Context Aggregator Hook + +Aggregates memory from multiple sources into a unified `CONTEXT.md` file that provides session continuity. + +## What It Does + +1. **Collects Memory Sources**: + - Recent hourly backups (last 48 hours) + - Daily notes (`memory/YYYY-MM-DD.md`) + - Active projects (`memory/active-projects.md`) + - Session summaries + - Recent conversation excerpts from session logs + +2. **Generates CONTEXT.md**: Creates a unified context file in your workspace + +3. **Updates Activity State**: Tracks last user message time from session logs + +## Output + +`CONTEXT.md` contains: + +- Memory management reminders +- Recent hourly backups +- Recent daily notes +- Active projects +- Session summaries +- Recent conversation excerpts +- Current heartbeat state + +## When It Runs + +- On `gateway:startup` - Refreshes context when Clawdbot starts +- On `command:new` - Refreshes context for new session + +For more frequent updates, configure a cron job to trigger it. + +## Requirements + +- **Config**: `workspace.dir` must be set + +## Configuration + +```json +{ + "hooks": { + "internal": { + "entries": { + "context-aggregator": { + "enabled": true, + "lookbackHours": 48 + } + } + } + } +} +``` + +## Disabling + +```bash +clawdbot hooks disable context-aggregator +``` diff --git a/src/hooks/bundled/context-aggregator/handler.ts b/src/hooks/bundled/context-aggregator/handler.ts new file mode 100644 index 000000000..feb3bb3df --- /dev/null +++ b/src/hooks/bundled/context-aggregator/handler.ts @@ -0,0 +1,404 @@ +/** + * Context aggregator hook handler + * + * Aggregates memory from multiple sources into CONTEXT.md + * for session continuity across /new resets. + */ + +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 { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import type { HookHandler } from "../../hooks.js"; + +// ============================================ +// Types +// ============================================ + +interface BackupState { + lastBackup: number | null; + lastDistill: number | null; + lastSessionSummary: number | null; + lastUserMessage: number | null; + backupActive: boolean; +} + +interface HookConfig { + lookbackHours?: number; +} + +// ============================================ +// Constants +// ============================================ + +const DEFAULT_LOOKBACK_HOURS = 48; + +// ============================================ +// Helpers +// ============================================ + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readFileIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + return null; + } +} + +async function getFilesModifiedSince( + dir: string, + sinceTimestamp: number, + pattern?: RegExp +): Promise { + try { + const files = await fs.readdir(dir); + const results: string[] = []; + + for (const file of files) { + if (pattern && !pattern.test(file)) continue; + + const filePath = path.join(dir, file); + const stat = await fs.stat(filePath); + + if (stat.mtimeMs / 1000 > sinceTimestamp) { + results.push(filePath); + } + } + + // Sort by modification time, newest first + results.sort((a, b) => b.localeCompare(a)); + return results; + } catch { + return []; + } +} + +async function getRecentConversation(sessionsDir: string, limit: number = 20): Promise { + try { + const files = await fs.readdir(sessionsDir); + const sessionFiles = files + .filter((f) => f.endsWith(".jsonl") && !f.includes("probe")) + .map((f) => path.join(sessionsDir, f)); + + if (sessionFiles.length === 0) return []; + + // Get most recently modified session file + const stats = await Promise.all( + sessionFiles.map(async (f) => ({ file: f, mtime: (await fs.stat(f)).mtimeMs })) + ); + stats.sort((a, b) => b.mtime - a.mtime); + const latestSession = stats[0].file; + + // Read and parse session + const content = await fs.readFile(latestSession, "utf-8"); + const lines = content.trim().split("\n").slice(-100); + + const messages: string[] = []; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message) { + const msg = entry.message; + const role = msg.role; + if ((role === "user" || role === "assistant") && msg.content) { + const text = Array.isArray(msg.content) + ? msg.content.find((c: any) => c.type === "text")?.text + : msg.content; + if (text && typeof text === "string") { + const truncated = text.length > 500 ? text.slice(0, 500) + "..." : text; + messages.push(`**${role}:** ${truncated}`); + } + } + } + } catch { + // Skip invalid JSON + } + } + + return messages.slice(-limit); + } catch { + return []; + } +} + +async function getLastUserMessageTime(sessionsDir: string): Promise { + try { + const files = await fs.readdir(sessionsDir); + const sessionFiles = files + .filter((f) => f.endsWith(".jsonl") && !f.includes("probe")) + .map((f) => path.join(sessionsDir, f)); + + if (sessionFiles.length === 0) return null; + + const stats = await Promise.all( + sessionFiles.map(async (f) => ({ file: f, mtime: (await fs.stat(f)).mtimeMs })) + ); + stats.sort((a, b) => b.mtime - a.mtime); + const latestSession = stats[0].file; + + const content = await fs.readFile(latestSession, "utf-8"); + const lines = content.trim().split("\n").slice(-200); + + let lastUserTs: number | null = null; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message?.role === "user" && entry.timestamp) { + const ts = new Date(entry.timestamp).getTime(); + if (!lastUserTs || ts > lastUserTs) { + lastUserTs = ts; + } + } + } catch { + // Skip invalid JSON + } + } + + return lastUserTs ? Math.floor(lastUserTs / 1000) : null; + } catch { + return null; + } +} + +// ============================================ +// Context Generation +// ============================================ + +async function generateContext( + workspaceDir: string, + sessionsDir: string, + lookbackHours: number +): Promise { + const memoryDir = path.join(workspaceDir, "memory"); + const hourlyDir = path.join(memoryDir, "hourly-backups"); + const stateFile = path.join(memoryDir, "heartbeat-state.json"); + + const now = new Date(); + const lookbackTimestamp = Math.floor(Date.now() / 1000) - lookbackHours * 3600; + + const lines: string[] = [ + "# CONTEXT.md - Auto-Generated Session Context", + "", + `**Generated:** ${now.toISOString()}`, + "", + ]; + + // Section 1: Memory Management Reminders + lines.push("## 🧠 Memory Management Reminders"); + lines.push(""); + lines.push("**On EVERY user message:**"); + lines.push("1. Update `memory/heartbeat-state.json` with current timestamp + `backupActive=true`"); + lines.push("2. Actively use `memory_store` for decisions, preferences, context, opinions"); + lines.push(""); + lines.push("**On session end (/new or gap detected):**"); + lines.push("1. Write session summary to `memory/YYYY-MM-DD-session-summary.md`"); + lines.push("2. Update `memory/active-projects.md`"); + lines.push("3. Final `memory_store` calls for anything not captured"); + lines.push(""); + lines.push("**During conversation:**"); + lines.push("- Use `memory_recall` before responding to complex questions"); + lines.push("- Aim for 2-5 `memory_store` calls per substantial conversation"); + lines.push(""); + + // Section 2: Recent Hourly Backups + lines.push("## 📁 Recent Hourly Backups (Last 48h)"); + lines.push(""); + + if (await fileExists(hourlyDir)) { + const backupFiles = await getFilesModifiedSince(hourlyDir, lookbackTimestamp, /\.md$/); + + if (backupFiles.length > 0) { + for (const file of backupFiles.slice(0, 20)) { + const content = await readFileIfExists(file); + if (content) { + lines.push(`### ${path.basename(file)}`); + lines.push(""); + // Truncate very long backups + const truncated = content.length > 3000 ? content.slice(0, 3000) + "\n\n*[truncated]*" : content; + lines.push(truncated); + lines.push(""); + } + } + } else { + lines.push("*No hourly backups in last 48 hours.*"); + lines.push(""); + } + } else { + lines.push("*Hourly backups directory not found.*"); + lines.push(""); + } + + // Section 3: Recent Daily Notes + lines.push("## 📅 Recent Daily Notes"); + lines.push(""); + + const today = now.toISOString().split("T")[0]; + const yesterday = new Date(Date.now() - 86400000).toISOString().split("T")[0]; + + for (const date of [today, yesterday]) { + const dailyFile = path.join(memoryDir, `${date}.md`); + const content = await readFileIfExists(dailyFile); + if (content) { + lines.push(`### ${date}`); + lines.push(""); + lines.push(content); + lines.push(""); + } + } + + // Section 4: Active Projects + lines.push("## 🎯 Active Projects"); + lines.push(""); + + const projectsFile = path.join(memoryDir, "active-projects.md"); + const projectsContent = await readFileIfExists(projectsFile); + if (projectsContent) { + lines.push(projectsContent); + } else { + lines.push("*No active projects file.*"); + } + lines.push(""); + + // Section 5: Recent Session Summaries + lines.push("## 📋 Recent Session Summaries"); + lines.push(""); + + const summaryFiles = await getFilesModifiedSince(memoryDir, lookbackTimestamp, /-session-summary\.md$/); + if (summaryFiles.length > 0) { + for (const file of summaryFiles.slice(0, 5)) { + const content = await readFileIfExists(file); + if (content) { + lines.push(`### ${path.basename(file)}`); + lines.push(""); + lines.push(content); + lines.push(""); + } + } + } else { + lines.push("*No recent session summaries.*"); + lines.push(""); + } + + // Section 6: Current State + lines.push("## ⚡ Current State"); + lines.push(""); + + const stateContent = await readFileIfExists(stateFile); + if (stateContent) { + lines.push("```json"); + lines.push(stateContent); + lines.push("```"); + } else { + lines.push("*No state file found.*"); + } + lines.push(""); + + // Section 7: Recent Conversation + lines.push("## 💬 Recent Conversation Excerpts"); + lines.push(""); + + const recentMessages = await getRecentConversation(sessionsDir); + if (recentMessages.length > 0) { + for (const msg of recentMessages) { + lines.push(msg); + lines.push(""); + } + } else { + lines.push("*No recent conversation found.*"); + lines.push(""); + } + + lines.push("---"); + lines.push("*End of auto-generated context.*"); + + return lines.join("\n"); +} + +// ============================================ +// Main Hook Handler +// ============================================ + +const contextAggregatorHandler: HookHandler = async (event) => { + // Trigger on gateway:startup or command:new + const isStartup = event.type === "gateway" && event.action === "startup"; + const isNewCommand = event.type === "command" && event.action === "new"; + + if (!isStartup && !isNewCommand) { + return; + } + + const context = event.context || {}; + const cfg = context.cfg as ClawdbotConfig | undefined; + + if (!cfg) { + console.log("[context-aggregator] No config available, skipping"); + return; + } + + const agentId = resolveAgentIdFromSessionKey(event.sessionKey); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const clawdbotDir = path.join(os.homedir(), ".clawdbot"); + const sessionsDir = path.join(clawdbotDir, "agents", agentId, "sessions"); + const memoryDir = path.join(workspaceDir, "memory"); + const stateFile = path.join(memoryDir, "heartbeat-state.json"); + const contextFile = path.join(workspaceDir, "CONTEXT.md"); + + await fs.mkdir(memoryDir, { recursive: true }); + + // Get config options + const hookConfig = (context.hookConfig || {}) as HookConfig; + const lookbackHours = hookConfig.lookbackHours || DEFAULT_LOOKBACK_HOURS; + + console.log(`[context-aggregator] Generating context (lookback: ${lookbackHours}h)`); + + // Update activity state from session logs + const lastMsgTime = await getLastUserMessageTime(sessionsDir); + if (lastMsgTime) { + const now = Math.floor(Date.now() / 1000); + const timeSinceMsg = now - lastMsgTime; + const hourInSeconds = 3600; + + let state: BackupState; + try { + const stateContent = await fs.readFile(stateFile, "utf-8"); + state = JSON.parse(stateContent); + } catch { + state = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: null, + backupActive: false, + }; + } + + state.lastUserMessage = lastMsgTime; + + if (timeSinceMsg < hourInSeconds) { + state.backupActive = true; + } else if (timeSinceMsg > 2 * hourInSeconds) { + state.backupActive = false; + } + + await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8"); + } + + // Generate and write context + const content = await generateContext(workspaceDir, sessionsDir, lookbackHours); + await fs.writeFile(contextFile, content, "utf-8"); + + console.log(`[context-aggregator] Context written to ${contextFile}`); +}; + +export default contextAggregatorHandler; diff --git a/src/hooks/bundled/session-backup/HOOK.md b/src/hooks/bundled/session-backup/HOOK.md new file mode 100644 index 000000000..e9c92fc84 --- /dev/null +++ b/src/hooks/bundled/session-backup/HOOK.md @@ -0,0 +1,88 @@ +--- +name: session-backup +description: "Automated hourly session backups with activity-based lifecycle management" +homepage: https://docs.clawd.bot/hooks#session-backup +metadata: + { + "clawdbot": + { + "emoji": "📦", + "events": ["command:new", "gateway:startup"], + "requires": { "config": ["workspace.dir"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }], + }, + } +--- + +# Session Backup Hook + +Automatically creates detailed hourly backups of your conversations and manages backup lifecycle based on activity. + +## What It Does + +1. **Tracks Activity**: Monitors when you last sent a message +2. **Hourly Backups**: Creates detailed conversation backups every hour while you're active +3. **Grace Period**: After 1-2 hours of inactivity, creates a final backup +4. **Auto-Stop**: Stops backing up after 2+ hours of inactivity until you return + +## Backup Contents + +Each backup file (`memory/hourly-backups/YYYY-MM-DD-HH00.md`) contains: + +- Conversation log from the session +- Timestamp and session metadata +- Sections for extracted insights (decisions, preferences, action items) + +## State Management + +Activity is tracked in `memory/heartbeat-state.json`: + +```json +{ + "lastBackup": 1234567890, + "lastUserMessage": 1234567890, + "backupActive": true +} +``` + +## Lifecycle + +``` +User sends message → backupActive=true, lastUserMessage=now + ↓ +Hourly cron runs → creates backup if active + ↓ +User goes idle (1-2 hours) → final backup, backupActive=false + ↓ +Hourly cron runs → skips (backupActive=false) + ↓ +User returns → backupActive=true, cycle restarts +``` + +## Configuration + +```json +{ + "hooks": { + "internal": { + "entries": { + "session-backup": { + "enabled": true, + "graceHours": 2, + "backupIntervalMinutes": 60 + } + } + } + } +} +``` + +## Requirements + +- **Config**: `workspace.dir` must be set + +## Disabling + +```bash +clawdbot hooks disable session-backup +``` diff --git a/src/hooks/bundled/session-backup/handler.ts b/src/hooks/bundled/session-backup/handler.ts new file mode 100644 index 000000000..de2f28ef9 --- /dev/null +++ b/src/hooks/bundled/session-backup/handler.ts @@ -0,0 +1,338 @@ +/** + * Session backup hook handler + * + * Creates hourly backups of conversations based on activity state. + * Manages backup lifecycle: active → grace period → stopped + */ + +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 { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; +import type { HookHandler } from "../../hooks.js"; + +// ============================================ +// Types +// ============================================ + +interface BackupState { + lastBackup: number | null; + lastDistill: number | null; + lastSessionSummary: number | null; + lastUserMessage: number | null; + backupActive: boolean; +} + +interface HookConfig { + graceHours?: number; + backupIntervalMinutes?: number; +} + +// ============================================ +// Constants +// ============================================ + +const DEFAULT_GRACE_HOURS = 2; +const DEFAULT_BACKUP_INTERVAL_MINUTES = 60; +const HOUR_MS = 3600 * 1000; + +// ============================================ +// State Management +// ============================================ + +async function readState(stateFile: string): Promise { + try { + const content = await fs.readFile(stateFile, "utf-8"); + return JSON.parse(content); + } catch { + return { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: null, + backupActive: false, + }; + } +} + +async function writeState(stateFile: string, state: BackupState): Promise { + await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8"); +} + +// ============================================ +// Session Log Parsing +// ============================================ + +interface SessionMessage { + role: string; + content: string; + timestamp?: string; +} + +async function extractSessionMessages( + sessionsDir: string, + sinceTimestamp: number +): Promise { + try { + // Find most recent non-probe session file + const files = await fs.readdir(sessionsDir); + const sessionFiles = files + .filter((f) => f.endsWith(".jsonl") && !f.includes("probe")) + .map((f) => path.join(sessionsDir, f)); + + if (sessionFiles.length === 0) return []; + + // Get most recently modified session file + const stats = await Promise.all( + sessionFiles.map(async (f) => ({ file: f, mtime: (await fs.stat(f)).mtimeMs })) + ); + stats.sort((a, b) => b.mtime - a.mtime); + const latestSession = stats[0].file; + + // Read and parse session + const content = await fs.readFile(latestSession, "utf-8"); + const lines = content.trim().split("\n"); + + const messages: SessionMessage[] = []; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message) { + const msg = entry.message; + const role = msg.role; + if ((role === "user" || role === "assistant") && msg.content) { + const text = Array.isArray(msg.content) + ? msg.content.find((c: any) => c.type === "text")?.text + : msg.content; + if (text) { + messages.push({ + role, + content: typeof text === "string" ? text : JSON.stringify(text), + timestamp: entry.timestamp, + }); + } + } + } + } catch { + // Skip invalid JSON lines + } + } + + return messages; + } catch { + return []; + } +} + +async function getLastUserMessageTime(sessionsDir: string): Promise { + try { + const files = await fs.readdir(sessionsDir); + const sessionFiles = files + .filter((f) => f.endsWith(".jsonl") && !f.includes("probe")) + .map((f) => path.join(sessionsDir, f)); + + if (sessionFiles.length === 0) return null; + + // Get most recently modified session file + const stats = await Promise.all( + sessionFiles.map(async (f) => ({ file: f, mtime: (await fs.stat(f)).mtimeMs })) + ); + stats.sort((a, b) => b.mtime - a.mtime); + const latestSession = stats[0].file; + + // Read last 200 lines looking for user messages + const content = await fs.readFile(latestSession, "utf-8"); + const lines = content.trim().split("\n").slice(-200); + + let lastUserTs: number | null = null; + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message?.role === "user" && entry.timestamp) { + const ts = new Date(entry.timestamp).getTime(); + if (!lastUserTs || ts > lastUserTs) { + lastUserTs = ts; + } + } + } catch { + // Skip invalid JSON + } + } + + return lastUserTs ? Math.floor(lastUserTs / 1000) : null; + } catch { + return null; + } +} + +// ============================================ +// Backup Creation +// ============================================ + +async function createBackup( + workspaceDir: string, + sessionsDir: string, + state: BackupState, + isFinal: boolean +): Promise { + const hourlyDir = path.join(workspaceDir, "memory", "hourly-backups"); + await fs.mkdir(hourlyDir, { recursive: true }); + + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const hourStr = now.getHours().toString().padStart(2, "0"); + const filename = `${dateStr}-${hourStr}00.md`; + const backupPath = path.join(hourlyDir, filename); + + // Get messages since last backup + const sinceTs = state.lastBackup || Math.floor(Date.now() / 1000) - 7200; + const messages = await extractSessionMessages(sessionsDir, sinceTs); + + // Build backup content + const lines: string[] = [ + "# Hourly Memory Backup", + "", + `**Generated:** ${now.toISOString()}`, + `**Type:** ${isFinal ? "FINAL (session ending)" : "Hourly"}`, + `**Covering:** ${new Date(sinceTs * 1000).toISOString()} to now`, + "", + "---", + "", + "## Conversation Log", + "", + ]; + + if (messages.length > 0) { + for (const msg of messages.slice(-50)) { + lines.push(`### ${msg.role.toUpperCase()}`); + lines.push(""); + // Truncate very long messages + const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + "..." : msg.content; + lines.push(content); + lines.push(""); + } + } else { + lines.push("*No messages found in this period.*"); + lines.push(""); + } + + lines.push("---"); + lines.push(""); + lines.push("## Key Points to Remember"); + lines.push(""); + lines.push("*This section should be filled in by reviewing the conversation above.*"); + lines.push(""); + lines.push("### Decisions Made"); + lines.push("- (none extracted yet)"); + lines.push(""); + lines.push("### Preferences Discovered"); + lines.push("- (none extracted yet)"); + lines.push(""); + lines.push("### Action Items"); + lines.push("- (none extracted yet)"); + lines.push(""); + + await fs.writeFile(backupPath, lines.join("\n"), "utf-8"); + return backupPath; +} + +// ============================================ +// Main Hook Handler +// ============================================ + +const sessionBackupHandler: HookHandler = async (event) => { + // Trigger on gateway:startup (for scheduled checks) or command:new (session end) + const isStartup = event.type === "gateway" && event.action === "startup"; + const isNewCommand = event.type === "command" && event.action === "new"; + + if (!isStartup && !isNewCommand) { + return; + } + + const context = event.context || {}; + const cfg = context.cfg as ClawdbotConfig | undefined; + + if (!cfg) { + console.log("[session-backup] No config available, skipping"); + return; + } + + const agentId = resolveAgentIdFromSessionKey(event.sessionKey); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const clawdbotDir = path.join(os.homedir(), ".clawdbot"); + const sessionsDir = path.join(clawdbotDir, "agents", agentId, "sessions"); + const memoryDir = path.join(workspaceDir, "memory"); + const stateFile = path.join(memoryDir, "heartbeat-state.json"); + + await fs.mkdir(memoryDir, { recursive: true }); + + // Read current state + let state = await readState(stateFile); + const now = Math.floor(Date.now() / 1000); + + // Update lastUserMessage from session logs + const lastMsgTime = await getLastUserMessageTime(sessionsDir); + if (lastMsgTime) { + state.lastUserMessage = lastMsgTime; + } + + // Get config options + const hookConfig = (context.hookConfig || {}) as HookConfig; + const graceHours = hookConfig.graceHours || DEFAULT_GRACE_HOURS; + + // Calculate time since last user message + const timeSinceMsg = state.lastUserMessage ? now - state.lastUserMessage : Infinity; + const graceMs = graceHours * HOUR_MS / 1000; + + console.log(`[session-backup] State: lastUserMessage=${state.lastUserMessage}, backupActive=${state.backupActive}, timeSinceMsg=${timeSinceMsg}s`); + + // Handle command:new - always do a session-end backup + if (isNewCommand) { + console.log("[session-backup] /new command - creating session-end backup"); + const backupPath = await createBackup(workspaceDir, sessionsDir, state, true); + state.lastBackup = now; + state.lastSessionSummary = now; + await writeState(stateFile, state); + event.messages.push(`📦 Session backup saved: ${path.basename(backupPath)}`); + return; + } + + // Handle gateway:startup (scheduled check) + if (isStartup) { + // Rule 1: If backupActive=false, skip + if (!state.backupActive) { + console.log("[session-backup] backupActive=false, skipping"); + return; + } + + // Rule 2: If inactive >2 hours, skip + if (timeSinceMsg > graceMs) { + console.log("[session-backup] Inactive >2 hours, skipping"); + state.backupActive = false; + await writeState(stateFile, state); + return; + } + + // Rule 3: If 1-2 hours inactive, do final backup + if (timeSinceMsg > HOUR_MS / 1000) { + console.log("[session-backup] Grace period (1-2h), doing FINAL backup"); + const backupPath = await createBackup(workspaceDir, sessionsDir, state, true); + state.lastBackup = now; + state.backupActive = false; + await writeState(stateFile, state); + console.log(`[session-backup] Final backup saved: ${backupPath}`); + return; + } + + // Rule 4: Active within last hour, do regular backup + console.log("[session-backup] User active, creating hourly backup"); + const backupPath = await createBackup(workspaceDir, sessionsDir, state, false); + state.lastBackup = now; + await writeState(stateFile, state); + console.log(`[session-backup] Backup saved: ${backupPath}`); + } +}; + +export default sessionBackupHandler; diff --git a/test/hooks/session-backup.test.ts b/test/hooks/session-backup.test.ts new file mode 100644 index 000000000..30fc78bb0 --- /dev/null +++ b/test/hooks/session-backup.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for session-backup hook + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; + +// Test utilities +const TEST_DIR = path.join(os.tmpdir(), "clawdbot-test-session-backup"); +const MEMORY_DIR = path.join(TEST_DIR, "memory"); +const HOURLY_DIR = path.join(MEMORY_DIR, "hourly-backups"); +const STATE_FILE = path.join(MEMORY_DIR, "heartbeat-state.json"); +const SESSIONS_DIR = path.join(TEST_DIR, "sessions"); + +interface BackupState { + lastBackup: number | null; + lastDistill: number | null; + lastSessionSummary: number | null; + lastUserMessage: number | null; + backupActive: boolean; +} + +async function setupTestDirs() { + await fs.mkdir(MEMORY_DIR, { recursive: true }); + await fs.mkdir(HOURLY_DIR, { recursive: true }); + await fs.mkdir(SESSIONS_DIR, { recursive: true }); +} + +async function cleanupTestDirs() { + try { + await fs.rm(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} + +async function writeState(state: BackupState) { + await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8"); +} + +async function readState(): Promise { + const content = await fs.readFile(STATE_FILE, "utf-8"); + return JSON.parse(content); +} + +async function createMockSessionFile() { + const sessionId = "test-session-" + Date.now(); + const sessionFile = path.join(SESSIONS_DIR, `${sessionId}.jsonl`); + + const messages = [ + { type: "message", message: { role: "user", content: "Hello" }, timestamp: new Date().toISOString() }, + { type: "message", message: { role: "assistant", content: "Hi there!" }, timestamp: new Date().toISOString() }, + ]; + + await fs.writeFile(sessionFile, messages.map(m => JSON.stringify(m)).join("\n"), "utf-8"); + return sessionFile; +} + +describe("session-backup hook", () => { + beforeEach(async () => { + await cleanupTestDirs(); + await setupTestDirs(); + }); + + afterEach(async () => { + await cleanupTestDirs(); + }); + + describe("state management", () => { + it("initializes default state when file missing", async () => { + const defaultState: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: null, + backupActive: false, + }; + + await writeState(defaultState); + const state = await readState(); + + expect(state.backupActive).toBe(false); + expect(state.lastBackup).toBeNull(); + }); + + it("persists state changes", async () => { + const now = Math.floor(Date.now() / 1000); + const state: BackupState = { + lastBackup: now, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: now, + backupActive: true, + }; + + await writeState(state); + const readBack = await readState(); + + expect(readBack.lastBackup).toBe(now); + expect(readBack.backupActive).toBe(true); + }); + }); + + describe("activity detection", () => { + it("sets backupActive=true when user active within 1 hour", async () => { + const now = Math.floor(Date.now() / 1000); + const state: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: now - 1800, // 30 min ago + backupActive: true, + }; + + await writeState(state); + const readBack = await readState(); + + expect(readBack.backupActive).toBe(true); + }); + + it("detects grace period (1-2 hours inactive)", async () => { + const now = Math.floor(Date.now() / 1000); + const ninetyMinAgo = now - 5400; + + const state: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: ninetyMinAgo, + backupActive: true, + }; + + await writeState(state); + + // Verify the time difference + const timeSinceMsg = now - ninetyMinAgo; + expect(timeSinceMsg).toBeGreaterThan(3600); // > 1 hour + expect(timeSinceMsg).toBeLessThan(7200); // < 2 hours + }); + + it("sets backupActive=false when inactive >2 hours", async () => { + const now = Math.floor(Date.now() / 1000); + const threeHoursAgo = now - 10800; + + const state: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: threeHoursAgo, + backupActive: false, // Should be false after >2h + }; + + await writeState(state); + const readBack = await readState(); + + expect(readBack.backupActive).toBe(false); + }); + }); + + describe("backup creation", () => { + it("creates backup file with correct naming", async () => { + await createMockSessionFile(); + + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const hourStr = now.getHours().toString().padStart(2, "0"); + const expectedFilename = `${dateStr}-${hourStr}00.md`; + + // Simulate backup creation + const backupPath = path.join(HOURLY_DIR, expectedFilename); + await fs.writeFile(backupPath, "# Test Backup\n", "utf-8"); + + const exists = await fs.access(backupPath).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + + it("skips backup when backupActive=false", async () => { + const state: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: null, + backupActive: false, + }; + + await writeState(state); + + // Check that no backup would be created + const readBack = await readState(); + expect(readBack.backupActive).toBe(false); + }); + }); + + describe("lifecycle", () => { + it("full lifecycle: active → grace → stopped", async () => { + const now = Math.floor(Date.now() / 1000); + + // Step 1: User active + let state: BackupState = { + lastBackup: null, + lastDistill: null, + lastSessionSummary: null, + lastUserMessage: now, + backupActive: true, + }; + await writeState(state); + expect((await readState()).backupActive).toBe(true); + + // Step 2: User goes idle (90 min) + state.lastUserMessage = now - 5400; + await writeState(state); + // At this point, a hook would do final backup and set backupActive=false + state.backupActive = false; + state.lastBackup = now; + await writeState(state); + expect((await readState()).backupActive).toBe(false); + + // Step 3: User returns + state.lastUserMessage = now; + state.backupActive = true; + await writeState(state); + expect((await readState()).backupActive).toBe(true); + }); + }); +});