From 552b26cc95c1c979716b0d6a30cd9d90fb5d4e91 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 1 Feb 2026 15:36:57 +0200 Subject: [PATCH] Add PreCompact hook for conversation archiving, remove /clear command - Add PreCompact hook in agent-runner that archives conversations before compaction, using session summary from sessions-index.json for filename - Remove /clear command (programmatic compaction not supported by SDK) - Add /add-clear to RFS for future implementation - Update CLAUDE.md templates with memory system instructions Co-Authored-By: Claude Opus 4.5 --- .claude/skills/customize/SKILL.md | 2 +- README.md | 8 +- REQUIREMENTS.md | 3 +- SPEC.md | 16 +-- container/agent-runner/src/index.ts | 175 ++++++++++++++++++++++++++-- groups/CLAUDE.md | 20 ++++ groups/main/CLAUDE.md | 20 ++++ src/config.ts | 1 - src/index.ts | 18 --- 9 files changed, 214 insertions(+), 49 deletions(-) diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index d9f5521..c5ba96e 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -73,7 +73,7 @@ Questions to ask: Implementation: 1. Add command handling in `processMessage()` in `src/index.ts` -2. Follow the pattern used for `/clear` +2. Check for the command before the trigger pattern check ### Changing Deployment diff --git a/README.md b/README.md index cffe5a7..984b906 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,6 @@ Talk to your assistant with the trigger word (default: `@Andy`): @Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing ``` -Clear conversation context: -``` -/clear -``` - From the main channel (your self-chat), you can manage groups and tasks: ``` @Andy list all scheduled tasks across groups @@ -104,6 +99,9 @@ Skills we'd love to see: **Platform Support** - `/setup-windows` - Windows via WSL2 + Docker +**Session Management** +- `/add-clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK. + ## Requirements - macOS Tahoe (26) or later - runs great on Mac Mini diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 0782b75..284c941 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -101,8 +101,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ### Session Management - Each group maintains a conversation session (via Claude Agent SDK) -- `/clear` command resets the session but keeps memory files -- Old session IDs are archived to a file +- Sessions auto-compact when context gets too long, preserving critical information ### Container Isolation - All agents run inside Apple Container (lightweight Linux VMs) diff --git a/SPEC.md b/SPEC.md index dffe857..c70a6a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -142,7 +142,6 @@ nanoclaw/ │ ├── data/ # Application state (gitignored) │ ├── sessions.json # Active session IDs per group -│ ├── archived_sessions.json # Old sessions after /clear │ ├── registered_groups.json # Group JID → folder mapping │ ├── router_state.json # Last processed timestamp + last agent timestamps │ ├── env/env # Copy of .env for container mounting @@ -182,7 +181,6 @@ export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '3000 export const IPC_POLL_INTERVAL = 1000; export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); -export const CLEAR_COMMAND = '/clear'; ``` **Note:** Paths must be absolute for Apple Container volume mounts to work correctly. @@ -305,15 +303,6 @@ Sessions enable conversation continuity - Claude remembers what you talked about } ``` -### The /clear Command - -When a user sends `/clear` in any group: - -1. Current session ID is moved to `data/archived_sessions.json` -2. Session ID is removed from `data/sessions.json` -3. Next message starts a fresh session -4. **Memory files are NOT deleted** - only the session resets - --- ## Message Flow @@ -335,8 +324,7 @@ When a user sends `/clear` in any group: ▼ 5. Router checks: ├── Is chat_jid in registered_groups.json? → No: ignore - ├── Does message start with @Assistant? → No: ignore - └── Is message "/clear"? → Yes: handle specially + └── Does message start with @Assistant? → No: ignore │ ▼ 6. Router catches up conversation: @@ -370,7 +358,6 @@ Messages must start with the trigger pattern (default: `@Andy`): - `@andy help me` → ✅ Triggers (case insensitive) - `Hey @Andy` → ❌ Ignored (trigger not at start) - `What's up?` → ❌ Ignored (no trigger) -- `/clear` → ✅ Special command (no trigger needed) ### Conversation Catch-Up @@ -393,7 +380,6 @@ This allows the agent to understand the conversation context even if it wasn't m | Command | Example | Effect | |---------|---------|--------| | `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude | -| `/clear` | `/clear` | Reset session, keep memory | ### Commands Available in Main Channel Only diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 5b8cf9d..60125f1 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -3,7 +3,9 @@ * Runs inside a container, receives config via stdin, outputs result to stdout */ -import { query } from '@anthropic-ai/claude-agent-sdk'; +import fs from 'fs'; +import path from 'path'; +import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { createIpcMcp } from './ipc-mcp.js'; interface ContainerInput { @@ -21,6 +23,17 @@ interface ContainerOutput { error?: string; } +interface SessionEntry { + sessionId: string; + fullPath: string; + summary: string; + firstPrompt: string; +} + +interface SessionsIndex { + entries: SessionEntry[]; +} + async function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; @@ -32,15 +45,163 @@ async function readStdin(): Promise { } function writeOutput(output: ContainerOutput): void { - // Write to stdout as JSON (this is how the host process receives results) console.log(JSON.stringify(output)); } function log(message: string): void { - // Write logs to stderr so they don't interfere with JSON output console.error(`[agent-runner] ${message}`); } +/** + * Find session summary from sessions-index.json + */ +function getSessionSummary(sessionId: string, transcriptPath: string): string | null { + // The sessions-index.json is in the same directory as the transcript + const projectDir = path.dirname(transcriptPath); + const indexPath = path.join(projectDir, 'sessions-index.json'); + + if (!fs.existsSync(indexPath)) { + log(`Sessions index not found at ${indexPath}`); + return null; + } + + try { + const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + const entry = index.entries.find(e => e.sessionId === sessionId); + if (entry?.summary) { + return entry.summary; + } + } catch (err) { + log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); + } + + return null; +} + +/** + * Archive the full transcript to conversations/ before compaction. + */ +function createPreCompactHook(): HookCallback { + return async (input, _toolUseId, _context) => { + const preCompact = input as PreCompactHookInput; + const transcriptPath = preCompact.transcript_path; + const sessionId = preCompact.session_id; + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return {}; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + + if (messages.length === 0) { + log('No messages to archive'); + return {}; + } + + // Get summary from sessions-index.json for the filename + const summary = getSessionSummary(sessionId, transcriptPath); + const name = summary ? sanitizeFilename(summary) : generateFallbackName(); + + const conversationsDir = '/workspace/group/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + + const date = new Date().toISOString().split('T')[0]; + const filename = `${date}-${name}.md`; + const filePath = path.join(conversationsDir, filename); + + const markdown = formatTranscriptMarkdown(messages, summary); + fs.writeFileSync(filePath, markdown); + + log(`Archived conversation to ${filePath}`); + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + } + + return {}; + }; +} + +/** + * Sanitize a summary string into a valid filename. + */ +function sanitizeFilename(summary: string): string { + return summary + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +function generateFallbackName(): string { + const time = new Date(); + return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; +} + +interface ParsedMessage { + role: 'user' | 'assistant'; + content: string; +} + +function parseTranscript(content: string): ParsedMessage[] { + const messages: ParsedMessage[] = []; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + const text = typeof entry.message.content === 'string' + ? entry.message.content + : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); + if (text) messages.push({ role: 'user', content: text }); + } else if (entry.type === 'assistant' && entry.message?.content) { + const textParts = entry.message.content + .filter((c: { type: string }) => c.type === 'text') + .map((c: { text: string }) => c.text); + const text = textParts.join(''); + if (text) messages.push({ role: 'assistant', content: text }); + } + } catch { + // Skip malformed lines + } + } + + return messages; +} + +function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null): string { + const now = new Date(); + const formatDateTime = (d: Date) => d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + const lines: string[] = []; + lines.push(`# ${title || 'Conversation'}`); + lines.push(''); + lines.push(`Archived: ${formatDateTime(now)}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + for (const msg of messages) { + const sender = msg.role === 'user' ? 'User' : 'Andy'; + const content = msg.content.length > 2000 + ? msg.content.slice(0, 2000) + '...' + : msg.content; + lines.push(`**${sender}**: ${content}`); + lines.push(''); + } + + return lines.join('\n'); +} + async function main(): Promise { let input: ContainerInput; @@ -57,7 +218,6 @@ async function main(): Promise { process.exit(1); } - // Create IPC-based MCP for communicating back to host const ipcMcp = createIpcMcp({ chatJid: input.chatJid, groupFolder: input.groupFolder, @@ -76,7 +236,7 @@ async function main(): Promise { cwd: '/workspace/group', resume: input.sessionId, allowedTools: [ - 'Bash', // Safe - sandboxed in container! + 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'mcp__nanoclaw__*', @@ -88,16 +248,17 @@ async function main(): Promise { mcpServers: { nanoclaw: ipcMcp, gmail: { command: 'npx', args: ['-y', '@gongrzhe/server-gmail-autoauth-mcp'] } + }, + hooks: { + PreCompact: [{ hooks: [createPreCompactHook()] }] } } })) { - // Capture session ID from init message if (message.type === 'system' && message.subtype === 'init') { newSessionId = message.session_id; log(`Session initialized: ${newSessionId}`); } - // Capture final result if ('result' in message && message.result) { result = message.result as string; } diff --git a/groups/CLAUDE.md b/groups/CLAUDE.md index 11766cf..c084ae6 100644 --- a/groups/CLAUDE.md +++ b/groups/CLAUDE.md @@ -11,8 +11,28 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Schedule tasks to run later or on a recurring basis - Send messages back to the chat +## Long Tasks + +If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: + +1. Send a brief message: what you understood and what you'll do +2. Do the work +3. Exit with the final answer + +This keeps users informed instead of waiting in silence. + ## Your Workspace Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. Your `CLAUDE.md` file in that folder is your memory - update it with important context you want to remember. + +## Memory + +The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. + +When you learn something important: +- Create files for structured data (e.g., `customers.md`, `preferences.md`) +- Split files larger than 500 lines into folders +- Add recurring context directly to this CLAUDE.md +- Always index new memory files at the top of CLAUDE.md diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 5a3fb9c..e7286b1 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -11,6 +11,26 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Schedule tasks to run later or on a recurring basis - Send messages back to the chat +## Long Tasks + +If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: + +1. Send a brief message: what you understood and what you'll do +2. Do the work +3. Exit with the final answer + +This keeps users informed instead of waiting in silence. + +## Memory + +The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. + +When you learn something important: +- Create files for structured data (e.g., `customers.md`, `preferences.md`) +- Split files larger than 500 lines into folders +- Add recurring context directly to this CLAUDE.md +- Always index new memory files at the top of CLAUDE.md + --- ## Admin Context diff --git a/src/config.ts b/src/config.ts index 9108bbc..d275f4e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,4 +17,3 @@ export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '3000 export const IPC_POLL_INTERVAL = 1000; // Check IPC directories every second export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); -export const CLEAR_COMMAND = '/clear'; diff --git a/src/index.ts b/src/index.ts index 11f5c13..8212bda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,8 @@ import { ASSISTANT_NAME, POLL_INTERVAL, STORE_DIR, - GROUPS_DIR, DATA_DIR, TRIGGER_PATTERN, - CLEAR_COMMAND, MAIN_GROUP_FOLDER, IPC_POLL_INTERVAL } from './config.js'; @@ -81,22 +79,6 @@ async function processMessage(msg: NewMessage): Promise { const content = msg.content.trim(); - if (content.toLowerCase() === CLEAR_COMMAND) { - if (sessions[group.folder]) { - const archived = loadJson>>( - path.join(DATA_DIR, 'archived_sessions.json'), {} - ); - if (!archived[group.folder]) archived[group.folder] = []; - archived[group.folder].push({ session_id: sessions[group.folder], cleared_at: new Date().toISOString() }); - saveJson(path.join(DATA_DIR, 'archived_sessions.json'), archived); - delete sessions[group.folder]; - saveJson(path.join(DATA_DIR, 'sessions.json'), sessions); - } - logger.info({ group: group.name }, 'Session cleared'); - await sendMessage(msg.chat_jid, `${ASSISTANT_NAME}: Conversation cleared. Starting fresh!`); - return; - } - if (!TRIGGER_PATTERN.test(content)) return; // Get messages since last agent interaction to catch up the session