From 3665aa0f4f138130655fc607826fbf526673f6b2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:51:50 -0500 Subject: [PATCH] Add memory prompt templates and robust JSON summarization --- src/prompts/memory/condense_prompt.txt | 12 ++ src/prompts/memory/prune_prompt.txt | 9 ++ src/prompts/memory/score_prompt.txt | 10 ++ src/prompts/memory/summarize_prompt.txt | 17 +++ src/services/memory/ingestor.js | 10 +- src/services/memory/summarizer.js | 152 +++++++++++++++++++++++- test/memory-summarizer.test.mjs | 47 ++++++++ 7 files changed, 247 insertions(+), 10 deletions(-) create mode 100644 src/prompts/memory/condense_prompt.txt create mode 100644 src/prompts/memory/prune_prompt.txt create mode 100644 src/prompts/memory/score_prompt.txt create mode 100644 src/prompts/memory/summarize_prompt.txt create mode 100644 test/memory-summarizer.test.mjs diff --git a/src/prompts/memory/condense_prompt.txt b/src/prompts/memory/condense_prompt.txt new file mode 100644 index 0000000..41e578e --- /dev/null +++ b/src/prompts/memory/condense_prompt.txt @@ -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}} diff --git a/src/prompts/memory/prune_prompt.txt b/src/prompts/memory/prune_prompt.txt new file mode 100644 index 0000000..31b7f5b --- /dev/null +++ b/src/prompts/memory/prune_prompt.txt @@ -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. diff --git a/src/prompts/memory/score_prompt.txt b/src/prompts/memory/score_prompt.txt new file mode 100644 index 0000000..3d5c293 --- /dev/null +++ b/src/prompts/memory/score_prompt.txt @@ -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 diff --git a/src/prompts/memory/summarize_prompt.txt b/src/prompts/memory/summarize_prompt.txt new file mode 100644 index 0000000..12da634 --- /dev/null +++ b/src/prompts/memory/summarize_prompt.txt @@ -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}} diff --git a/src/services/memory/ingestor.js b/src/services/memory/ingestor.js index 86d6707..d3dd135 100644 --- a/src/services/memory/ingestor.js +++ b/src/services/memory/ingestor.js @@ -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, diff --git a/src/services/memory/summarizer.js b/src/services/memory/summarizer.js index a529448..0f12850 100644 --- a/src/services/memory/summarizer.js +++ b/src/services/memory/summarizer.js @@ -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, + * 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; + } + } } diff --git a/test/memory-summarizer.test.mjs b/test/memory-summarizer.test.mjs new file mode 100644 index 0000000..6ccc7db --- /dev/null +++ b/test/memory-summarizer.test.mjs @@ -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: alice@example.com', 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\]/); +});