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:
parent
dce7925e2a
commit
b85810a550
78
src/hooks/bundled/context-aggregator/HOOK.md
Normal file
78
src/hooks/bundled/context-aggregator/HOOK.md
Normal 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
|
||||
```
|
||||
404
src/hooks/bundled/context-aggregator/handler.ts
Normal file
404
src/hooks/bundled/context-aggregator/handler.ts
Normal 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;
|
||||
88
src/hooks/bundled/session-backup/HOOK.md
Normal file
88
src/hooks/bundled/session-backup/HOOK.md
Normal 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
|
||||
```
|
||||
338
src/hooks/bundled/session-backup/handler.ts
Normal file
338
src/hooks/bundled/session-backup/handler.ts
Normal 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;
|
||||
227
test/hooks/session-backup.test.ts
Normal file
227
test/hooks/session-backup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user