import { createHash, randomBytes } from "node:crypto"; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; const SEARCH_ID_RE = /^ws_[a-f0-9]{24}$/; const SESSION_DIRECTORY_RE = /^[a-f0-9]{24}$/; const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000; const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024 * 1024; const MAX_SEARCH_ARTIFACT_BYTES = 64 * 1024 * 1024; let cleanupQueue: Promise = Promise.resolve(); export interface SearchArtifactMetadata { kind: "search"; id: string; query: string; createdAt: number; reportBytes: number; reportSha256: string; rawOutputBytes?: number; rawOutputSha256?: string; referenceIds?: string[]; resultCount?: number; provider?: string; model?: string; } export interface ArtifactPaths { directory: string; metadata: string; report: string; rawSearch: string; } export interface ArtifactCleanupOptions { maxAgeMs?: number; maxTotalBytes?: number; now?: number; } function sha256(value: string | Uint8Array): string { return createHash("sha256").update(value).digest("hex"); } function sessionDirectoryName(sessionId: string): string { return createHash("sha256").update(sessionId).digest("hex").slice(0, 24); } function createArtifactId(): string { return `ws_${randomBytes(12).toString("hex")}`; } function assertArtifactId(id: string): void { if (!SEARCH_ID_RE.test(id)) throw new Error(`Invalid search artifact id: ${id}`); } async function writeAtomic(path: string, content: string | Uint8Array): Promise { const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`; await writeFile(temporary, content, { mode: 0o600 }); await rename(temporary, path); } async function directorySize(path: string): Promise { let total = 0; let entries; try { entries = await readdir(path, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; throw error; } for (const entry of entries) { const child = join(path, entry.name); if (entry.isDirectory()) total += await directorySize(child); else if (entry.isFile()) { try { total += (await stat(child)).size; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } } return total; } export class ArtifactStore { readonly cacheRoot: string; readonly root: string; constructor(sessionId: string, cacheRoot = join(getAgentDir(), "cache", "pi-codex-search")) { this.cacheRoot = cacheRoot; this.root = join(cacheRoot, sessionDirectoryName(sessionId)); } private async ensureRoot(): Promise { await mkdir(this.cacheRoot, { recursive: true, mode: 0o700 }); await mkdir(this.root, { recursive: true, mode: 0o700 }); } paths(id: string): ArtifactPaths { assertArtifactId(id); const directory = join(this.root, id); return { directory, metadata: join(directory, "metadata.json"), report: join(directory, "report.md"), rawSearch: join(directory, "raw-search.txt"), }; } async saveSearch( query: string, report: string, extra: Pick< SearchArtifactMetadata, "provider" | "model" | "referenceIds" | "resultCount" > = {}, rawOutput?: string, ): Promise<{ id: string; metadata: SearchArtifactMetadata; paths: ArtifactPaths }> { const reportBytes = Buffer.byteLength(report, "utf8"); const rawOutputBytes = rawOutput === undefined ? 0 : Buffer.byteLength(rawOutput, "utf8"); if (reportBytes + rawOutputBytes > MAX_SEARCH_ARTIFACT_BYTES) { throw new Error(`Search artifact exceeds the ${MAX_SEARCH_ARTIFACT_BYTES} byte limit`); } await this.ensureRoot(); const id = createArtifactId(); const paths = this.paths(id); await mkdir(paths.directory, { recursive: false, mode: 0o700 }); const metadata: SearchArtifactMetadata = { kind: "search", id, query, createdAt: Date.now(), reportBytes, reportSha256: sha256(report), ...(rawOutput === undefined ? {} : { rawOutputBytes, rawOutputSha256: sha256(rawOutput) }), ...extra, }; const writes = [ writeAtomic(paths.report, report), writeAtomic(paths.metadata, `${JSON.stringify(metadata, null, 2)}\n`), ]; if (rawOutput !== undefined) writes.push(writeAtomic(paths.rawSearch, rawOutput)); await Promise.all(writes); return { id, metadata, paths }; } async readMetadata(id: string): Promise { assertArtifactId(id); const text = await readFile(this.paths(id).metadata, "utf8").catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") throw new Error(`Web artifact not found: ${id}`); throw error; }); const metadata = JSON.parse(text) as SearchArtifactMetadata; if (metadata.id !== id || metadata.kind !== "search") { throw new Error(`Invalid metadata for web artifact: ${id}`); } return metadata; } async readSearch(id: string): Promise<{ text: string; metadata: SearchArtifactMetadata; paths: ArtifactPaths }> { assertArtifactId(id); const paths = this.paths(id); const [text, metadata] = await Promise.all([ readFile(paths.report, "utf8").catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") throw new Error(`Search artifact not found: ${id}`); throw error; }), this.readMetadata(id), ]); return { text, metadata, paths }; } cleanup(options: ArtifactCleanupOptions = {}): Promise { const operation = cleanupQueue.then(() => this.cleanupUnlocked(options)); cleanupQueue = operation.catch(() => {}); return operation; } private async cleanupUnlocked(options: ArtifactCleanupOptions): Promise { await this.ensureRoot(); const now = options.now ?? Date.now(); const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS; const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; const records: Array<{ path: string; modifiedAt: number; bytes: number }> = []; for (const session of await readdir(this.cacheRoot, { withFileTypes: true })) { if (!session.isDirectory() || !SESSION_DIRECTORY_RE.test(session.name)) continue; const sessionPath = join(this.cacheRoot, session.name); let entries; try { entries = await readdir(sessionPath, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } for (const entry of entries) { if (!entry.isDirectory() || !SEARCH_ID_RE.test(entry.name)) continue; const path = join(sessionPath, entry.name); let info; try { info = await stat(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } if (now - info.mtimeMs > maxAgeMs) { await rm(path, { recursive: true, force: true }); continue; } records.push({ path, modifiedAt: info.mtimeMs, bytes: await directorySize(path) }); } } let total = records.reduce((sum, record) => sum + record.bytes, 0); for (const record of records.sort((left, right) => left.modifiedAt - right.modifiedAt)) { if (total <= maxTotalBytes) break; await rm(record.path, { recursive: true, force: true }); total -= record.bytes; } } } export function isSearchArtifactId(value: string): boolean { return SEARCH_ID_RE.test(value); }