284 lines
8.7 KiB
TypeScript
284 lines
8.7 KiB
TypeScript
/**
|
|
* Three-layer memory management with LLM summarization.
|
|
*
|
|
* Layers:
|
|
* summaryMemory (budget: 2000 chars) — compressed history
|
|
* recentMemory (budget: 500 chars) — recent step records
|
|
* pendingMemory (no budget) — current step in progress
|
|
*
|
|
* Memory text is injected into systemPrompt (not via transformContext).
|
|
* Screenshot pruning (pruneScreenshots) is used as transformContext hook.
|
|
*/
|
|
|
|
import { complete } from '@mariozechner/pi-ai';
|
|
import type { Model, Api, AssistantMessage } from '@mariozechner/pi-ai';
|
|
import { logInfo, logDebug, logError } from '../utils/logger.js';
|
|
|
|
// Type alias for AgentMessage — we only need role and content fields
|
|
interface MessageLike {
|
|
role: string;
|
|
content: unknown;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
const MEMORY_SUMMARIZATION_PROMPT = `You are a memory summarization assistant for a GUI automation agent.
|
|
Your task is to condense step-by-step action records into concise memory entries.
|
|
|
|
Output JSON:
|
|
{
|
|
"summary": "Concise summary of the actions taken and their outcomes..."
|
|
}
|
|
|
|
Guidelines:
|
|
- Preserve key information: what was done, what succeeded/failed, current state
|
|
- Remove redundant details and repetitive patterns
|
|
- Keep the summary actionable — the agent needs to know what happened to plan next steps`;
|
|
|
|
export class MemoryManager {
|
|
private summaryMemory: string = '';
|
|
private recentMemory: string = '';
|
|
private pendingMemory: string = '';
|
|
|
|
private recentBudget: number = 500;
|
|
private summaryBudget: number = 2000;
|
|
private screenshotKeepCount: number = 3;
|
|
|
|
private memoryModel: Model<any>;
|
|
private apiKey: string;
|
|
|
|
constructor(memoryModel: Model<any>, apiKey: string) {
|
|
this.memoryModel = memoryModel;
|
|
this.apiKey = apiKey;
|
|
}
|
|
|
|
/**
|
|
* Record a pending step (currently executing).
|
|
*/
|
|
addPendingStep(stepId: number, goal: string): void {
|
|
this.pendingMemory = `Step ${stepId} | Goal: ${goal}`;
|
|
}
|
|
|
|
/**
|
|
* Finalize a step — move from pending to recent, trigger compression if over budget.
|
|
*/
|
|
async finalizeStep(stepId: number, evaluation: 'success' | 'failed'): Promise<void> {
|
|
const entry = `Step ${stepId} | Eval: ${evaluation} | ${this.pendingMemory.replace(`Step ${stepId} | `, '')}`;
|
|
this.pendingMemory = '';
|
|
|
|
if (this.recentMemory) {
|
|
this.recentMemory += '\n' + entry;
|
|
} else {
|
|
this.recentMemory = entry;
|
|
}
|
|
|
|
// Check if recent memory exceeds budget
|
|
if (this.recentMemory.length > this.recentBudget) {
|
|
await this.compressRecent();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compose all three layers into a single text block for systemPrompt injection.
|
|
*/
|
|
compose(): string {
|
|
const parts: string[] = [];
|
|
if (this.summaryMemory) {
|
|
parts.push(`[Summarized history]\n${this.summaryMemory}`);
|
|
}
|
|
if (this.recentMemory) {
|
|
parts.push(`[Recent steps]\n${this.recentMemory}`);
|
|
}
|
|
if (this.pendingMemory) {
|
|
parts.push(`[Current step]\n${this.pendingMemory}`);
|
|
}
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Prune screenshots from messages — for use as transformContext hook.
|
|
*
|
|
* Keeps last N screenshots, replaces older ones with text placeholders.
|
|
* This only affects the LLM input, not the Agent's internal message history.
|
|
*/
|
|
pruneScreenshots<T>(messages: T[]): T[] {
|
|
// Find all messages with image content
|
|
const imageIndices: number[] = [];
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i] as any;
|
|
if (hasImageContent(msg.content)) {
|
|
imageIndices.push(i);
|
|
}
|
|
}
|
|
|
|
// If we have fewer images than the keep count, no pruning needed
|
|
if (imageIndices.length <= this.screenshotKeepCount) {
|
|
return this.applyTokenHardLimit(messages);
|
|
}
|
|
|
|
// Clone messages array and replace old screenshots
|
|
const pruned = messages.map((msg, idx) => {
|
|
if (!imageIndices.includes(idx)) return msg;
|
|
|
|
// Keep the most recent N screenshots
|
|
const imageRank = imageIndices.indexOf(idx);
|
|
const keepFrom = imageIndices.length - this.screenshotKeepCount;
|
|
if (imageRank >= keepFrom) return msg;
|
|
|
|
// Replace image content with text placeholder
|
|
return {
|
|
...msg,
|
|
content: replaceImageContent((msg as any).content, `[Screenshot removed - Step ${imageRank + 1}]`),
|
|
};
|
|
});
|
|
|
|
return this.applyTokenHardLimit(pruned);
|
|
}
|
|
|
|
/**
|
|
* Token hard limit fallback — estimate total tokens and force-remove
|
|
* oldest images if exceeding contextWindow * 0.9.
|
|
*/
|
|
private applyTokenHardLimit<T>(messages: T[]): T[] {
|
|
const MAX_CONTEXT_TOKENS = 128_000; // conservative default
|
|
const TOKEN_THRESHOLD = MAX_CONTEXT_TOKENS * 0.9;
|
|
const TOKENS_PER_IMAGE = 800;
|
|
|
|
let totalTokens = 0;
|
|
const imagePositions: number[] = [];
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const msg = messages[i] as any;
|
|
if (!msg.content) continue;
|
|
|
|
if (Array.isArray(msg.content)) {
|
|
for (const c of msg.content) {
|
|
if (c.type === 'image') {
|
|
totalTokens += TOKENS_PER_IMAGE;
|
|
imagePositions.push(i);
|
|
} else if (c.type === 'text' && c.text) {
|
|
totalTokens += Math.ceil(c.text.length / 3);
|
|
}
|
|
}
|
|
} else if (typeof msg.content === 'string') {
|
|
totalTokens += Math.ceil(msg.content.length / 3);
|
|
}
|
|
}
|
|
|
|
if (totalTokens <= TOKEN_THRESHOLD) {
|
|
return messages;
|
|
}
|
|
|
|
// Force-remove images from oldest messages until under threshold
|
|
logDebug(`Token hard limit: estimated ${totalTokens} tokens, threshold ${TOKEN_THRESHOLD}, removing oldest images`);
|
|
const result = [...messages];
|
|
for (const idx of imagePositions) {
|
|
if (totalTokens <= TOKEN_THRESHOLD) break;
|
|
const msg = result[idx] as any;
|
|
if (hasImageContent(msg.content)) {
|
|
result[idx] = {
|
|
...msg,
|
|
content: replaceImageContent(msg.content, '[Screenshot removed - token limit]'),
|
|
} as T;
|
|
totalTokens -= TOKENS_PER_IMAGE;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Compress recent memory into summary via LLM call.
|
|
*/
|
|
private async compressRecent(): Promise<void> {
|
|
try {
|
|
logDebug(`Compressing recent memory (${this.recentMemory.length} chars)`);
|
|
|
|
const summary = await this.summarize(this.recentMemory);
|
|
|
|
if (this.summaryMemory) {
|
|
this.summaryMemory += '\n' + summary;
|
|
} else {
|
|
this.summaryMemory = summary;
|
|
}
|
|
this.recentMemory = '';
|
|
|
|
// Check if summary also exceeds budget
|
|
if (this.summaryMemory.length > this.summaryBudget) {
|
|
await this.compressSummary();
|
|
}
|
|
} catch (err) {
|
|
logError(`Memory compression failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
// On failure, keep recent memory as-is rather than losing data
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Second-level compression: summarize the summary.
|
|
*/
|
|
private async compressSummary(): Promise<void> {
|
|
try {
|
|
logDebug(`Compressing summary memory (${this.summaryMemory.length} chars)`);
|
|
const summary = await this.summarize(this.summaryMemory);
|
|
this.summaryMemory = summary;
|
|
} catch (err) {
|
|
logError(`Summary compression failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call the memory model to generate a summary.
|
|
* If apiKey is empty, just returns the original text (no-op compression).
|
|
*/
|
|
private async summarize(text: string): Promise<string> {
|
|
// If no API key is configured, skip summarization and return original text
|
|
if (!this.apiKey) {
|
|
return text;
|
|
}
|
|
|
|
const result: AssistantMessage = await complete(this.memoryModel, {
|
|
systemPrompt: MEMORY_SUMMARIZATION_PROMPT,
|
|
messages: [
|
|
{ role: 'user' as const, content: text, timestamp: Date.now() },
|
|
],
|
|
}, {
|
|
apiKey: this.apiKey,
|
|
});
|
|
|
|
// Extract text from response
|
|
const textContent = result.content.find(c => c.type === 'text');
|
|
if (!textContent || textContent.type !== 'text') {
|
|
throw new Error('Memory model returned no text content');
|
|
}
|
|
|
|
// Try to parse JSON response
|
|
try {
|
|
const parsed = JSON.parse(textContent.text);
|
|
return parsed.summary || textContent.text;
|
|
} catch {
|
|
// If not JSON, use the raw text
|
|
return textContent.text;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function hasImageContent(content: unknown): boolean {
|
|
if (Array.isArray(content)) {
|
|
return content.some(c => c && typeof c === 'object' && 'type' in c && c.type === 'image');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function replaceImageContent(content: unknown, placeholder: string): unknown {
|
|
if (Array.isArray(content)) {
|
|
return content.map(c => {
|
|
if (c && typeof c === 'object' && 'type' in c && c.type === 'image') {
|
|
return { type: 'text', text: placeholder };
|
|
}
|
|
return c;
|
|
});
|
|
}
|
|
return content;
|
|
}
|