diff --git a/src/agent.ts b/src/agent.ts index e1ad2d3..e98e3fe 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -31,6 +31,8 @@ export interface AgentOptions { customTools?: ToolDefinition[]; /** Agent home directory for loading bootstrap files (AGENT.md, memory/) */ agentHome?: string; + /** Project UUID — loads project-specific memory context */ + projectId?: string; } export interface AgentMessage { @@ -39,52 +41,8 @@ export interface AgentMessage { sessionId?: string; } -// --- Bootstrap context loader --- -const BOOTSTRAP_FILES = ['AGENT.md', 'memory/notes.md']; -const BOOTSTRAP_MAX_CHARS = 15_000; - -function loadBootstrapContext(agentHome: string): string { - const parts: string[] = []; - let totalChars = 0; - - for (const relPath of BOOTSTRAP_FILES) { - const filePath = path.join(agentHome, relPath); - try { - if (!fs.existsSync(filePath)) continue; - const content = fs.readFileSync(filePath, 'utf-8').trim(); - if (!content) continue; - const remaining = BOOTSTRAP_MAX_CHARS - totalChars; - if (remaining <= 0) break; - const truncated = content.length > remaining ? content.slice(0, remaining) + '\n[...truncated]' : content; - parts.push(`## ${relPath}\n${truncated}`); - totalChars += truncated.length; - } catch { - // skip unreadable files - } - } - - // Also load per-project memory files - const projectMemDir = path.join(agentHome, 'memory', 'projects'); - try { - if (fs.existsSync(projectMemDir)) { - for (const file of fs.readdirSync(projectMemDir).filter(f => f.endsWith('.md'))) { - const filePath = path.join(projectMemDir, file); - const content = fs.readFileSync(filePath, 'utf-8').trim(); - if (!content) continue; - const remaining = BOOTSTRAP_MAX_CHARS - totalChars; - if (remaining <= 200) break; - const truncated = content.length > remaining ? content.slice(0, remaining) + '\n[...truncated]' : content; - parts.push(`## memory/projects/${file}\n${truncated}`); - totalChars += truncated.length; - } - } - } catch { - // skip - } - - if (parts.length === 0) return ''; - return `# Agent Context (bootstrap)\n\n${parts.join('\n\n')}`; -} +// Bootstrap context loading moved to memory.ts +import { loadBootstrapContext } from './memory.js'; // --- Model alias map: short name → full model ID --- const MODEL_ALIASES: Record = { @@ -236,7 +194,10 @@ export async function* runAgent( // Load bootstrap context from agent home (AGENT.md, memory/) if (options.agentHome) { - const bootstrapContext = loadBootstrapContext(options.agentHome); + const bootstrapContext = loadBootstrapContext({ + agentHome: options.agentHome, + projectId: options.projectId, + }); if (bootstrapContext) { promptParts.push(bootstrapContext); log.info({ chars: bootstrapContext.length }, 'Bootstrap context loaded'); diff --git a/src/index.ts b/src/index.ts index 0be6619..d3560d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,11 @@ async function startAgentWs(config: AgentConfig, client: TrackerClient): Promise await wsTransport.start(); logger.info('Connected to tracker via WebSocket'); + // Register project mappings for memory context + if (wsTransport.projects.length > 0) { + router.setProjectMappings(wsTransport.projects); + } + const shutdown = () => { logger.info('Shutting down agent (ws)...'); wsTransport.stop().then(() => { diff --git a/src/memory.ts b/src/memory.ts new file mode 100644 index 0000000..b4f0178 --- /dev/null +++ b/src/memory.ts @@ -0,0 +1,172 @@ +/** + * Agent Memory — two-level per-project structure. + * + * agents/{slug}/ + * AGENT.md # static instructions + * memory/ + * agent.md # personal lessons, style (always loaded, ~2K) + * projects/ + * {project_uuid}/ + * context.md # architecture, decisions (loaded per-task, ~3K) + * recent.md # rolling window, ~20 entries + */ + +import fs from 'fs'; +import path from 'path'; +import { logger } from './logger.js'; + +const log = logger.child({ component: 'memory' }); + +const MAX_RECENT_ENTRIES = 20; +const MAX_BOOTSTRAP_CHARS = 10_000; + +// --- Bootstrap context loading --- + +export interface BootstrapOptions { + agentHome: string; + /** If set, also loads project-specific context */ + projectId?: string; +} + +/** + * Load bootstrap context for system prompt injection. + * + * Always loads: AGENT.md + memory/agent.md + * If projectId given: + memory/projects/{projectId}/context.md + recent.md + */ +export function loadBootstrapContext(opts: BootstrapOptions): string { + const { agentHome, projectId } = opts; + const parts: string[] = []; + let totalChars = 0; + + function addFile(relPath: string, label?: string) { + const filePath = path.join(agentHome, relPath); + try { + if (!fs.existsSync(filePath)) return; + const content = fs.readFileSync(filePath, 'utf-8').trim(); + if (!content) return; + const remaining = MAX_BOOTSTRAP_CHARS - totalChars; + if (remaining <= 100) return; + const truncated = content.length > remaining + ? content.slice(0, remaining) + '\n[...truncated]' + : content; + parts.push(`## ${label || relPath}\n${truncated}`); + totalChars += truncated.length; + } catch { + // skip + } + } + + // Always load + addFile('AGENT.md', 'Agent Instructions'); + addFile('memory/agent.md', 'Personal Memory'); + + // Per-project context + if (projectId) { + addFile(`memory/projects/${projectId}/context.md`, 'Project Context'); + addFile(`memory/projects/${projectId}/recent.md`, 'Recent Activity'); + } + + if (parts.length === 0) return ''; + return `# Agent Context (bootstrap)\n\n${parts.join('\n\n')}`; +} + +// --- Memory persistence --- + +/** + * Append an entry to recent.md for a project. + * Maintains rolling window of MAX_RECENT_ENTRIES. + */ +export function appendRecent(agentHome: string, projectId: string, entry: string): void { + const dir = path.join(agentHome, 'memory', 'projects', projectId); + fs.mkdirSync(dir, { recursive: true }); + + const recentPath = path.join(dir, 'recent.md'); + let lines: string[] = []; + + try { + if (fs.existsSync(recentPath)) { + const content = fs.readFileSync(recentPath, 'utf-8'); + lines = content.split('\n- ').filter(l => l.trim()); + // First element may have header + if (lines.length > 0 && lines[0].startsWith('#')) { + lines.shift(); // remove header + } + } + } catch { + // start fresh + } + + // Add new entry + const timestamp = new Date().toISOString().slice(0, 16).replace('T', ' '); + lines.push(`[${timestamp}] ${entry}`); + + // Trim to window + if (lines.length > MAX_RECENT_ENTRIES) { + lines = lines.slice(lines.length - MAX_RECENT_ENTRIES); + } + + const output = `# Recent Activity\n\n${lines.map(l => `- ${l}`).join('\n')}\n`; + fs.writeFileSync(recentPath, output, 'utf-8'); + log.info({ projectId, entries: lines.length }, 'Updated recent.md'); +} + +/** + * Read or initialize context.md for a project. + */ +export function readProjectContext(agentHome: string, projectId: string): string { + const contextPath = path.join(agentHome, 'memory', 'projects', projectId, 'context.md'); + try { + if (fs.existsSync(contextPath)) { + return fs.readFileSync(contextPath, 'utf-8'); + } + } catch { + // skip + } + return ''; +} + +/** + * Write context.md for a project (full replace). + */ +export function writeProjectContext(agentHome: string, projectId: string, content: string): void { + const dir = path.join(agentHome, 'memory', 'projects', projectId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'context.md'), content, 'utf-8'); + log.info({ projectId, chars: content.length }, 'Updated context.md'); +} + +/** + * Read or initialize agent.md (personal memory). + */ +export function readAgentMemory(agentHome: string): string { + const agentMdPath = path.join(agentHome, 'memory', 'agent.md'); + try { + if (fs.existsSync(agentMdPath)) { + return fs.readFileSync(agentMdPath, 'utf-8'); + } + } catch { + // skip + } + return ''; +} + +/** + * Write agent.md (personal memory, full replace). + */ +export function writeAgentMemory(agentHome: string, content: string): void { + const dir = path.join(agentHome, 'memory'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'agent.md'), content, 'utf-8'); + log.info({ chars: content.length }, 'Updated agent.md'); +} + +/** + * Generate a summary of agent actions for recent.md entry. + * Extracts key info from tool log. + */ +export function summarizeToolLog(toolLog: Array<{ name: string; result?: string }>): string { + if (toolLog.length === 0) return ''; + const toolNames = [...new Set(toolLog.map(t => t.name))]; + return `Used tools: ${toolNames.join(', ')}`; +} diff --git a/src/router.ts b/src/router.ts index 9bece60..c4a07d1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,6 +1,7 @@ import path from 'path'; import { logger } from './logger.js'; import { runAgent } from './agent.js'; +import { appendRecent, summarizeToolLog } from './memory.js'; import { TrackerClient } from './tracker/client.js'; import { createTrackerTools } from './tools/index.js'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; @@ -18,6 +19,8 @@ export class EventRouter { private trackerTools: ToolDefinition[]; private trackerClient: TrackerClient; private wsTransport: WsClientTransport | null = null; + /** chat_id → project_id mapping (populated from auth.ok) */ + private chatToProject: Map = new Map(); constructor( private config: AgentConfig, @@ -37,6 +40,21 @@ export class EventRouter { this.wsTransport = transport; } + /** Register chat_id → project_id mappings from auth.ok */ + setProjectMappings(projects: Array<{ id: string; chat_id?: string | null }>): void { + for (const p of projects) { + if (p.chat_id) { + this.chatToProject.set(p.chat_id, p.id); + } + } + this.log.info({ mappings: this.chatToProject.size }, 'Project mappings set'); + } + + private resolveProjectId(chatId?: string): string | undefined { + if (chatId) return this.chatToProject.get(chatId); + return undefined; + } + async handleEvent(event: TrackerEvent): Promise { this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id); @@ -103,13 +121,14 @@ 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); // Stream start if (this.wsTransport && target) { this.wsTransport.sendStreamEvent('agent.stream.start', { ...target }); } - const result = await this.runAgent(prompt, target); + const result = await this.runAgent(prompt, target, projectId); // Auto-reply via WS: if agent produced text but didn't call send_message if (result.text && !result.usedSendMessage && target) { @@ -138,6 +157,18 @@ export class EventRouter { this.wsTransport.sendStreamEvent('agent.stream.end', { ...target }); } + // Memory flush: append to recent.md + if (projectId && this.config.agentHome) { + try { + const toolSummary = summarizeToolLog(result.toolLog); + const replySnippet = result.text ? result.text.slice(0, 100) : '(no reply)'; + const entry = `${from}: "${content.slice(0, 80)}" → ${replySnippet}${toolSummary ? ` [${toolSummary}]` : ''}`; + appendRecent(this.config.agentHome, projectId, entry); + } catch (err) { + this.log.warn({ err }, 'Failed to flush memory'); + } + } + this.log.info('└── MESSAGE handled'); } @@ -148,6 +179,7 @@ export class EventRouter { private async runAgent( prompt: string, target: { chat_id?: string; task_id?: string } | null, + projectId?: string, ): Promise<{ text: string; thinking: string; toolLog: Array<{name: string; args?: string; result?: string; error?: boolean}>; usedSendMessage: boolean }> { let text = ''; let thinking = ''; @@ -168,6 +200,7 @@ export class EventRouter { : [this.config.agentHome], customTools: this.trackerTools, agentHome: this.config.agentHome, + projectId, })) { if (msg.type === 'error') { this.log.error({ error: msg.content }, 'Agent error');