import * as fs from "node:fs"; import * as path from "node:path"; import { randomBytes } from "node:crypto"; import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; export const AGI_DIR = ".pi/agi"; export const CONTEXT_BLOCK_MAX_CHARS = 12_000; const SAFE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const AGENT_NAME_PATTERN = /^[a-z0-9][a-z0-9_]{0,63}$/; const TRUNCATION_MARKER = "\n[…truncated; read the file directly…]"; export interface AgiPaths { root: string; goal: string; plan: string; userRequests: string; memory: string; memoryIndex: string; notes: string; archive: string; gitignore: string; runtime: string; orchestratorLock: string; } /** Strict frontmatter is configuration syntax for worker profiles, not AGI state. */ export interface FrontmatterDocument { fields: Record; body: string; bodyStartLine: number; } export interface FileSummary { name: string; title: string; bytes: number; } export type NoteSummary = FileSummary; export interface FileStatus { exists: boolean; bytes: number; error?: string; } export interface DigestResult { content: string; missing: string[]; } export interface RawWriteResult { path: string; bytesWritten: number; } export interface ArchiveResult { path?: string; archived: string[]; } export function resolvePaths(cwd: string): AgiPaths { const root = path.join(cwd, AGI_DIR); const memory = path.join(root, "memory"); const runtime = path.join(root, ".runtime"); return { root, goal: path.join(root, "goal.md"), plan: path.join(root, "plan.md"), userRequests: path.join(root, "user-requests.md"), memory, memoryIndex: path.join(memory, "index.md"), notes: path.join(root, "notes"), archive: path.join(root, "archive"), gitignore: path.join(root, ".gitignore"), runtime, orchestratorLock: path.join(runtime, "orchestrator.lock"), }; } export function readIfExists(file: string): string | undefined { try { return fs.readFileSync(file, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } function ensureNotSymlink(file: string): void { try { if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`Refusing symlink path: ${file}`); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } } function safeName(raw: string, kind: "note" | "memory"): string { const name = raw.trim().replace(/\.md$/i, ""); if (!SAFE_NAME_PATTERN.test(name) || name.includes("..") || name.includes("/") || name.includes("\\")) { throw new Error( `Invalid ${kind} name '${raw}'. Use 1-64 lowercase letters, digits, or hyphens, starting with a letter or digit.`, ); } return name; } export function sanitizeNoteName(raw: string): string { return safeName(raw, "note"); } export function validateMemoryName(raw: string): string { return safeName(raw, "memory"); } export function validateAgentName(raw: string): string { const name = raw.trim(); if (!AGENT_NAME_PATTERN.test(name) || name.includes("..") || name.includes("/") || name.includes("\\")) { throw new Error( `Invalid agent name '${raw}'. Use 1-64 lowercase letters, digits, or underscores, starting with a letter or digit.`, ); } return name; } export function parseFrontmatter(content: string, file = "document"): FrontmatterDocument { const lines = content.replace(/\r\n/g, "\n").split("\n"); if (lines[0] !== "---") throw new Error(`${file}: line 1: expected frontmatter opening '---'.`); const end = lines.indexOf("---", 1); if (end < 0) throw new Error(`${file}: line ${lines.length}: expected frontmatter closing '---'.`); const fields: Record = {}; for (let i = 1; i < end; i++) { const line = lines[i]; if (line === undefined || line.trim().length === 0 || line.trimStart().startsWith("#")) continue; const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/.exec(line); if (match === null) throw new Error(`${file}: line ${i + 1}: expected 'key: value' frontmatter field.`); const key = match[1]; const rawValue = match[2]; if (key === undefined || rawValue === undefined) continue; if (fields[key] !== undefined) throw new Error(`${file}: line ${i + 1}: duplicate frontmatter field '${key}'.`); const value = rawValue.replace(/\s+#.*$/, "").trim(); fields[key] = value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value; } return { fields, body: lines.slice(end + 1).join("\n"), bodyStartLine: end + 2 }; } function writeAtomicSync(file: string, content: string, mode?: number): void { fs.mkdirSync(path.dirname(file), { recursive: true, mode: file.includes(`${path.sep}.runtime`) ? 0o700 : undefined, }); ensureNotSymlink(file); const tmp = `${file}.tmp-${randomBytes(8).toString("hex")}`; try { const fd = fs.openSync(tmp, "wx", mode); try { fs.writeFileSync(fd, content, "utf8"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.renameSync(tmp, file); } catch (error) { try { fs.unlinkSync(tmp); } catch {} throw error; } } function firstHeading(content: string): string { if (content.length === 0) return "empty"; return /^#{1,6}\s+(.+?)\s*$/m.exec(content)?.[1]?.trim() || "untitled"; } function clipText(content: string, limit: number): string { if (content.length <= limit) return content; const keep = Math.max(0, limit - TRUNCATION_MARKER.length); return `${content.slice(0, keep).trimEnd()}${TRUNCATION_MARKER}`; } function hardLimit(content: string): string { if (content.length <= CONTEXT_BLOCK_MAX_CHARS) return content; const marker = "\n\n[truncated; read the files directly for the rest]"; return `${content.slice(0, CONTEXT_BLOCK_MAX_CHARS - marker.length).trimEnd()}${marker}`; } function listMarkdownFiles(dir: string, exclude = new Set()): FileSummary[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; return [{ name: "[unavailable]", title: (error as Error).message, bytes: 0 }]; } const summaries: FileSummary[] = []; for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { if (!entry.isFile() || !entry.name.endsWith(".md") || exclude.has(entry.name)) continue; const file = path.join(dir, entry.name); try { ensureNotSymlink(file); const content = readIfExists(file); if (content === undefined) continue; summaries.push({ name: entry.name, title: firstHeading(content), bytes: Buffer.byteLength(content) }); } catch (error) { summaries.push({ name: entry.name, title: `[unavailable: ${(error as Error).message}]`, bytes: 0 }); } } return summaries; } function renderListing(label: string, files: FileSummary[], limit: number): string { const body = files.length === 0 ? "(none)" : files.map((file) => `- ${file.name} — ${file.title} (${file.bytes} bytes)`).join("\n"); return `## ${label}\n\n${clipText(body, limit)}`; } function directoryHasEntries(dir: string): boolean { try { return fs.readdirSync(dir).length > 0; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } } function collisionSafeArchivePath(base: string, stamp: string): string { let candidate = path.join(base, stamp); for (let suffix = 2; fs.existsSync(candidate); suffix++) candidate = path.join(base, `${stamp}-${suffix}`); return candidate; } export class StateStore { readonly paths: AgiPaths; constructor(cwd: string) { this.paths = resolvePaths(cwd); } /** Create storage directories only. Content files are optional and model-owned. */ scaffold(): void { fs.mkdirSync(this.paths.memory, { recursive: true }); fs.mkdirSync(this.paths.notes, { recursive: true }); fs.mkdirSync(this.paths.runtime, { recursive: true, mode: 0o700 }); if (readIfExists(this.paths.gitignore) === undefined) writeAtomicSync(this.paths.gitignore, ".runtime/\n"); } readGoal(): string | undefined { ensureNotSymlink(this.paths.goal); return readIfExists(this.paths.goal); } readPlan(): string | undefined { ensureNotSymlink(this.paths.plan); return readIfExists(this.paths.plan); } readUserRequests(): string | undefined { ensureNotSymlink(this.paths.userRequests); return readIfExists(this.paths.userRequests); } readMemory(name: string): string | undefined { const file = path.join(this.paths.memory, `${validateMemoryName(name)}.md`); ensureNotSymlink(file); return readIfExists(file); } readNote(name: string): string | undefined { const file = path.join(this.paths.notes, `${sanitizeNoteName(name)}.md`); ensureNotSymlink(file); return readIfExists(file); } listNotes(): FileSummary[] { return listMarkdownFiles(this.paths.notes); } listMemoryFiles(): FileSummary[] { return listMarkdownFiles(this.paths.memory, new Set(["index.md"])); } listMemories(): string[] { return this.listMemoryFiles().filter((file) => file.name.endsWith(".md")).map((file) => file.name.slice(0, -3)); } fileStatus(file: string): FileStatus { try { ensureNotSymlink(file); const stat = fs.statSync(file); if (!stat.isFile()) return { exists: true, bytes: 0, error: "not a regular file" }; return { exists: true, bytes: stat.size }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, bytes: 0 }; return { exists: true, bytes: 0, error: (error as Error).message }; } } async writeGoal(content: string, ..._legacyIgnored: unknown[]): Promise { return this.writeRaw(this.paths.goal, content); } async writePlan(content: string, ..._legacyIgnored: unknown[]): Promise { return this.writeRaw(this.paths.plan, content); } /** Preserve each real user turn verbatim as ordinary, free-form durable context. */ async appendUserRequest(content: string): Promise { return withFileMutationQueue(this.paths.userRequests, async () => { ensureNotSymlink(this.paths.userRequests); const existing = readIfExists(this.paths.userRequests); const next = existing === undefined ? `# User requests\n\n${content}\n` : `${existing.trimEnd()}\n\n---\n\n${content}\n`; writeAtomicSync(this.paths.userRequests, next); return { path: this.paths.userRequests, bytesWritten: Buffer.byteLength(next) }; }); } async writeMemory(name: string, content: string, ..._legacyIgnored: unknown[]): Promise { return this.writeRaw(path.join(this.paths.memory, `${validateMemoryName(name)}.md`), content); } async writeNote(name: string, content: string, ..._legacyIgnored: unknown[]): Promise { return this.writeRaw(path.join(this.paths.notes, `${sanitizeNoteName(name)}.md`), content); } private async writeRaw(file: string, content: string): Promise { return withFileMutationQueue(file, async () => { writeAtomicSync(file, content); return { path: file, bytesWritten: Buffer.byteLength(content) }; }); } async archive(): Promise { return withFileMutationQueue(this.paths.root, async () => { ensureNotSymlink(this.paths.root); ensureNotSymlink(this.paths.goal); ensureNotSymlink(this.paths.plan); ensureNotSymlink(this.paths.notes); ensureNotSymlink(this.paths.archive); const candidates: Array<{ from: string; name: string }> = []; if (readIfExists(this.paths.goal) !== undefined) candidates.push({ from: this.paths.goal, name: "goal.md" }); if (readIfExists(this.paths.plan) !== undefined) candidates.push({ from: this.paths.plan, name: "plan.md" }); if (directoryHasEntries(this.paths.notes)) candidates.push({ from: this.paths.notes, name: "notes" }); if (candidates.length === 0) return { archived: [] }; fs.mkdirSync(this.paths.archive, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const target = collisionSafeArchivePath(this.paths.archive, stamp); fs.mkdirSync(target); const moved: Array<{ from: string; to: string }> = []; try { for (const candidate of candidates) { const destination = path.join(target, candidate.name); fs.renameSync(candidate.from, destination); moved.push({ from: candidate.from, to: destination }); } fs.mkdirSync(this.paths.notes, { recursive: true }); return { path: target, archived: candidates.map((candidate) => candidate.name) }; } catch (error) { for (const move of moved.reverse()) { try { fs.renameSync(move.to, move.from); } catch {} } try { fs.rmdirSync(target); } catch {} throw error; } }); } buildDigest(notice?: string): DigestResult { const missing: string[] = []; const rawSection = (label: string, file: string, limit: number): string => { let content: string | undefined; try { ensureNotSymlink(file); content = readIfExists(file); } catch (error) { return `## ${label}\n\n[unavailable: ${(error as Error).message}]`; } if (content === undefined) { missing.push(label); return `## ${label}\n\n(missing)`; } return `## ${label}\n\n${content.length === 0 ? "(empty)" : clipText(content, limit)}`; }; const sections = [ "# Working files", rawSection("goal.md", this.paths.goal, 3_100), rawSection("plan.md", this.paths.plan, 3_100), rawSection("memory/index.md", this.paths.memoryIndex, 2_200), renderListing("notes/", this.listNotes(), 1_100), renderListing("memory/ files", this.listMemoryFiles(), 1_100), ...(notice === undefined || notice.trim().length === 0 ? [] : [`## Current notice\n\n${clipText(notice.trim(), 900)}`]), ]; return { content: hardLimit(sections.join("\n\n")), missing }; } } export function isInitialized(paths: AgiPaths): boolean { return fs.existsSync(paths.root); }