Phase 2: Agent memory — bootstrap context + memory persistence

New memory.ts module:
- loadBootstrapContext(): AGENT.md + agent.md + per-project context.md + recent.md
- appendRecent(): rolling window (20 entries) of agent activity
- summarizeToolLog(): extracts tool names from log

Router:
- Resolves projectId from chat_id via auth.ok mappings
- Passes projectId to agent for project-specific context loading
- Flushes memory after each message (appends to recent.md)

Agent:
- Accepts projectId option, passes to bootstrap loader
- Bootstrap now loads project-specific memory when available
This commit is contained in:
Markov 2026-02-27 14:11:48 +01:00
parent 4c7a41494a
commit b2ef840c8e
4 changed files with 219 additions and 48 deletions

View File

@ -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<string, string> = {
@ -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');

View File

@ -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(() => {

172
src/memory.ts Normal file
View File

@ -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(', ')}`;
}

View File

@ -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<string, string> = 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<void> {
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');