feat(hooks): Add memory system hooks for session continuity

Adds two new bundled hooks:

1. session-backup: Automated hourly session backups with activity-based lifecycle
   - Tracks user activity via lastUserMessage timestamp
   - Creates hourly backups while user is active
   - Grace period (1-2h) triggers final backup then stops
   - Respects backupActive flag to avoid unnecessary work

2. context-aggregator: Aggregates memory into CONTEXT.md
   - Collects from hourly backups, daily notes, session summaries
   - Updates activity state from session logs
   - Provides memory management reminders
   - Creates unified context for session continuity

Also adds tests for session-backup hook covering:
- State management
- Activity detection
- Backup creation
- Full lifecycle simulation

Resolves the 'new intern each session' problem by providing structured
memory persistence that doesn't rely on agent behavioral compliance.
This commit is contained in:
Rex Liu 2026-01-26 21:57:06 -05:00
parent dce7925e2a
commit b85810a550
No known key found for this signature in database
GPG Key ID: F6E1D95B5DE90338
5 changed files with 1135 additions and 0 deletions

View File

@ -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
```

View File

@ -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<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function readFileIfExists(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf-8");
} catch {
return null;
}
}
async function getFilesModifiedSince(
dir: string,
sinceTimestamp: number,
pattern?: RegExp
): Promise<string[]> {
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<string[]> {
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<number | null> {
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<string> {
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;

View File

@ -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
```

View File

@ -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<BackupState> {
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<void> {
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<SessionMessage[]> {
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<number | null> {
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<string> {
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;

View File

@ -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<BackupState> {
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);
});
});
});