import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { type Static, Type } from "typebox"; import { Check, Errors } from "typebox/value"; /** * Memory recall bridge for eval cells (port of pi-fabric's memory recall * surface: a lexical OR search over past sessions). * * The reserved tool name is RESERVED_MEMORY_TOOL ("__memory__"); preludes * expose it as `recall(query, limit?)`. The bridge reads the pi session * JSONL files under /sessions/, matches records with a lexical OR * search (NFKC-normalized, lowercased, no regex), ranks exact query matches * first, and returns bounded results: at most 10 items with 2000 chars per * preview. */ /** Maximum number of recall results the bridge returns. */ export const MEMORY_MAX_RESULTS = 10; /** Maximum preview length per recall result. */ export const MEMORY_PREVIEW_MAX_CHARS = 2000; /** Session files larger than this are skipped to keep recall bounded. */ const MAX_SESSION_FILE_BYTES = 8 * 1024 * 1024; /** One recall result: the session file, the record type, and a bounded preview. */ export interface MemoryRecallItem { /** Absolute path of the session JSONL file the record came from. */ readonly file: string; /** Record type in the session file ("message", "session", "custom", ...). */ readonly type: string; /** Bounded text preview of the record (max 2000 chars). */ readonly preview: string; } export interface RunEvalMemoryOptions { /** Agent directory; session JSONL is read from /sessions/. Defaults to getAgentDir(). */ readonly agentDir?: string; readonly signal?: AbortSignal; } const memoryArgsSchema = Type.Object( { op: Type.Literal("recall"), query: Type.String({ minLength: 1 }), limit: Type.Optional( Type.Integer({ minimum: 1, maximum: MEMORY_MAX_RESULTS }) ), }, { additionalProperties: false } ); type MemoryArgs = Static; class MemoryArgumentsError extends Error { readonly name = "MemoryArgumentsError"; constructor(summary: string) { super(`recall() received invalid arguments: ${summary}`); } } const normalizeText = (text: string): string => text.normalize("NFKC").toLowerCase(); function extractContentText(content: unknown): string { if (!Array.isArray(content)) { return ""; } const parts: string[] = []; for (const part of content) { if (typeof part !== "object" || part === null) { continue; } const entry = part as Readonly>; if (entry.type === "text" && typeof entry.text === "string") { parts.push(entry.text); continue; } // Tool results nest their own content parts; include their text too. if (entry.type === "tool_result" && Array.isArray(entry.content)) { const nested = extractContentText(entry.content); if (nested) { parts.push(nested); } } } return parts.join("\n"); } function extractRecordText(record: unknown): string { if (typeof record !== "object" || record === null) { return ""; } const entry = record as Readonly>; const type = typeof entry.type === "string" ? entry.type : ""; if (type === "message") { const message = entry.message; if (typeof message !== "object" || message === null) { return ""; } return extractContentText((message as Readonly>).content); } if (type === "session") { return [entry.cwd, entry.id, entry.parentSession] .filter((value): value is string => typeof value === "string" && value.length > 0) .join(" "); } if (type === "custom") { const data = entry.data; if (typeof data === "string") { return data; } if (data === undefined) { return ""; } try { return JSON.stringify(data); } catch { return ""; } } return ""; } interface ScannedRecord { readonly file: string; readonly type: string; readonly text: string; } async function collectJsonlFiles(dir: string, files: string[]): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { await collectJsonlFiles(fullPath, files); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { files.push(fullPath); } } } async function scanSessionFiles(agentDir: string): Promise { const files: string[] = []; await collectJsonlFiles(join(agentDir, "sessions"), files); files.sort(); const records: ScannedRecord[] = []; for (const file of files) { try { const info = await stat(file); if (info.size > MAX_SESSION_FILE_BYTES) { continue; } const data = await readFile(file, "utf8"); for (const line of data.split("\n")) { if (!line) { continue; } let record: unknown; try { record = JSON.parse(line); } catch { continue; } const type = typeof record === "object" && record !== null ? ((record as Readonly>).type) : undefined; const text = extractRecordText(record); if (text.length > 0) { records.push({ file, type: typeof type === "string" ? type : "", text, }); } } } catch { // A session file that disappears or cannot be read is skipped. } } return records; } export async function runEvalMemory( args: unknown, options: RunEvalMemoryOptions ): Promise { const parsed = parseMemoryArgs(args); if (options.signal?.aborted) { throw new Error("Memory recall cancelled"); } const query = normalizeText(parsed.query); const terms = query.split(/\s+/u).filter((term) => term.length > 0); if (terms.length === 0) { return []; } const limit = Math.min( parsed.limit ?? MEMORY_MAX_RESULTS, MEMORY_MAX_RESULTS ); const agentDir = options.agentDir ?? getAgentDir(); const records = await scanSessionFiles(agentDir); if (options.signal?.aborted) { throw new Error("Memory recall cancelled"); } interface Match extends ScannedRecord { readonly matchedTerms: number; readonly exact: boolean; readonly order: number; } const matches: Match[] = []; let order = 0; for (const record of records) { const normalized = normalizeText(record.text); if (normalized.length === 0) { continue; } let matchedTerms = 0; for (const term of terms) { if (normalized.includes(term)) { matchedTerms += 1; } } if (matchedTerms === 0) { continue; } matches.push({ ...record, matchedTerms, exact: normalized.includes(query), order, }); order += 1; } matches.sort((left, right) => { if (left.exact !== right.exact) { return left.exact ? -1 : 1; } if (right.matchedTerms !== left.matchedTerms) { return right.matchedTerms - left.matchedTerms; } return left.order - right.order; }); return matches.slice(0, limit).map((match) => ({ file: match.file, type: match.type, preview: match.text.slice(0, MEMORY_PREVIEW_MAX_CHARS), })); } function parseMemoryArgs(value: unknown): MemoryArgs { if (Check(memoryArgsSchema, value)) { return value; } const summary = Errors(memoryArgsSchema, value) .map((error) => `${error.instancePath || "/"} ${error.message}`) .join("; "); throw new MemoryArgumentsError(summary || "invalid value"); }