import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { sanitizeText } from "../security/privacy.js"; import { estimateTokens } from "./envelope.js"; import type { PrivacyClass, TaskShape } from "../types.js"; export const ROLE_MEMORY_ROLES = ["root", "scout", "writer", "reviewer", "warroom-member"] as const; export type RoleMemoryRole = (typeof ROLE_MEMORY_ROLES)[number]; export const ROLE_MEMORY_LIMITS = { maxEntries: 64, maxBytes: 64 * 1024, maxClaimChars: 400, sliceTokens: 400, sliceEntries: 5 } as const; export interface RoleMemoryEntry { claim: string; intent: string; breadth: string; paths: string[]; } function isEntry(value: unknown): value is RoleMemoryEntry { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const entry = value as Record; return typeof entry.claim === "string" && entry.claim.trim().length > 0 && typeof entry.intent === "string" && typeof entry.breadth === "string" && Array.isArray(entry.paths) && entry.paths.every((path) => typeof path === "string"); } export function roleMemoryPath(root: string, role: RoleMemoryRole): string { return join(root, "role-memory", `${role}.json`); } function evict(entries: RoleMemoryEntry[]): RoleMemoryEntry[] { let kept = entries.slice(-ROLE_MEMORY_LIMITS.maxEntries); while (kept.length > 1 && Buffer.byteLength(JSON.stringify(kept), "utf8") > ROLE_MEMORY_LIMITS.maxBytes) kept = kept.slice(1); return kept; } export async function readRoleMemory(root: string, role: RoleMemoryRole): Promise { try { const value: unknown = JSON.parse(await readFile(roleMemoryPath(root, role), "utf8")); return Array.isArray(value) ? value.filter(isEntry) : []; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } } export function retainsRoleMemory(privacyClass: PrivacyClass): boolean { return privacyClass === "public" || privacyClass === "internal"; } export async function appendRoleMemory(root: string, role: RoleMemoryRole, entries: readonly RoleMemoryEntry[], secret: Buffer | string, privacyClass: PrivacyClass = "internal"): Promise { if (!entries.length || !retainsRoleMemory(privacyClass)) return await readRoleMemory(root, role); const sanitized = entries.map((entry) => ({ claim: sanitizeText(entry.claim, privacyClass, secret).slice(0, ROLE_MEMORY_LIMITS.maxClaimChars), intent: entry.intent, breadth: entry.breadth, paths: entry.paths.map((path) => sanitizeText(path, privacyClass, secret)).slice(0, 8), })).filter((entry) => entry.claim.trim().length > 0); const merged = evict([...(await readRoleMemory(root, role)), ...sanitized]); const file = roleMemoryPath(root, role); await mkdir(join(root, "role-memory"), { recursive: true, mode: 0o700 }); await writeFile(file, JSON.stringify(merged), { mode: 0o600 }); return merged; } export function roleMemorySlice(entries: readonly RoleMemoryEntry[], shape: Pick): string[] { const scored = entries.map((entry) => { const pathOverlap = entry.paths.filter((path) => shape.mentionedPaths.some((mentioned) => mentioned === path || mentioned.startsWith(`${path}/`) || path.startsWith(`${mentioned}/`))).length; return { entry, score: pathOverlap * 4 + (entry.intent === shape.intent ? 2 : 0) + (entry.breadth === shape.breadth ? 1 : 0) }; }).filter((candidate) => candidate.score > 0); scored.sort((left, right) => right.score - left.score); const slice: string[] = []; for (const candidate of scored.slice(0, ROLE_MEMORY_LIMITS.sliceEntries)) { const next = [...slice, candidate.entry.claim]; if (estimateTokens(next) > ROLE_MEMORY_LIMITS.sliceTokens) break; slice.push(candidate.entry.claim); } return slice; }