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.
228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|