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:
parent
4c7a41494a
commit
b2ef840c8e
55
src/agent.ts
55
src/agent.ts
@ -31,6 +31,8 @@ export interface AgentOptions {
|
|||||||
customTools?: ToolDefinition[];
|
customTools?: ToolDefinition[];
|
||||||
/** Agent home directory for loading bootstrap files (AGENT.md, memory/) */
|
/** Agent home directory for loading bootstrap files (AGENT.md, memory/) */
|
||||||
agentHome?: string;
|
agentHome?: string;
|
||||||
|
/** Project UUID — loads project-specific memory context */
|
||||||
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentMessage {
|
export interface AgentMessage {
|
||||||
@ -39,52 +41,8 @@ export interface AgentMessage {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bootstrap context loader ---
|
// Bootstrap context loading moved to memory.ts
|
||||||
const BOOTSTRAP_FILES = ['AGENT.md', 'memory/notes.md'];
|
import { loadBootstrapContext } from './memory.js';
|
||||||
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')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Model alias map: short name → full model ID ---
|
// --- Model alias map: short name → full model ID ---
|
||||||
const MODEL_ALIASES: Record<string, string> = {
|
const MODEL_ALIASES: Record<string, string> = {
|
||||||
@ -236,7 +194,10 @@ export async function* runAgent(
|
|||||||
|
|
||||||
// Load bootstrap context from agent home (AGENT.md, memory/)
|
// Load bootstrap context from agent home (AGENT.md, memory/)
|
||||||
if (options.agentHome) {
|
if (options.agentHome) {
|
||||||
const bootstrapContext = loadBootstrapContext(options.agentHome);
|
const bootstrapContext = loadBootstrapContext({
|
||||||
|
agentHome: options.agentHome,
|
||||||
|
projectId: options.projectId,
|
||||||
|
});
|
||||||
if (bootstrapContext) {
|
if (bootstrapContext) {
|
||||||
promptParts.push(bootstrapContext);
|
promptParts.push(bootstrapContext);
|
||||||
log.info({ chars: bootstrapContext.length }, 'Bootstrap context loaded');
|
log.info({ chars: bootstrapContext.length }, 'Bootstrap context loaded');
|
||||||
|
|||||||
@ -46,6 +46,11 @@ async function startAgentWs(config: AgentConfig, client: TrackerClient): Promise
|
|||||||
await wsTransport.start();
|
await wsTransport.start();
|
||||||
logger.info('Connected to tracker via WebSocket');
|
logger.info('Connected to tracker via WebSocket');
|
||||||
|
|
||||||
|
// Register project mappings for memory context
|
||||||
|
if (wsTransport.projects.length > 0) {
|
||||||
|
router.setProjectMappings(wsTransport.projects);
|
||||||
|
}
|
||||||
|
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
logger.info('Shutting down agent (ws)...');
|
logger.info('Shutting down agent (ws)...');
|
||||||
wsTransport.stop().then(() => {
|
wsTransport.stop().then(() => {
|
||||||
|
|||||||
172
src/memory.ts
Normal file
172
src/memory.ts
Normal 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(', ')}`;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { runAgent } from './agent.js';
|
import { runAgent } from './agent.js';
|
||||||
|
import { appendRecent, summarizeToolLog } from './memory.js';
|
||||||
import { TrackerClient } from './tracker/client.js';
|
import { TrackerClient } from './tracker/client.js';
|
||||||
import { createTrackerTools } from './tools/index.js';
|
import { createTrackerTools } from './tools/index.js';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
@ -18,6 +19,8 @@ export class EventRouter {
|
|||||||
private trackerTools: ToolDefinition[];
|
private trackerTools: ToolDefinition[];
|
||||||
private trackerClient: TrackerClient;
|
private trackerClient: TrackerClient;
|
||||||
private wsTransport: WsClientTransport | null = null;
|
private wsTransport: WsClientTransport | null = null;
|
||||||
|
/** chat_id → project_id mapping (populated from auth.ok) */
|
||||||
|
private chatToProject: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private config: AgentConfig,
|
private config: AgentConfig,
|
||||||
@ -37,6 +40,21 @@ export class EventRouter {
|
|||||||
this.wsTransport = transport;
|
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> {
|
async handleEvent(event: TrackerEvent): Promise<void> {
|
||||||
this.log.info('┌── ROUTER: handling %s (id: %s)', event.event, event.id);
|
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));
|
this.log.info('│ %s %s: "%s"', ctx, from, content.slice(0, 200));
|
||||||
|
|
||||||
const target = chatId ? { chat_id: chatId } : taskId ? { task_id: taskId } : null;
|
const target = chatId ? { chat_id: chatId } : taskId ? { task_id: taskId } : null;
|
||||||
|
const projectId = this.resolveProjectId(chatId);
|
||||||
|
|
||||||
// Stream start
|
// Stream start
|
||||||
if (this.wsTransport && target) {
|
if (this.wsTransport && target) {
|
||||||
this.wsTransport.sendStreamEvent('agent.stream.start', { ...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
|
// Auto-reply via WS: if agent produced text but didn't call send_message
|
||||||
if (result.text && !result.usedSendMessage && target) {
|
if (result.text && !result.usedSendMessage && target) {
|
||||||
@ -138,6 +157,18 @@ export class EventRouter {
|
|||||||
this.wsTransport.sendStreamEvent('agent.stream.end', { ...target });
|
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');
|
this.log.info('└── MESSAGE handled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,6 +179,7 @@ export class EventRouter {
|
|||||||
private async runAgent(
|
private async runAgent(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
target: { chat_id?: string; task_id?: string } | null,
|
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 }> {
|
): Promise<{ text: string; thinking: string; toolLog: Array<{name: string; args?: string; result?: string; error?: boolean}>; usedSendMessage: boolean }> {
|
||||||
let text = '';
|
let text = '';
|
||||||
let thinking = '';
|
let thinking = '';
|
||||||
@ -168,6 +200,7 @@ export class EventRouter {
|
|||||||
: [this.config.agentHome],
|
: [this.config.agentHome],
|
||||||
customTools: this.trackerTools,
|
customTools: this.trackerTools,
|
||||||
agentHome: this.config.agentHome,
|
agentHome: this.config.agentHome,
|
||||||
|
projectId,
|
||||||
})) {
|
})) {
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
this.log.error({ error: msg.content }, 'Agent error');
|
this.log.error({ error: msg.content }, 'Agent error');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user