diff --git a/src/memory.ts b/src/memory.ts index b4f0178..f85ec4e 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -170,3 +170,102 @@ export function summarizeToolLog(toolLog: Array<{ name: string; result?: string const toolNames = [...new Set(toolLog.map(t => t.name))]; return `Used tools: ${toolNames.join(', ')}`; } + +// --- Session compaction --- + +/** + * Compact a session: summarize the JSONL session file into a few lines, + * append to recent.md, and optionally update context.md. + * + * Uses Anthropic API directly (haiku for cheap summarization). + */ +export async function compactSession( + agentHome: string, + projectId: string, + sessionContent: string, + apiKey: string, +): Promise { + if (!sessionContent.trim()) return ''; + + const prompt = `Summarize the following agent session into 3-5 bullet points. +Focus on: what was done, key decisions made, problems encountered, results. +Be concise. Output only bullet points, no headers. + +Session: +${sessionContent.slice(0, 8000)}`; + + try { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5', + max_tokens: 300, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!response.ok) { + log.warn({ status: response.status }, 'Compaction API call failed'); + return ''; + } + + const data = await response.json() as { content: Array<{ text: string }> }; + const summary = data.content?.[0]?.text || ''; + log.info({ projectId, summaryChars: summary.length }, 'Session compacted'); + return summary; + } catch (err) { + log.warn({ err }, 'Session compaction failed'); + return ''; + } +} + +/** + * Check if a session file is large enough to warrant compaction. + * Returns the session content if compaction needed, empty string otherwise. + */ +export function needsCompaction(sessionFile: string, maxChars: number = 50_000): string { + try { + if (!fs.existsSync(sessionFile)) return ''; + const content = fs.readFileSync(sessionFile, 'utf-8'); + if (content.length > maxChars) { + return content; + } + } catch { + // skip + } + return ''; +} + +/** + * Run compaction on a session: summarize → append recent → truncate session. + */ +export async function runCompaction( + agentHome: string, + projectId: string, + sessionFile: string, + apiKey: string, +): Promise { + const content = needsCompaction(sessionFile); + if (!content) return; + + log.info({ projectId, sessionChars: content.length }, 'Starting session compaction'); + + const summary = await compactSession(agentHome, projectId, content, apiKey); + if (!summary) return; + + // Append summary to recent.md + appendRecent(agentHome, projectId, `[compaction] ${summary.replace(/\n/g, ' | ')}`); + + // Truncate session file — keep last 10K chars as context continuity + const keepChars = 10_000; + const truncated = content.length > keepChars + ? content.slice(content.length - keepChars) + : content; + fs.writeFileSync(sessionFile, truncated, 'utf-8'); + log.info({ projectId, oldChars: content.length, newChars: truncated.length }, 'Session truncated after compaction'); +} diff --git a/src/router.ts b/src/router.ts index c4a07d1..526d4e2 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,7 +1,7 @@ import path from 'path'; import { logger } from './logger.js'; import { runAgent } from './agent.js'; -import { appendRecent, summarizeToolLog } from './memory.js'; +import { appendRecent, summarizeToolLog, runCompaction } from './memory.js'; import { TrackerClient } from './tracker/client.js'; import { createTrackerTools } from './tools/index.js'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; @@ -87,6 +87,7 @@ export class EventRouter { const taskId = data.task_id as string | undefined; const chatId = data.chat_id as string | undefined; const taskKey = data.task_key as string | undefined; + const eventProjectId = data.project_id as string | undefined; // Extract author info from nested author object if present const author = data.author as Record | undefined; @@ -121,7 +122,7 @@ export class EventRouter { this.log.info('│ %s %s: "%s"', ctx, from, content.slice(0, 200)); const target = chatId ? { chat_id: chatId } : taskId ? { task_id: taskId } : null; - const projectId = this.resolveProjectId(chatId); + const projectId = eventProjectId || this.resolveProjectId(chatId); // Stream start if (this.wsTransport && target) { @@ -167,6 +168,17 @@ export class EventRouter { } catch (err) { this.log.warn({ err }, 'Failed to flush memory'); } + + // Background compaction check (non-blocking) + if (this.config.sessionId) { + const sessionFile = path.join(this.config.agentHome, 'sessions', `${this.config.sessionId}.jsonl`); + const apiKey = process.env.ANTHROPIC_API_KEY || ''; + if (apiKey) { + runCompaction(this.config.agentHome, projectId, sessionFile, apiKey).catch(err => { + this.log.warn({ err }, 'Background compaction failed'); + }); + } + } } this.log.info('└── MESSAGE handled');