Add memory prompt templates and robust JSON summarization

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 14:51:50 -05:00
1 parent 415bff738e
commit 3665aa0f4f
7 files changed
+247 -10

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
The previous response was malformed JSON.
Repair it into STRICT JSON only, preserving original meaning and using conservative confidence:
{"summary":"...","importance":0.0}
No markdown. No preamble. No trailing text.
Parse error:
{{PARSE_ERROR}}
Malformed response:
{{RAW_RESPONSE}}
+9
View File
@@ -0,0 +1,9 @@
Decide whether low-value memory can be pruned.
Output STRICT JSON only:
{"prune":false,"reason":"short factual reason"}
Rules:
- Never prune if uncertain.
- Never expose PII; redact if quoted.
- Prefer retaining safety-critical, decision, and incident context.
+10
View File
@@ -0,0 +1,10 @@
Score memory importance from 0.0 to 1.0 conservatively.
Output STRICT JSON only:
{"importance":0.0,"rationale":"short factual reason"}
Guidelines:
- 0.0-0.3: routine/noisy or low impact
- 0.4-0.7: useful operational context
- 0.8-1.0: high-salience commitments, incidents, or decisions
- Avoid over-scoring uncertain data
+17
View File
@@ -0,0 +1,17 @@
You are a memory summarizer.
Task:
- Summarize the input events factually.
- Do not invent entities, actions, or outcomes not present in the events.
- Redact PII (emails, phone numbers, SSNs) as [redacted:*].
- Keep summary length <= {{MAX_SUMMARY_LENGTH}} characters.
Return STRICT JSON only (no markdown, no prose):
{"summary":"...","importance":0.0}
Rules:
- importance is a number between 0.0 and 1.0.
- Use conservative importance when uncertain.
Events:
{{EVENTS}}
+6 -4
View File
@@ -182,9 +182,9 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
const chunks = chunkEvents(sorted, options);
for (const chunk of chunks) {
const summary = summarizeEvents(chunk, options);
const summarization = await summarizeEvents(chunk, options);
const contextText = chunk.map((event) => event.content).join('\n');
const embedding = await embedText(`${summary}\n${contextText}`, options);
const embedding = await embedText(`${summarization.summary}\n${contextText}`, options);
const chunkStart = Math.min(...chunk.map((event) => event.timestamp));
const chunkEnd = Math.max(...chunk.map((event) => event.timestamp));
@@ -193,9 +193,11 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown',
type: 'batch_window',
content: contextText,
summary,
summary: summarization.summary,
tags: [...new Set(chunk.flatMap((event) => event.tags))],
importance: Number.isFinite(chunk[0]?.metadata?.importance) ? Number(chunk[0].metadata.importance) : 0.5,
importance: Number.isFinite(chunk[0]?.metadata?.importance)
? Number(chunk[0].metadata.importance)
: summarization.importance,
embedding_id: Array.isArray(embedding) && embedding.length > 0 ? randomUUID() : null,
created_at: chunkStart,
last_seen: chunkEnd,
+146 -6
View File
@@ -1,11 +1,151 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_TEMPLATE_DIR = path.resolve(__dirname, '../../prompts/memory');
const TEMPLATE_FILES = {
summarize: 'summarize_prompt.txt',
condense: 'condense_prompt.txt',
score: 'score_prompt.txt',
prune: 'prune_prompt.txt',
};
const templateCache = new Map();
function redactObviousPii(text) {
if (typeof text !== 'string' || text.length === 0) return '';
return text
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]')
.replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]')
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]');
}
function renderTemplate(template, replacements) {
return Object.entries(replacements).reduce(
(output, [key, value]) => output.replaceAll(`{{${key}}}`, String(value ?? '')),
template
);
}
export function loadMemoryPromptTemplates(options = {}) {
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
if (templateCache.has(templateDir)) {
return templateCache.get(templateDir);
}
const templates = Object.fromEntries(
Object.entries(TEMPLATE_FILES).map(([key, fileName]) => {
const filePath = path.join(templateDir, fileName);
const content = readFileSync(filePath, 'utf8').trim();
return [key, content];
})
);
templateCache.set(templateDir, templates);
return templates;
}
function parseSummaryPayload(rawResponse) {
const parsed = JSON.parse(rawResponse);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new TypeError('Response must be a JSON object');
}
const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : '';
if (!summary) {
throw new TypeError('Response.summary must be a non-empty string');
}
const rawImportance = Number(parsed.importance);
const importance = Number.isFinite(rawImportance)
? Math.min(1, Math.max(0, rawImportance))
: 0.25;
return { summary, importance };
}
function deterministicFallback(events, maxSummaryLength) {
const joined = events.map((event) => redactObviousPii(event.content)).join(' ').trim();
const bounded = joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
return { summary: bounded, importance: 0.25 };
}
function isLikelyFactual(summary, sourceText) {
const sourceTokens = new Set(
sourceText
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 3)
);
const summaryTokens = summary
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 3);
if (!summaryTokens.length) return true;
const overlap = summaryTokens.filter((token) => sourceTokens.has(token)).length;
return overlap / summaryTokens.length >= 0.35;
}
function sanitizeResult(parsed, sourceText, maxSummaryLength, fallback) {
const sanitizedSummary = redactObviousPii(parsed.summary);
if (!isLikelyFactual(sanitizedSummary, sourceText)) {
return fallback;
}
return {
summary: sanitizedSummary.length > maxSummaryLength
? `${sanitizedSummary.slice(0, maxSummaryLength - 1)}…`
: sanitizedSummary,
importance: parsed.importance,
};
}
/**
* @param {import('./schema.js').MemoryEvent[]} events
* @param {{ maxSummaryLength?: number }} [options]
* @returns {string}
* @param {{
* maxSummaryLength?: number,
* generateSummary?: (prompt: string) => Promise<string> | string,
* templateDir?: string,
* }} [options]
* @returns {Promise<{ summary: string, importance: number }>}
*/
export function summarizeEvents(events, options = {}) {
export async function summarizeEvents(events, options = {}) {
const maxSummaryLength = options.maxSummaryLength ?? 280;
const joined = events.map((event) => event.content).join(' ').trim();
if (!joined) return '';
return joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
const fallback = deterministicFallback(events, maxSummaryLength);
if (!fallback.summary) return fallback;
const templates = loadMemoryPromptTemplates(options);
const sourceText = events.map((event) => redactObviousPii(event.content)).join('\n').trim();
const generateSummary = options.generateSummary;
if (typeof generateSummary !== 'function') {
return fallback;
}
const summaryPrompt = renderTemplate(templates.summarize, {
EVENTS: sourceText,
MAX_SUMMARY_LENGTH: maxSummaryLength,
});
let firstResponse = '';
try {
firstResponse = await generateSummary(summaryPrompt);
const parsed = parseSummaryPayload(firstResponse);
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
} catch (firstError) {
try {
const repairPrompt = renderTemplate(templates.condense, {
RAW_RESPONSE: firstResponse,
PARSE_ERROR: firstError instanceof Error ? firstError.message : 'unknown_parse_error',
});
const repairedResponse = await generateSummary(repairPrompt);
const parsed = parseSummaryPayload(repairedResponse);
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
} catch {
return fallback;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadMemoryPromptTemplates, summarizeEvents } from '../src/services/memory/summarizer.js';
const SAMPLE_EVENTS = [
{ agent_id: 'a', content: 'Deployment completed for service alpha', timestamp: 1, tags: [], metadata: {} },
{ agent_id: 'a', content: 'Contact: [email protected]', timestamp: 2, tags: [], metadata: {} },
];
test('loadMemoryPromptTemplates loads all memory prompt templates from disk', () => {
const templates = loadMemoryPromptTemplates();
assert.ok(templates.summarize.includes('STRICT JSON only'));
assert.ok(templates.condense.includes('malformed JSON'));
assert.ok(templates.score.includes('importance'));
assert.ok(templates.prune.includes('prune'));
});
test('summarizeEvents retries with repair prompt when first response is malformed JSON', async () => {
const prompts = [];
const result = await summarizeEvents(SAMPLE_EVENTS, {
generateSummary(prompt) {
prompts.push(prompt);
if (prompts.length === 1) {
return '{"summary":"partial"';
}
return '{"summary":"Deployment completed for service alpha [redacted:email]","importance":0.6}';
},
});
assert.equal(prompts.length, 2);
assert.equal(result.importance, 0.6);
assert.equal(result.summary, 'Deployment completed for service alpha [redacted:email]');
});
test('summarizeEvents falls back to deterministic minimal summary with conservative importance when parsing fails twice', async () => {
const result = await summarizeEvents(SAMPLE_EVENTS, {
maxSummaryLength: 80,
async generateSummary() {
return 'not json';
},
});
assert.equal(result.importance, 0.25);
assert.match(result.summary, /Deployment completed for service alpha/);
assert.match(result.summary, /\[redacted:email\]/);
});