import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; export async function emptyWorkspaceMemory(root: string): Promise { return { version: 1, workspace: { root, key: await workspaceKey(root) }, limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries, }, entries: [], updatedAt: new Date().toISOString(), }; } export async function loadWorkspaceMemory(root: string): Promise { const fallback = await emptyWorkspaceMemory(root); const loaded = await readJSON(await workspaceMemoryPath(root), () => fallback); loaded.workspace = { root, key: await workspaceKey(root) }; loaded.limits = { maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, }; loaded.entries = Array.isArray(loaded.entries) ? loaded.entries : []; return loaded; } export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise { const normalized = await normalizeWorkspaceMemory(root, store); await atomicWriteJSON(await workspaceMemoryPath(root), normalized); } export async function updateWorkspaceMemory( root: string, updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise, ): Promise { const path = await workspaceMemoryPath(root); const fallback = await emptyWorkspaceMemory(root); return updateJSON(path, () => fallback, async current => { const normalized = await normalizeWorkspaceMemory(root, current); return normalizeWorkspaceMemory(root, await updater(normalized)); }); } async function normalizeWorkspaceMemory( root: string, store: WorkspaceMemoryStore, ): Promise { store.workspace = { root, key: await workspaceKey(root) }; store.limits = { maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars, maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries, }; store.entries = enforceLongTermLimits(store.entries); store.updatedAt = new Date().toISOString(); return store; } function sourcePriority(source: LongTermMemoryEntry["source"]): number { if (source === "explicit") return 3; if (source === "manual") return 2; return 1; } function canonicalMemoryText(text: string): string { return text .normalize("NFKC") .toLowerCase() .replace(/[\s\p{P}]+/gu, " ") .trim(); } export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { const byKey = new Map(); for (const entry of entries.filter(entry => entry.status === "active")) { const text = entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars); const key = `${entry.type}:${canonicalMemoryText(text)}`; const existing = byKey.get(key); // Source priority: explicit > manual > compaction // Same source: higher confidence wins if (!existing) { byKey.set(key, { ...entry, text }); } else if (sourcePriority(entry.source) > sourcePriority(existing.source)) { byKey.set(key, { ...entry, text }); } else if (sourcePriority(entry.source) === sourcePriority(existing.source)) { if (entry.confidence > existing.confidence) { byKey.set(key, { ...entry, text }); } } } return [...byKey.values()] .sort((a, b) => priority(b) - priority(a)) .slice(0, LONG_TERM_LIMITS.maxEntries); } function priority(entry: LongTermMemoryEntry): number { const typeWeight = { feedback: 400, decision: 300, project: 200, reference: 100, }[entry.type]; const sourceWeight = entry.source === "explicit" ? 1000 : 0; return sourceWeight + typeWeight + entry.confidence * 10; } function wouldFit( lines: string[], nextLine: string, closingLine: string, maxChars: number ): boolean { return [...lines, nextLine, closingLine].join("\n").length <= maxChars; } export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { const active = enforceLongTermLimits(store.entries); if (active.length === 0) return ""; const maxChars = Math.min( store.limits.maxRenderedChars, LONG_TERM_LIMITS.maxRenderedChars ); // If maxChars smaller than minimum envelope, return empty string if (maxChars < MIN_ENVELOPE_LENGTH) return ""; const lines: string[] = [ "Workspace memory (cross-session, verify if stale):", ]; for (const type of ["feedback", "project", "decision", "reference"] as const) { const items = active.filter(entry => entry.type === type); if (items.length === 0) continue; const sectionLines: string[] = [`${type}:`]; for (const item of items) { const line = `- ${renderEntry(item)}`; if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { sectionLines.push(line); } } if (sectionLines.length > 1) { lines.push(...sectionLines); } } return lines.join("\n"); } function renderEntry(entry: LongTermMemoryEntry): string { const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000); const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : ""; const rationale = entry.rationale ? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}` : ""; return `${entry.text}${rationale}${stale}`; }