From fefba2697d18e59615bdcffe33e0050a3fa797ce Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 03:44:51 +0200 Subject: [PATCH 1/9] Files page: shared types and helpers --- packages/core/src/memory/editor.ts | 2 +- packages/core/src/memory/workspace.ts | 15 +++- packages/shared/src/api.ts | 110 ++++++++++++++++++++++++++ packages/shared/src/models.ts | 3 +- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/packages/core/src/memory/editor.ts b/packages/core/src/memory/editor.ts index 94fb26bd..275ccefa 100644 --- a/packages/core/src/memory/editor.ts +++ b/packages/core/src/memory/editor.ts @@ -22,7 +22,7 @@ export function memoryDigestOf(text: string): string { } /** Markdown notes under memory/, sorted; hidden entries and symlinks are skipped. */ -function memoryTopics(repoPath: string): string[] { +export function memoryTopics(repoPath: string): string[] { const out: string[] = []; const walk = (rel: string, depth: number) => { if (depth > 6 || out.length >= MAX_TOPICS) return; diff --git a/packages/core/src/memory/workspace.ts b/packages/core/src/memory/workspace.ts index 485727ee..8d98abd5 100644 --- a/packages/core/src/memory/workspace.ts +++ b/packages/core/src/memory/workspace.ts @@ -226,6 +226,15 @@ export function namedBy(agent: Owner): (rel: string) => string | null { return (rel) => pinnedBy(agent, refs, rel) ?? memoryMentions(refs, rel)[0] ?? null; } +/** Every place that names `workspace/` or something in it, read once for many entries: what keeps it, then the memory files. */ +export function namesOf(agent: Owner): (rel: string) => string[] { + const refs = references(agent); + return (rel) => { + const pin = pinnedBy(agent, refs, rel); + return [...(pin ? [pin] : []), ...memoryMentions(refs, rel)]; + }; +} + /** Paths in tmp/ the agent's notes name (`tmp/...`, existing ones only) → where they are named. */ function namedScratch(root: string, refs: References): Map { const found = new Map(); @@ -256,14 +265,14 @@ function inProject(root: string, rel: string): boolean { return false; } -interface Note { +export interface Note { rel: string; bytes: number; modified: number; } /** The agent's notes: Markdown files in the workspace, outside tmp/, uploads/, hidden folders, packages and projects. */ -function listNotes(root: string): Note[] { +export function listNotes(root: string): Note[] { const notes: Note[] = []; const walk = (dir: string, depth: number) => { let names: string[]; @@ -292,7 +301,7 @@ function listNotes(root: string): Note[] { } /** The note's first line as a title. */ -function noteTitle(abs: string): string { +export function noteTitle(abs: string): string { try { const head = readFileSync(abs, "utf8").slice(0, 2_000); const line = head.split("\n").find((l) => l.trim()) ?? ""; diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 4c5dff47..c8ad532d 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -555,6 +555,116 @@ export interface AgentFileEntry { modifiedAt: string; } +/* ------------------------------------------------------------------ */ +/* Files page: the human tidies an agent's repository */ +/* ------------------------------------------------------------------ */ + +/** Size of a file or folder: every file below it counted, symlinks never followed. */ +export interface AgentFileSize { + path: string; + bytes: number; + files: number; + /** Newest change of a file in it (a folder without files: its own). */ + modifiedAt: string | null; + /** The count stopped early (a very large folder): at least this much. */ + partial: boolean; +} + +/** GET /api/files/overview — one row per agent, for picking the one to tidy. */ +export interface AgentFilesOverview { + agentId: ID; + /** workspace/, memory/ and MEMORY.md together. */ + bytes: number; + workspaceBytes: number; + /** Of that, what lies in workspace/tmp (Godmode clears it by itself). */ + scratchBytes: number; + /** Markdown notes in memory/ and workspace/ (tmp/ and uploads/ left out). */ + notes: number; + memoryChars: number; + /** Entries in the agent's bin (deleted on the Files page, not yet emptied). */ + deleted: number; + partial: boolean; +} + +/** What the agent keeps about a path, before it is moved or deleted. */ +export interface AgentFileInfo extends AgentFileSize { + type: "file" | "dir"; + /** Where it is named ("MEMORY.md", "the automation …"); empty = nowhere. */ + namedBy: string[]; + /** Why it can't be moved or deleted here (null = it can). */ + locked: string | null; +} + +export type FileReviewKind = "build" | "junk" | "empty" | "duplicate" | "large" | "scratch" | "stale" | "loose" | "unlinked-note" | "broken-link"; + +/** One thing on the Files page's Review list. */ +export interface FileReviewItem { + kind: FileReviewKind; + path: string; + type: "file" | "dir"; + bytes: number; + files: number; + modifiedAt: string | null; + /** Why it is on the list, in a sentence. */ + detail: string; + /** Where the agent names it (it may still need it); null = nowhere. */ + namedBy: string | null; + /** duplicate: the copy that stays. broken-link: the file that links to the missing path. */ + related: string | null; +} + +export interface FileReview { + agentId: ID; + checkedAt: ISODate; + items: FileReviewItem[]; + /** The look stopped early (a very large repository): there may be more. */ + partial: boolean; +} + +/** A Markdown note of the agent, for the Notes list. */ +export interface AgentNote { + path: string; + title: string; + bytes: number; + modifiedAt: string; + /** MEMORY.md, or a note MEMORY.md or another memory note links to (unlinked memory notes are rarely read). */ + linked: boolean; + namedBy: string | null; +} + +export interface AgentFileSearchHit { + path: string; + type: "file" | "dir"; + /** 1-based line of the first match in the content; null = the name matched. */ + line: number | null; + snippet: string | null; +} + +/** An entry the human deleted on the Files page: kept in the agent's bin until it is put back or emptied. */ +export interface DeletedAgentFile { + id: string; + path: string; + type: "file" | "dir"; + bytes: number; + files: number; + deletedAt: ISODate; +} + +export interface AgentFilesDeleteResult { + deleted: DeletedAgentFile[]; + refused: { path: string; reason: string }[]; +} + +export interface AgentFileMoveResult { + path: string; + /** Memory files whose mentions of the old path now point at the new one. */ + rewritten: string[]; +} + +export interface AgentFileRestoreResult { + path: string; +} + export interface GitCommit { oid: string; message: string; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 21fc9575..e30f6d21 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -1792,7 +1792,8 @@ export type CleanupId = | "vm-images" | "agent-scratch" | "agent-run-logs" - | "agent-history"; + | "agent-history" + | "deleted-files"; export interface CleanupEntry { name: string; From fb1fc89aaaccd7c3cebefb7b93fca4df206a7d0c Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 03:48:00 +0200 Subject: [PATCH 2/9] Files page: core sizes, review, notes, search, moves and the bin --- packages/core/src/agents/files.ts | 941 ++++++++++++++++++++++ packages/core/src/cloud/scope.ts | 19 +- packages/core/src/server/routes/agents.ts | 57 ++ packages/core/src/services/cleanup.ts | 19 + 4 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/agents/files.ts diff --git a/packages/core/src/agents/files.ts b/packages/core/src/agents/files.ts new file mode 100644 index 00000000..b47e0b2e --- /dev/null +++ b/packages/core/src/agents/files.ts @@ -0,0 +1,941 @@ +/** + * The Files page: the human looks through an agent's repository and tidies it by hand — folder sizes, the agent's + * notes, a search, a review of what clutters it (build output, junk, empty entries, copies, large files, stale and + * unsorted entries, memory notes nothing links to), new folders, moves and deletes. + * + * A delete goes into the agent's bin (`/.deleted-files///`), where it waits to be put back until + * the automatic cleanup removes what is older than DELETED_DAYS (services/cleanup.ts). A move points the agent's memory + * at the new place. Godmode's own files stay where they are (lockReason), nothing moves while the agent dreams (the + * dream's rollback and undo would lose track of it), symlinks are never followed, and every change is committed. + */ +import { createHash, randomBytes } from "node:crypto"; +import { createReadStream, existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { cp, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, join } from "node:path"; +import type { + Agent, + AgentFileInfo, + AgentFileMoveResult, + AgentFileRestoreResult, + AgentFileSearchHit, + AgentFileSize, + AgentFilesDeleteResult, + AgentFilesOverview, + AgentNote, + DeletedAgentFile, + FileReview, + FileReviewItem, + FileReviewKind, +} from "@godmode/shared"; +import { formatBytes } from "@godmode/shared"; +import { config } from "../config"; +import { all } from "../db"; +import { isDreaming } from "../memory/dreaming"; +import { memoryTopics } from "../memory/editor"; +import { MEMORY_DIR, MEMORY_FILE, canRestore, memoryChars, snapshotMemory } from "../memory/files"; +import { listNotes, namesOf, noteTitle, WORKSPACE_DIR } from "../memory/workspace"; +import { HttpError, badRequest, conflict, notFound, now } from "../util"; +import { AGENT_REPO_DIRS } from "./claudeMd"; +import { commitAllInLock, resolveRepoPath, withRepoLock } from "./repo"; +import { withRepo } from "./service"; + +const BIN = ".deleted-files"; +/** What the human deleted waits this long in the agent's bin, then the automatic cleanup removes it. */ +export const DELETED_DAYS = 30; +const DAY = 86_400_000; +/** Entries one size count visits at most (a folder with more counts as "at least"). */ +const COUNT_BUDGET = 400_000; +const REVIEW_BUDGET = 250_000; +const SEARCH_BUDGET = 80_000; +const SEARCH_LIMIT = 100; +const SEARCH_READ_MAX = 512 * 1024; +const LARGE = 25 * 1024 * 1024; +const LARGE_SCRATCH = 50 * 1024 * 1024; +const STALE_DAYS = 30; +const RECENT_DAYS = 3; +const HASH_MIN = 1024; +const HASH_MAX_FILE = 1024 * 1024 * 1024; +const HASH_BUDGET = 2 * 1024 * 1024 * 1024; +const KIND_LIMIT = 200; +const MAX_PATHS = 500; +const CACHE_MS = 30_000; + +const SCRATCH = `${WORKSPACE_DIR}/tmp`; +const UPLOADS = `${WORKSPACE_DIR}/uploads`; + +/** Installed packages and caches: installing or building again brings them back. */ +const BUILD = new Set([ + "node_modules", + ".venv", + "venv", + "__pycache__", + "Pods", + "DerivedData", + ".next", + ".nuxt", + ".turbo", + ".gradle", + ".pytest_cache", + ".parcel-cache", + ".svelte-kit", + ".dart_tool", + ".expo", +]); +/** Build output, when it lies next to a project file. */ +const OUTPUT = new Set(["build", "dist", "out", "target", ".build"]); +const PROJECT_FILES = ["package.json", "build.gradle", "build.gradle.kts", "pubspec.yaml", "Cargo.toml", "pyproject.toml", "setup.py", "Package.swift", "pom.xml", "Podfile"]; +const JUNK = /^(\.DS_Store|Thumbs\.db|desktop\.ini|npm-debug\.log.*|yarn-error\.log|~\$.*|\.~lock\..*#)$|\.(log|tmp|temp|bak|swp|crdownload|part)$/i; +const TEXT = /\.(md|markdown|txt|json|jsonl|csv|tsv|ya?ml|toml|html?|css|xml|sh|py|m?js|ts|tsx|jsx|sql|env\.example)$/i; +const NOTE = /\.(md|markdown)$/i; +const ROOT_KNOWN = new Set([MEMORY_FILE, "CLAUDE.md", ".gitignore", ...AGENT_REPO_DIRS]); + +/* ------------------------------------------------------------------ */ +/* What stays put */ +/* ------------------------------------------------------------------ */ + +const FIXED: Record = { + "": "The agent's repository itself.", + [MEMORY_FILE]: "The agent's memory: edit it here, but it stays.", + "CLAUDE.md": "Written from the agent's settings — change them in its Settings tab.", + ".gitignore": "Keeps scratch files and run logs out of the agent's history.", + [WORKSPACE_DIR]: "Every agent has this folder.", + [MEMORY_DIR]: "Every agent has this folder.", + [SCRATCH]: "Godmode's scratch folder: what lies in it goes by itself a week after its last change.", + [UPLOADS]: "Where the files you attach in chats land.", +}; +const GODMODE_DIRS: Record = { + conversations: "Chat transcripts are written by Godmode.", + runs: "Run logs are written by Godmode — Settings → Cleanup removes the old ones.", + state: "A snapshot of the agent's settings, written by Godmode.", +}; + +/** Why `rel` can't be moved or deleted on the Files page (null = it can). */ +export function lockReason(rel: string): string | null { + if (rel in FIXED) return FIXED[rel]!; + const parts = rel.split("/"); + if (parts.length === 2 && parts[1] === ".gitkeep" && (AGENT_REPO_DIRS as readonly string[]).includes(parts[0]!)) return "Keeps the folder in the agent's history."; + return GODMODE_DIRS[parts[0]!] ?? null; +} + +/** Where moved entries and new folders may go: inside workspace/ or memory/. */ +function placeable(rel: string): boolean { + return rel.startsWith(`${WORKSPACE_DIR}/`) || rel.startsWith(`${MEMORY_DIR}/`); +} + +/** No symlink on the way from the repository down to `rel` (with `self`, the entry itself neither). */ +function noLink(root: string, rel: string, self = true): boolean { + const parts = rel ? rel.split("/") : []; + let probe = root; + for (let i = 0; i < parts.length; i++) { + probe = join(probe, parts[i]!); + if (i === parts.length - 1 && !self) break; + try { + if (lstatSync(probe).isSymbolicLink()) return false; + } catch { + return true; + } + } + return true; +} + +function entryOf(root: string, rel: string) { + try { + return lstatSync(join(root, rel)); + } catch { + return null; + } +} + +/** A path of the repository as the human sent it, confined to it ("" = the repository itself). */ +function repoPath(agent: Agent, input: string): string { + return resolveRepoPath(agent.repoPath, input).rel; +} + +function refuseWhileDreaming(agent: Agent) { + if (isDreaming(agent.id)) { + throw conflict(`${agent.name} is dreaming (tidying its memory and workspace) right now — try again when the dream has ended.`); + } +} + +/* ------------------------------------------------------------------ */ +/* Sizes */ +/* ------------------------------------------------------------------ */ + +interface Tally { + bytes: number; + files: number; + modified: number; + partial: boolean; +} + +const NONE: Tally = { bytes: 0, files: 0, modified: 0, partial: false }; + +function add(a: Tally, b: Tally): Tally { + return { bytes: a.bytes + b.bytes, files: a.files + b.files, modified: Math.max(a.modified, b.modified), partial: a.partial || b.partial }; +} + +function limiter(max: number) { + let active = 0; + const waiting: (() => void)[] = []; + return async (fn: () => Promise): Promise => { + if (active >= max) await new Promise((resolve) => waiting.push(resolve)); + active++; + try { + return await fn(); + } finally { + active--; + waiting.shift()?.(); + } + }; +} + +const io = limiter(48); + +/** Counts what lies below a path (symlinks counted, never followed) within one budget of entries. */ +class Counter { + constructor(private left = COUNT_BUDGET) {} + + async of(abs: string): Promise { + if (this.left-- <= 0) return { ...NONE, partial: true }; + const st = await io(() => lstat(abs)).catch(() => null); + if (!st) return NONE; + if (!st.isDirectory()) return { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false }; + const names = await io(() => readdir(abs)).catch(() => [] as string[]); + const total = (await Promise.all(names.map((n) => this.of(join(abs, n))))).reduce(add, NONE); + return total.files ? total : { ...total, modified: st.mtimeMs }; + } +} + +const sizeCache = new Map }>(); + +function cachedTally(abs: string): Promise { + const hit = sizeCache.get(abs); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.tally; + const tally = new Counter().of(abs); + sizeCache.set(abs, { at: Date.now(), tally }); + return tally; +} + +const reviewCache = new Map }>(); +let overviewCache: { at: number; rows: Promise } | null = null; + +/** Something in the agent's repository changed here: sizes and the review are looked at again. */ +function forget(agent: Agent) { + for (const key of sizeCache.keys()) if (key === agent.repoPath || key.startsWith(`${agent.repoPath}/`)) sizeCache.delete(key); + reviewCache.delete(agent.id); + overviewCache = null; +} + +function sizeOf(path: string, t: Tally): AgentFileSize { + return { path, bytes: t.bytes, files: t.files, modifiedAt: t.modified ? new Date(t.modified).toISOString() : null, partial: t.partial }; +} + +/** Sizes of what lies directly in a folder of the repository. */ +export async function folderSizes(agentId: string, dir: string): Promise { + const agent = await withRepo(agentId); + const rel = repoPath(agent, dir); + const st = entryOf(agent.repoPath, rel); + if (!st || !noLink(agent.repoPath, rel)) throw notFound("Folder"); + if (!st.isDirectory()) throw badRequest("Path is not a folder"); + const names = readdirSync(join(agent.repoPath, rel)).filter((n) => n.toLowerCase() !== ".git"); + return Promise.all( + names.map(async (name) => { + const child = rel ? `${rel}/${name}` : name; + return sizeOf(child, await cachedTally(join(agent.repoPath, child))); + }), + ); +} + +function memoryNoteCount(repoPath: string): number { + return memoryTopics(repoPath).filter((p) => NOTE.test(p)).length; +} + +function binDir(agentId: string): string { + return join(config().agentsDir, BIN, agentId); +} + +/** One row per agent: what its files take, how many notes it keeps, how long its memory is. */ +export function filesOverview(): Promise { + if (overviewCache && Date.now() - overviewCache.at < CACHE_MS * 4) return overviewCache.rows; + const rows = Promise.all( + all<{ id: string; repo_path: string }>("SELECT id, repo_path FROM agents ORDER BY name").map(async (a) => { + const workspace = join(a.repo_path, WORKSPACE_DIR); + const [ws, scratch, memory, memoryFile] = await Promise.all([ + cachedTally(workspace), + cachedTally(join(workspace, "tmp")), + cachedTally(join(a.repo_path, MEMORY_DIR)), + cachedTally(join(a.repo_path, MEMORY_FILE)), + ]); + let notes = memoryNoteCount(a.repo_path); + if (entryOf(a.repo_path, WORKSPACE_DIR)?.isDirectory()) notes += listNotes(workspace).length; + return { + agentId: a.id, + bytes: ws.bytes + memory.bytes + memoryFile.bytes, + workspaceBytes: ws.bytes, + scratchBytes: scratch.bytes, + notes, + memoryChars: memoryChars(a.repo_path), + deleted: deletedEntries(a.id).length, + partial: ws.partial || memory.partial, + }; + }), + ); + overviewCache = { at: Date.now(), rows }; + rows.catch(() => (overviewCache = null)); + return rows; +} + +/* ------------------------------------------------------------------ */ +/* Who names what */ +/* ------------------------------------------------------------------ */ + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** `rel` as a path of its own in a text (not the start of a longer name). */ +function mention(rel: string, flags = ""): RegExp { + return new RegExp(`(? string[] { + const files = Object.entries(snapshotMemory(repoPath).files); + return (rel) => { + const full = mention(rel); + const name = basename(rel); + const stem = name.replace(/\.(md|markdown|txt)$/i, ""); + const near = new RegExp(`\\]\\((?:\\./)?${escape(name)}(?:#[^)]*)?\\)`); + const wiki = new RegExp(`\\[\\[${escape(stem)}(?:\\|[^\\]]*)?\\]\\]`); + return files + .filter(([path, text]) => path !== rel && (full.test(text) || wiki.test(text) || (dirname(path) === dirname(rel) && near.test(text)))) + .map(([path]) => path); + }; +} + +/** Where the agent names a path of its repository (read once for many paths). */ +function namer(agent: Agent): (rel: string) => string[] { + const workspace = namesOf(agent); + const memory = memoryLinks(agent.repoPath); + return (rel) => { + if (rel.startsWith(`${WORKSPACE_DIR}/`)) return workspace(rel.slice(WORKSPACE_DIR.length + 1)); + if (rel.startsWith(`${MEMORY_DIR}/`)) return memory(rel); + return []; + }; +} + +/** Size, what names them and whether they may go, for the paths the human is about to move or delete. */ +export async function inspectFiles(agentId: string, paths: string[]): Promise { + const agent = await withRepo(agentId); + const named = namer(agent); + const out: AgentFileInfo[] = []; + for (const input of paths.slice(0, MAX_PATHS)) { + const rel = repoPath(agent, input); + const st = entryOf(agent.repoPath, rel); + if (!st) continue; + const tally = st.isSymbolicLink() ? { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false } : await cachedTally(join(agent.repoPath, rel)); + out.push({ ...sizeOf(rel, tally), type: st.isDirectory() ? "dir" : "file", namedBy: named(rel), locked: lockReason(rel) }); + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Review */ +/* ------------------------------------------------------------------ */ + +/** Which kind wins when one path fits several. */ +const PRIORITY: FileReviewKind[] = ["build", "junk", "empty", "duplicate", "large", "scratch", "stale", "loose", "unlinked-note", "broken-link"]; + +interface Candidate { + kind: FileReviewKind; + rel: string; + dir: boolean; + tally: Tally; + detail: string; + related?: string; +} + +function day(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +function isProjectFolder(abs: string): boolean { + return PROJECT_FILES.some((f) => existsSync(join(abs, f))); +} + +async function sha(abs: string): Promise { + return new Promise((resolve) => { + const hash = createHash("sha256"); + createReadStream(abs) + .on("data", (chunk) => hash.update(chunk)) + .on("error", () => resolve(null)) + .on("end", () => resolve(hash.digest("hex"))); + }); +} + +class Reviewer { + readonly found: Candidate[] = []; + readonly copies = new Map(); + partial = false; + private left = REVIEW_BUDGET; + private counter = new Counter(); + + constructor( + private root: string, + private at: number, + ) {} + + private add(kind: FileReviewKind, rel: string, dir: boolean, tally: Tally, detail: string, related?: string) { + this.found.push({ kind, rel, dir, tally, detail, related }); + } + + /** Walk a folder of the repository: what clutters it, and the files that may be copies of each other. */ + async walk(rel: string, depth: number): Promise { + if (this.left <= 0 || depth > 14) { + this.partial = true; + return false; + } + const abs = join(this.root, rel); + const entries = await io(() => readdir(abs, { withFileTypes: true })).catch(() => null); + if (!entries) return false; + this.left -= entries.length; + let kept = 0; + for (const entry of entries) { + const name = entry.name; + const child = `${rel}/${name}`; + const childAbs = join(abs, name); + if (name.toLowerCase() === ".git") { + kept++; + continue; + } + if (entry.isSymbolicLink()) { + kept++; + continue; + } + if (entry.isDirectory()) { + if (child === SCRATCH) { + kept++; + await this.scratch(); + continue; + } + if (BUILD.has(name) || (OUTPUT.has(name) && isProjectFolder(abs))) { + const tally = await this.counter.of(childAbs); + this.add("build", child, true, tally, BUILD.has(name) ? `Installed packages or a cache (${name}) — installing or building again brings it back.` : `Build output (${name}) — building the project again brings it back.`); + kept++; + continue; + } + // A project of its own (a clone): its files are its business, not clutter of the agent. + if (existsSync(join(childAbs, ".git"))) { + kept++; + continue; + } + if (name.startsWith(".")) { + kept++; + continue; + } + const filled = await this.walk(child, depth + 1); + if (filled) kept++; + else { + const st = await lstat(childAbs).catch(() => null); + this.add("empty", child, true, { ...NONE, modified: st?.mtimeMs ?? 0 }, "An empty folder."); + } + continue; + } + if (!entry.isFile()) continue; + const st = await io(() => lstat(childAbs)).catch(() => null); + if (!st) continue; + const tally: Tally = { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false }; + if (JUNK.test(name)) { + this.add("junk", child, false, tally, name === ".DS_Store" ? "Finder's folder settings — macOS writes them again when needed." : /\.log$/i.test(name) ? "A log file." : "A temporary or backup file."); + continue; + } + if (name === ".gitkeep") { + kept++; + continue; + } + kept++; + if (st.size === 0) { + this.add("empty", child, false, tally, "An empty file."); + continue; + } + if (st.size >= LARGE) this.add("large", child, false, tally, `${formatBytes(st.size)} — one of the largest files here.`); + if (st.size >= HASH_MIN && st.size <= HASH_MAX_FILE) { + const list = this.copies.get(st.size) ?? []; + list.push({ rel: child, modified: st.mtimeMs }); + this.copies.set(st.size, list); + } + } + return kept > 0; + } + + /** tmp/ goes by itself: only what is large is worth a look now. */ + private async scratch() { + const abs = join(this.root, SCRATCH); + for (const name of await readdir(abs).catch(() => [] as string[])) { + if (name.startsWith(".")) continue; + const rel = `${SCRATCH}/${name}`; + const st = await lstat(join(abs, name)).catch(() => null); + if (!st || st.isSymbolicLink()) continue; + const tally = await this.counter.of(join(abs, name)); + const discarded = /^discarded-(\d{4}-\d{2}-\d{2})/.exec(name); + if (discarded) { + this.add("scratch", rel, st.isDirectory(), tally, `A dream sorted these out on ${discarded[1]} — the daily cleanup removes them a week after their last change.`); + } else if (tally.bytes >= LARGE_SCRATCH) { + const left = Math.max(0, Math.ceil((tally.modified + 7 * DAY - this.at) / DAY)); + this.add("scratch", rel, st.isDirectory(), tally, `Scratch in tmp/ — goes by itself ${left ? `in about ${left} day${left === 1 ? "" : "s"}` : "with the next daily cleanup"}, unless a note names it.`); + } + } + } + + /** Top of the repository and of workspace/: stray files, and entries nobody touched for long. */ + async tops() { + for (const name of await readdir(this.root).catch(() => [] as string[])) { + if (name.startsWith(".") || ROOT_KNOWN.has(name)) continue; + const st = await lstat(join(this.root, name)).catch(() => null); + if (!st || st.isSymbolicLink()) continue; + const tally = await this.counter.of(join(this.root, name)); + if (JUNK.test(name)) this.add("junk", name, st.isDirectory(), tally, /\.log$/i.test(name) ? "A log file at the top of the repository." : "A temporary file at the top of the repository."); + else this.add("loose", name, st.isDirectory(), tally, "Lies at the top of the repository, outside workspace/ — move it into workspace/ or delete it."); + } + const ws = join(this.root, WORKSPACE_DIR); + for (const name of await readdir(ws).catch(() => [] as string[])) { + const rel = `${WORKSPACE_DIR}/${name}`; + if (name.startsWith(".") || rel === SCRATCH || rel === UPLOADS) continue; + const st = await lstat(join(ws, name)).catch(() => null); + if (!st || st.isSymbolicLink()) continue; + const tally = await this.counter.of(join(ws, name)); + const idle = Math.floor((this.at - tally.modified) / DAY); + if (tally.modified && idle >= STALE_DAYS) { + this.add("stale", rel, st.isDirectory(), tally, `Nothing in it changed for ${idle} days (last ${day(tally.modified)}).`); + } else if (!st.isDirectory() && idle >= RECENT_DAYS) { + this.add("loose", rel, false, tally, "Lies loose at the top of workspace/ — sort it into a folder."); + } + } + } + + /** Files with the same content: the copy the agent names (or the oldest) stays. */ + async duplicates(named: (rel: string) => string[]) { + let budget = HASH_BUDGET; + for (const [size, list] of this.copies) { + if (list.length < 2) continue; + const byHash = new Map(); + for (const file of list) { + if (budget < size) { + this.partial = true; + break; + } + budget -= size; + const hash = await sha(join(this.root, file.rel)); + if (!hash) continue; + byHash.set(hash, [...(byHash.get(hash) ?? []), file]); + } + for (const group of byHash.values()) { + if (group.length < 2) continue; + const keep = [...group].sort( + (a, b) => + Number(named(b.rel).length > 0) - Number(named(a.rel).length > 0) || + Number(b.rel.startsWith(`${MEMORY_DIR}/`)) - Number(a.rel.startsWith(`${MEMORY_DIR}/`)) || + Number(a.rel.startsWith(`${SCRATCH}/`)) - Number(b.rel.startsWith(`${SCRATCH}/`)) || + a.modified - b.modified || + a.rel.length - b.rel.length, + )[0]!; + for (const copy of group) { + if (copy === keep) continue; + this.add("duplicate", copy.rel, false, { bytes: size, files: 1, modified: copy.modified, partial: false }, `Same content as ${keep.rel}.`, keep.rel); + } + } + } + } + + /** Memory notes nothing links to, and links to notes that don't exist. */ + memory(links: (rel: string) => string[]) { + for (const rel of memoryTopics(this.root)) { + if (!NOTE.test(rel) || links(rel).length) continue; + const st = entryOf(this.root, rel); + if (!st) continue; + this.add("unlinked-note", rel, false, { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false }, "No link from MEMORY.md or another note — the agent may never read it. Link it, merge it, or delete it."); + } + const pattern = /(?(); + for (const [file, text] of Object.entries(snapshotMemory(this.root).files)) { + for (const m of text.matchAll(pattern)) { + const target = m[0]; + if (seen.has(target) || target.split("/").some((p) => p === ".." || p === "") || entryOf(this.root, target)) continue; + seen.add(target); + this.add("broken-link", target, false, NONE, `${file} links to it, but it doesn't exist.`, file); + } + } + } +} + +/** What clutters the agent's repository, by kind; what the agent still names is marked, never hidden. */ +export async function reviewFiles(agentId: string, fresh = false): Promise { + const agent = await withRepo(agentId); + const hit = reviewCache.get(agent.id); + if (!fresh && hit && Date.now() - hit.at < CACHE_MS * 2) return hit.review; + const review = buildReview(agent); + reviewCache.set(agent.id, { at: Date.now(), review }); + review.catch(() => reviewCache.delete(agent.id)); + return review; +} + +async function buildReview(agent: Agent): Promise { + const at = Date.now(); + const r = new Reviewer(agent.repoPath, at); + const named = namer(agent); + if (entryOf(agent.repoPath, WORKSPACE_DIR)?.isDirectory()) await r.walk(WORKSPACE_DIR, 0); + if (entryOf(agent.repoPath, MEMORY_DIR)?.isDirectory()) await r.walk(MEMORY_DIR, 0); + await r.tops(); + await r.duplicates(named); + r.memory(memoryLinks(agent.repoPath)); + + const byPath = new Map(); + for (const c of r.found) { + const prev = byPath.get(c.rel); + if (!prev || PRIORITY.indexOf(c.kind) < PRIORITY.indexOf(prev.kind)) byPath.set(c.rel, c); + } + // A folder already on the list covers what lies in it. + const folders = [...byPath.values()].filter((c) => c.dir).map((c) => `${c.rel}/`); + const items: FileReviewItem[] = []; + const perKind = new Map(); + const sorted = [...byPath.values()] + .filter((c) => !lockReason(c.rel) && !folders.some((f) => c.rel.startsWith(f))) + .sort((a, b) => PRIORITY.indexOf(a.kind) - PRIORITY.indexOf(b.kind) || b.tally.bytes - a.tally.bytes || a.rel.localeCompare(b.rel)); + for (const c of sorted) { + const n = perKind.get(c.kind) ?? 0; + if (n >= KIND_LIMIT) { + r.partial = true; + continue; + } + perKind.set(c.kind, n + 1); + const names = c.kind === "broken-link" ? [] : named(c.rel); + items.push({ + kind: c.kind, + path: c.rel, + type: c.dir ? "dir" : "file", + bytes: c.tally.bytes, + files: c.tally.files, + modifiedAt: c.tally.modified ? new Date(c.tally.modified).toISOString() : null, + detail: c.detail, + namedBy: names[0] ?? null, + related: c.related ?? null, + }); + } + return { agentId: agent.id, checkedAt: new Date(at).toISOString(), items, partial: r.partial }; +} + +/* ------------------------------------------------------------------ */ +/* Notes and search */ +/* ------------------------------------------------------------------ */ + +/** The agent's Markdown notes: MEMORY.md, memory/ and the workspace (tmp/, uploads/ and clones left out). */ +export async function listAgentNotes(agentId: string): Promise { + const agent = await withRepo(agentId); + const links = memoryLinks(agent.repoPath); + const named = namer(agent); + const notes: AgentNote[] = []; + const push = (rel: string, linked: boolean) => { + const st = entryOf(agent.repoPath, rel); + if (!st?.isFile()) return; + const names = named(rel); + notes.push({ + path: rel, + title: noteTitle(join(agent.repoPath, rel)) || basename(rel), + bytes: st.size, + modifiedAt: st.mtime.toISOString(), + linked, + namedBy: names[0] ?? null, + }); + }; + push(MEMORY_FILE, true); + for (const rel of memoryTopics(agent.repoPath)) if (NOTE.test(rel)) push(rel, links(rel).length > 0); + const ws = join(agent.repoPath, WORKSPACE_DIR); + if (entryOf(agent.repoPath, WORKSPACE_DIR)?.isDirectory()) { + for (const note of listNotes(ws)) { + const rel = `${WORKSPACE_DIR}/${note.rel}`; + push(rel, named(rel).length > 0); + } + } + return notes; +} + +function snippetOf(line: string, at: number, length: number): string { + const start = Math.max(0, at - 60); + const end = Math.min(line.length, at + length + 100); + return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`; +} + +/** Files and folders whose name holds `query`, and text files that contain it (first match per file). */ +export async function searchAgentFiles(agentId: string, query: string): Promise { + const agent = await withRepo(agentId); + const needle = query.trim().toLowerCase(); + if (needle.length < 2) return []; + const hits: AgentFileSearchHit[] = []; + let left = SEARCH_BUDGET; + const visit = async (rel: string, depth: number): Promise => { + if (hits.length >= SEARCH_LIMIT || left <= 0 || depth > 12) return; + const entries = await readdir(join(agent.repoPath, rel), { withFileTypes: true }).catch(() => []); + left -= entries.length; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (hits.length >= SEARCH_LIMIT) return; + const name = entry.name; + if (name.startsWith(".") || BUILD.has(name) || entry.isSymbolicLink()) continue; + const child = `${rel}/${name}`; + const nameHit = name.toLowerCase().includes(needle); + if (entry.isDirectory()) { + if (nameHit) hits.push({ path: child, type: "dir", line: null, snippet: null }); + if (!existsSync(join(agent.repoPath, child, ".git"))) await visit(child, depth + 1); + continue; + } + if (!entry.isFile()) continue; + if (nameHit) { + hits.push({ path: child, type: "file", line: null, snippet: null }); + continue; + } + if (!TEXT.test(name)) continue; + const st = await lstat(join(agent.repoPath, child)).catch(() => null); + if (!st || st.size > SEARCH_READ_MAX) continue; + const text = await readFile(join(agent.repoPath, child), "utf8").catch(() => ""); + const lower = text.toLowerCase(); + const at = lower.indexOf(needle); + if (at < 0) continue; + const lineStart = lower.lastIndexOf("\n", at) + 1; + const lineEnd = lower.indexOf("\n", at); + const line = text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd); + hits.push({ path: child, type: "file", line: text.slice(0, at).split("\n").length, snippet: snippetOf(line, at - lineStart, needle.length) }); + } + }; + if (entryOf(agent.repoPath, MEMORY_FILE)) { + const text = readFileSync(join(agent.repoPath, MEMORY_FILE), "utf8"); + const at = text.toLowerCase().indexOf(needle); + if (at >= 0) { + const lineStart = text.lastIndexOf("\n", at) + 1; + const lineEnd = text.indexOf("\n", at); + hits.push({ path: MEMORY_FILE, type: "file", line: text.slice(0, at).split("\n").length, snippet: snippetOf(text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd), at - lineStart, needle.length) }); + } + } + await visit(MEMORY_DIR, 1); + await visit(WORKSPACE_DIR, 1); + return hits.sort((a, b) => Number(a.line !== null) - Number(b.line !== null)); +} + +/* ------------------------------------------------------------------ */ +/* Changes */ +/* ------------------------------------------------------------------ */ + +/** A new, empty folder inside workspace/ or memory/. */ +export async function createFolder(agentId: string, input: string): Promise<{ path: string }> { + const agent = await withRepo(agentId); + const rel = repoPath(agent, input); + if (!placeable(rel)) throw badRequest("New folders go inside workspace/ or memory/."); + if (!noLink(agent.repoPath, rel)) throw badRequest("The folder would lie behind a link."); + if (entryOf(agent.repoPath, rel)) throw conflict(`${rel} already exists.`); + await withRepoLock(agent.repoPath, () => mkdir(join(agent.repoPath, rel), { recursive: true })); + forget(agent); + return { path: rel }; +} + +/** Point the agent's memory at `to` wherever it named `from` (its own absolute paths too, never another agent's). */ +function repointMemory(repoPath: string, from: string, to: string): string[] { + const pattern = mention(from, "g"); + const own = (text: string, at: number) => at === 0 || text[at - 1] !== "/" || text.slice(0, at - 1).endsWith(repoPath) || text.slice(Math.max(0, at - 2), at) === "./"; + const changed: string[] = []; + for (const [path, text] of Object.entries(snapshotMemory(repoPath).files)) { + const next = text.replace(pattern, (match: string, at: number) => (own(text, at) ? to : match)); + if (next === text || !canRestore(repoPath, path) || !noLink(repoPath, path)) continue; + writeFileSync(join(repoPath, path), next); + changed.push(path); + } + return changed; +} + +/** Rename or move an entry inside workspace/ or memory/ (never over another one); the memory follows it. */ +export async function moveFile(agentId: string, fromInput: string, toInput: string): Promise { + const agent = await withRepo(agentId); + const from = repoPath(agent, fromInput); + const to = repoPath(agent, toInput); + const locked = lockReason(from); + if (locked) throw new HttpError(403, locked, "locked"); + if (!placeable(to) || lockReason(to)) throw badRequest("Entries move inside workspace/ or memory/."); + if (to === from) return { path: to, rewritten: [] }; + if (to.startsWith(`${from}/`)) throw badRequest("A folder can't move into itself."); + refuseWhileDreaming(agent); + return withRepoLock(agent.repoPath, async () => { + const source = entryOf(agent.repoPath, from); + if (!source) throw notFound(from); + if (!noLink(agent.repoPath, from, false) || !noLink(agent.repoPath, to, false)) throw badRequest("The way there goes through a link."); + const target = entryOf(agent.repoPath, to); + // Only a new spelling of the same name (on a case-insensitive disk) may find "itself" there. + if (target && !(target.ino === source.ino && target.dev === source.dev && from.toLowerCase() === to.toLowerCase())) { + throw conflict(`${to} already exists — pick another name.`); + } + await mkdir(dirname(join(agent.repoPath, to)), { recursive: true }); + await rename(join(agent.repoPath, from), join(agent.repoPath, to)); + const rewritten = repointMemory(agent.repoPath, from, to); + await commitAllInLock(agent.repoPath, `Move ${from} to ${to}`); + forget(agent); + return { path: to, rewritten }; + }); +} + +/* ------------------------------------------------------------------ */ +/* The bin */ +/* ------------------------------------------------------------------ */ + +const ENTRY_ID = /^[a-z0-9]{8,40}$/; + +interface Stored extends DeletedAgentFile { + dir: string; +} + +function readEntry(dir: string, id: string): Stored | null { + try { + const meta = JSON.parse(readFileSync(join(dir, id, "entry.json"), "utf8")) as DeletedAgentFile; + if (typeof meta.path !== "string" || !existsSync(join(dir, id, "item"))) return null; + return { ...meta, id, dir: join(dir, id) }; + } catch { + return null; + } +} + +function deletedEntries(agentId: string): Stored[] { + const dir = binDir(agentId); + let ids: string[]; + try { + ids = readdirSync(dir); + } catch { + return []; + } + return ids + .filter((id) => ENTRY_ID.test(id)) + .map((id) => readEntry(dir, id)) + .filter((e): e is Stored => !!e) + .sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)); +} + +const strip = ({ dir: _dir, ...entry }: Stored): DeletedAgentFile => entry; + +export async function listDeletedFiles(agentId: string): Promise { + const agent = await withRepo(agentId); + return deletedEntries(agent.id).map(strip); +} + +/** Entries of every agent's bin deleted more than `days` ago (agents removed since included): for the cleanup. */ +export function expiredDeletedFiles(at = Date.now(), days = DELETED_DAYS): { agentId: string; path: string; dir: string; deletedAt: string }[] { + const root = join(config().agentsDir, BIN); + let agents: string[]; + try { + agents = readdirSync(root); + } catch { + return []; + } + return agents.flatMap((agentId) => + deletedEntries(agentId) + .filter((e) => at - Date.parse(e.deletedAt) >= days * DAY) + .map((e) => ({ agentId, path: e.path, dir: e.dir, deletedAt: e.deletedAt })), + ); +} + +async function moveAside(from: string, to: string) { + try { + await rename(from, to); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err; + await cp(from, to, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true }); + await rm(from, { recursive: true, force: true }); + } +} + +/** Move entries into the agent's bin, in one commit. What must stay is refused, the rest goes. */ +export async function deleteFiles(agentId: string, inputs: string[]): Promise { + const agent = await withRepo(agentId); + refuseWhileDreaming(agent); + const paths = [...new Set(inputs.slice(0, MAX_PATHS).map((p) => repoPath(agent, p)))]; + // A folder that goes takes what lies in it along. + const top = paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(`${q}/`))); + const result: AgentFilesDeleteResult = { deleted: [], refused: [] }; + await withRepoLock(agent.repoPath, async () => { + const bin = binDir(agent.id); + for (const rel of top) { + const locked = lockReason(rel); + if (locked) { + result.refused.push({ path: rel, reason: locked }); + continue; + } + const st = entryOf(agent.repoPath, rel); + if (!st) { + result.refused.push({ path: rel, reason: "It isn't there anymore." }); + continue; + } + if (!noLink(agent.repoPath, rel, false)) { + result.refused.push({ path: rel, reason: "It lies behind a link." }); + continue; + } + const tally = st.isDirectory() ? await new Counter().of(join(agent.repoPath, rel)) : { bytes: st.size, files: 1, modified: st.mtimeMs, partial: false }; + const id = `${Date.now().toString(36)}${randomBytes(5).toString("hex")}`; + const dir = join(bin, id); + const entry: DeletedAgentFile = { id, path: rel, type: st.isDirectory() ? "dir" : "file", bytes: tally.bytes, files: tally.files, deletedAt: now() }; + try { + await mkdir(dir, { recursive: true, mode: 0o700 }); + await writeFile(join(dir, "entry.json"), JSON.stringify(entry)); + await moveAside(join(agent.repoPath, rel), join(dir, "item")); + result.deleted.push(entry); + } catch (err) { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + result.refused.push({ path: rel, reason: `It couldn't be moved to the bin (${err instanceof Error ? err.message : String(err)}).` }); + } + } + if (result.deleted.length) { + const first = result.deleted[0]!.path; + await commitAllInLock(agent.repoPath, result.deleted.length === 1 ? `Delete ${first}` : `Delete ${first} and ${result.deleted.length - 1} more`); + } + }); + for (const rel of paths) if (!top.includes(rel)) result.refused.push({ path: rel, reason: "Goes with the folder it lies in." }); + forget(agent); + return result; +} + +/** A free place next to `rel`: "notes (restored).md", "notes (restored 2).md", … */ +function freePlace(root: string, rel: string): string { + if (!entryOf(root, rel)) return rel; + const ext = extname(rel); + const base = ext ? rel.slice(0, -ext.length) : rel; + for (let i = 1; ; i++) { + const next = `${base} (restored${i > 1 ? ` ${i}` : ""})${ext}`; + if (!entryOf(root, next)) return next; + } +} + +/** Put a deleted entry back where it was (next to it, when something new took its place). */ +export async function restoreDeletedFile(agentId: string, id: string): Promise { + const agent = await withRepo(agentId); + if (!ENTRY_ID.test(id)) throw notFound("Deleted entry"); + refuseWhileDreaming(agent); + return withRepoLock(agent.repoPath, async () => { + const entry = readEntry(binDir(agent.id), id); + if (!entry) throw notFound("Deleted entry"); + const original = repoPath(agent, entry.path); + if (lockReason(original)) throw badRequest("It can't go back there."); + if (!noLink(agent.repoPath, original, false)) throw badRequest("The way back goes through a link."); + const rel = freePlace(agent.repoPath, original); + await mkdir(dirname(join(agent.repoPath, rel)), { recursive: true }); + await moveAside(join(entry.dir, "item"), join(agent.repoPath, rel)); + await rm(entry.dir, { recursive: true, force: true }); + await commitAllInLock(agent.repoPath, `Restore ${rel}`); + forget(agent); + return { path: rel }; + }); +} + +/** Remove deleted entries for good: one (`id`), or the whole bin. */ +export async function purgeDeletedFiles(agentId: string, id?: string): Promise<{ removed: number; bytes: number }> { + const agent = await withRepo(agentId); + if (id !== undefined && !ENTRY_ID.test(id)) throw notFound("Deleted entry"); + const entries = deletedEntries(agent.id).filter((e) => id === undefined || e.id === id); + if (id !== undefined && !entries.length) throw notFound("Deleted entry"); + let bytes = 0; + for (const e of entries) { + await rm(e.dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + bytes += e.bytes; + } + overviewCache = null; + return { removed: entries.length, bytes }; +} diff --git a/packages/core/src/cloud/scope.ts b/packages/core/src/cloud/scope.ts index ac845183..d7152662 100644 --- a/packages/core/src/cloud/scope.ts +++ b/packages/core/src/cloud/scope.ts @@ -142,6 +142,18 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["GET", "/api/agents/:id/commands", A], ["GET", "/api/agents/:id/files", A], ["GET|PUT", "/api/agents/:id/file", A], + ["GET", "/api/files/overview", A], + ["GET", "/api/agents/:id/files/sizes", A], + ["POST", "/api/agents/:id/files/inspect", A], + ["GET", "/api/agents/:id/files/review", A], + ["GET", "/api/agents/:id/files/notes", A], + ["GET", "/api/agents/:id/files/search", A], + ["POST", "/api/agents/:id/files/folder", A], + ["POST", "/api/agents/:id/files/move", A], + ["POST", "/api/agents/:id/files/delete", A], + ["GET|DELETE", "/api/agents/:id/files/deleted", A], + ["POST", "/api/agents/:id/files/deleted/:entryId/restore", A], + ["DELETE", "/api/agents/:id/files/deleted/:entryId", A], ["GET|PUT", "/api/agents/:id/memory", A], ["GET", "/api/agents/:id/commits", A], ["GET", "/api/agents/:id/heartbeat", A], @@ -408,8 +420,11 @@ const RULES: [methods: string, path: string, rule: Rule][] = [ ["POST", "/api/budgets/release", A], ]; -/** Writes a viewer may make: resolving a chat's files changes nothing. */ -const VIEWER_WRITES: [string, string][] = [["POST", "/api/conversations/:id/files"]]; +/** Writes a viewer may make: resolving a chat's files and looking at an agent's before a delete change nothing. */ +const VIEWER_WRITES: [string, string][] = [ + ["POST", "/api/conversations/:id/files"], + ["POST", "/api/agents/:id/files/inspect"], +]; /** Reads a viewer may not make: pictures of the screen, codes and keys. */ const VIEWER_HIDDEN_READS = ["/api/computer/thumbnail", "/api/computer/sources", "/api/vms/:id/screenshot", "/api/android/devices/:id/screenshot", "/api/totp/codes", "/api/ssh/local-keys"]; diff --git a/packages/core/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts index b4d7bfdb..73a9e67d 100644 --- a/packages/core/src/server/routes/agents.ts +++ b/packages/core/src/server/routes/agents.ts @@ -39,6 +39,20 @@ import { dreamOverview, getDream, isDreaming, revertDream, startDream } from ".. import { isMemoryPath } from "../../memory/files"; import { MAX_MEMORY_EDIT_BYTES, readAgentMemory, writeAgentMemory } from "../../memory/editor"; import { resolveRepoPath } from "../../agents/repo"; +import { + createFolder, + deleteFiles, + filesOverview, + folderSizes, + inspectFiles, + listAgentNotes, + listDeletedFiles, + moveFile, + purgeDeletedFiles, + restoreDeletedFile, + reviewFiles, + searchAgentFiles, +} from "../../agents/files"; import { getSettings } from "../../services/settings"; import { conflict } from "../../util"; import { requestDevice } from "../auth"; @@ -293,6 +307,49 @@ export function registerAgentRoutes(app: Hono): void { return c.json({ ok: true }); }); + /* Files page: sizes, review, notes, search, moves and the bin -------- */ + + const repoPathInput = z.string().trim().max(1024); + const pathList = z.array(repoPathInput.min(1)).min(1).max(500); + + app.get("/api/files/overview", async (c) => c.json(await filesOverview())); + + app.get("/api/agents/:id/files/sizes", async (c) => c.json(await folderSizes(c.req.param("id"), c.req.query("path") ?? ""))); + + app.post("/api/agents/:id/files/inspect", async (c) => { + const { paths } = await body(c, z.object({ paths: pathList })); + return c.json(await inspectFiles(c.req.param("id"), paths)); + }); + + app.get("/api/agents/:id/files/review", async (c) => c.json(await reviewFiles(c.req.param("id"), c.req.query("fresh") === "1"))); + + app.get("/api/agents/:id/files/notes", async (c) => c.json(await listAgentNotes(c.req.param("id")))); + + app.get("/api/agents/:id/files/search", async (c) => c.json(await searchAgentFiles(c.req.param("id"), (c.req.query("q") ?? "").slice(0, 200)))); + + app.post("/api/agents/:id/files/folder", async (c) => { + const { path } = await body(c, z.object({ path: repoPathInput.min(1) })); + return c.json(await createFolder(c.req.param("id"), path)); + }); + + app.post("/api/agents/:id/files/move", async (c) => { + const { from, to } = await body(c, z.object({ from: repoPathInput.min(1), to: repoPathInput.min(1) })); + return c.json(await moveFile(c.req.param("id"), from, to)); + }); + + app.post("/api/agents/:id/files/delete", async (c) => { + const { paths } = await body(c, z.object({ paths: pathList })); + return c.json(await deleteFiles(c.req.param("id"), paths)); + }); + + app.get("/api/agents/:id/files/deleted", async (c) => c.json(await listDeletedFiles(c.req.param("id")))); + + app.delete("/api/agents/:id/files/deleted", async (c) => c.json(await purgeDeletedFiles(c.req.param("id")))); + + app.post("/api/agents/:id/files/deleted/:entryId/restore", async (c) => c.json(await restoreDeletedFile(c.req.param("id"), c.req.param("entryId")))); + + app.delete("/api/agents/:id/files/deleted/:entryId", async (c) => c.json(await purgeDeletedFiles(c.req.param("id"), c.req.param("entryId")))); + /* Memory editor (MEMORY.md only; phones reach this, never the other files) */ app.get("/api/agents/:id/memory", async (c) => c.json(await readAgentMemory(c.req.param("id")))); diff --git a/packages/core/src/services/cleanup.ts b/packages/core/src/services/cleanup.ts index 306a4eb2..18060684 100644 --- a/packages/core/src/services/cleanup.ts +++ b/packages/core/src/services/cleanup.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CleanupEntry, CleanupId, CleanupItem, CleanupReport, CleanupResult, CleanupRun, HealthCheck, StorageArea, StorageUsage } from "@godmode/shared"; import { formatBytes } from "@godmode/shared"; +import { DELETED_DAYS, expiredDeletedFiles } from "../agents/files"; import { withRepoLock } from "../agents/repo"; import { namedBy } from "../memory/workspace"; import { backupInProgress } from "../backup/backup"; @@ -39,6 +40,7 @@ const NAMES = { "agent-scratch": "Agents' scratch files", "agent-run-logs": "Old run logs", "agent-history": "Agent file history", + "deleted-files": "Deleted files", database: "Database", "old-logs": "Old logs", "vm-downloads": "Unfinished VM downloads", @@ -57,6 +59,7 @@ export const RECOMMENDED: CleanupId[] = [ "agent-scratch", "agent-run-logs", "agent-history", + "deleted-files", "database", "old-logs", ]; @@ -758,6 +761,21 @@ async function vmDownloads(scan: Scan): Promise { }; } +async function deletedFiles(scan: Scan): Promise { + const agents = new Map(all<{ id: string; name: string }>("SELECT id, name FROM agents").map((a) => [a.id, a.name])); + const targets: Target[] = []; + for (const entry of expiredDeletedFiles(scan.at)) { + const t = await target(scan, entry.dir, `${agents.get(entry.agentId) ?? "A removed agent"} · ${entry.path}`); + t.entry.modifiedAt = entry.deletedAt; + targets.push(t); + } + return { + id: "deleted-files", + detail: `What you deleted on the Files page more than ${DELETED_DAYS} days ago. Until then it waits in the agent's bin, to be put back.`, + targets, + }; +} + const TRASH_STAMP = /-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})(?:-(\d{3}))?Z(?:-\d+)?$/; function trashedAt(name: string): number { @@ -809,6 +827,7 @@ const SCANNERS: Record Promise | Cat "agent-scratch": agentScratch, "agent-run-logs": agentRunLogs, "agent-history": agentHistory, + "deleted-files": deletedFiles, database, "old-logs": oldLogs, "vm-downloads": vmDownloads, From 47ddbeb2602f2a4815ec9b32da752b16f22537b6 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 03:49:22 +0200 Subject: [PATCH 3/9] Files page: tests for sizes, bin, moves, review, notes and search --- packages/core/test/agent-files.test.ts | 230 +++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 packages/core/test/agent-files.test.ts diff --git a/packages/core/test/agent-files.test.ts b/packages/core/test/agent-files.test.ts new file mode 100644 index 00000000..d4ef74ba --- /dev/null +++ b/packages/core/test/agent-files.test.ts @@ -0,0 +1,230 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Agent, AgentFileInfo, AgentFileMoveResult, AgentFileSearchHit, AgentFileSize, AgentFilesDeleteResult, AgentNote, DeletedAgentFile, FileReview, GitCommit } from "@godmode/shared"; +import { loadConfig } from "../src/config"; +import { closeDb, openDb, run } from "../src/db"; +import { setLogLevel } from "../src/log"; +import { resetSettingsCache } from "../src/services/settings"; +import { createApp } from "../src/server/app"; +import { getAccessToken } from "../src/server/auth"; +import { expiredDeletedFiles } from "../src/agents/files"; +import { runCleanup, scanCleanup } from "../src/services/cleanup"; + +let dataDir: string; +let app: ReturnType; +let token: string; +let agent: Agent; + +async function call(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + const headers: Record = { authorization: `Bearer ${token}` }; + if (body !== undefined) headers["content-type"] = "application/json"; + const res = await app.request(`http://127.0.0.1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); + return { status: res.status, data: (await res.json()) as T }; +} + +const at = (rel: string) => join(agent.repoPath, rel); + +function put(rel: string, content: string | Buffer, daysAgo = 0) { + mkdirSync(join(at(rel), ".."), { recursive: true }); + writeFileSync(at(rel), content); + if (daysAgo) { + const t = new Date(Date.now() - daysAgo * 86_400_000); + utimesSync(at(rel), t, t); + } +} + +beforeAll(async () => { + setLogLevel("error"); + dataDir = mkdtempSync(join(tmpdir(), "godmode-agent-files-")); + loadConfig({ dataDir }); + openDb(join(dataDir, "test.db")); + resetSettingsCache(); + app = createApp(); + token = getAccessToken(); + agent = (await call("POST", "/api/agents", { name: "Tidy Bot" })).data; + await call("GET", `/api/agents/${agent.id}/files?path=`); +}); + +afterAll(() => { + closeDb(); + resetSettingsCache(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe("files page", () => { + test("folder sizes count every file below, without following links", async () => { + put("workspace/reports/a.md", "x".repeat(1000)); + put("workspace/reports/deep/b.txt", "y".repeat(500)); + symlinkSync("/etc", at("workspace/reports/outside")); + const sizes = (await call("GET", `/api/agents/${agent.id}/files/sizes?path=workspace`)).data; + const reports = sizes.find((s) => s.path === "workspace/reports")!; + expect(reports.files).toBe(3); + expect(reports.bytes).toBeGreaterThanOrEqual(1500); + expect(reports.bytes).toBeLessThan(1600); + expect((await call("GET", `/api/agents/${agent.id}/files/sizes?path=..`)).status).toBe(403); + rmSync(at("workspace/reports/outside")); + }); + + test("delete goes to the bin and comes back; Godmode's own files stay", async () => { + put("workspace/old/report.md", "# Old report\n"); + put("memory/old-note.md", "# Old\n"); + const res = await call("POST", `/api/agents/${agent.id}/files/delete`, { + paths: ["workspace/old", "workspace/old/report.md", "memory/old-note.md", "MEMORY.md", "CLAUDE.md", "runs", "workspace/tmp", "workspace/missing.md"], + }); + expect(res.status).toBe(200); + expect(res.data.deleted.map((d) => d.path).sort()).toEqual(["memory/old-note.md", "workspace/old"]); + const refused = Object.fromEntries(res.data.refused.map((r) => [r.path, r.reason])); + expect(Object.keys(refused).sort()).toEqual(["CLAUDE.md", "MEMORY.md", "runs", "workspace/missing.md", "workspace/old/report.md", "workspace/tmp"]); + expect(existsSync(at("workspace/old"))).toBe(false); + expect(existsSync(at("MEMORY.md"))).toBe(true); + + const commits = (await call("GET", `/api/agents/${agent.id}/commits`)).data; + expect(commits[0]!.message).toMatch(/^Delete (memory\/old-note\.md|workspace\/old) and 1 more$/); + + const bin = (await call("GET", `/api/agents/${agent.id}/files/deleted`)).data; + expect(bin).toHaveLength(2); + const folder = bin.find((b) => b.path === "workspace/old")!; + expect(folder.type).toBe("dir"); + expect(folder.files).toBe(1); + + // Something new took its place meanwhile: it comes back next to it. + put("workspace/old", "new file"); + const restored = await call<{ path: string }>("POST", `/api/agents/${agent.id}/files/deleted/${folder.id}/restore`); + expect(restored.data.path).toBe("workspace/old (restored)"); + expect(readFileSync(at("workspace/old (restored)/report.md"), "utf8")).toBe("# Old report\n"); + + const note = bin.find((b) => b.path === "memory/old-note.md")!; + expect((await call("DELETE", `/api/agents/${agent.id}/files/deleted/${note.id}`)).status).toBe(200); + expect((await call("GET", `/api/agents/${agent.id}/files/deleted`)).data).toHaveLength(0); + expect((await call("POST", `/api/agents/${agent.id}/files/deleted/${note.id}/restore`)).status).toBe(404); + expect((await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: [".git/config"] })).status).toBe(403); + }); + + test("a move points the memory at the new place, never over another entry", async () => { + put("workspace/drafts/plan.md", "# Plan\n"); + put("memory/topic.md", "# Topic\nSee workspace/drafts/plan.md.\n"); + writeFileSync(at("MEMORY.md"), "# Memory\n- [Topic](memory/topic.md)\n- Plan: workspace/drafts/plan.md\n- Other agent: /x/other/workspace/drafts/plan.md\n"); + const moved = await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/drafts/plan.md", to: "workspace/plans/q4.md" }); + expect(moved.status).toBe(200); + expect(moved.data.rewritten.sort()).toEqual(["MEMORY.md", "memory/topic.md"]); + const memory = readFileSync(at("MEMORY.md"), "utf8"); + expect(memory).toContain("- Plan: workspace/plans/q4.md"); + expect(memory).toContain("/x/other/workspace/drafts/plan.md"); + + const renamed = await call("POST", `/api/agents/${agent.id}/files/move`, { from: "memory/topic.md", to: "memory/topics/main.md" }); + expect(renamed.data.rewritten).toEqual(["MEMORY.md"]); + expect(readFileSync(at("MEMORY.md"), "utf8")).toContain("(memory/topics/main.md)"); + + put("workspace/plans/other.md", "x"); + expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans/other.md", to: "workspace/plans/q4.md" })).status).toBe(409); + expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans", to: "workspace/plans/inner" })).status).toBe(400); + expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/plans/other.md", to: "state/other.md" })).status).toBe(400); + expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "MEMORY.md", to: "workspace/m.md" })).status).toBe(403); + expect((await call("GET", `/api/agents/${agent.id}/commits`)).data[0]!.message).toBe("Move memory/topic.md to memory/topics/main.md"); + }); + + test("inspect says what names a path and whether it may go", async () => { + const info = (await call("POST", `/api/agents/${agent.id}/files/inspect`, { paths: ["workspace/plans/q4.md", "memory/topics/main.md", "CLAUDE.md"] })).data; + const by = Object.fromEntries(info.map((i) => [i.path, i])); + expect(by["workspace/plans/q4.md"]!.namedBy).toContain("MEMORY.md"); + expect(by["memory/topics/main.md"]!.namedBy).toEqual(["MEMORY.md"]); + expect(by["CLAUDE.md"]!.locked).toContain("settings"); + }); + + test("new folders only inside workspace/ or memory/", async () => { + expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "workspace/clients/acme" })).status).toBe(200); + expect(existsSync(at("workspace/clients/acme"))).toBe(true); + expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "workspace/clients/acme" })).status).toBe(409); + expect((await call("POST", `/api/agents/${agent.id}/files/folder`, { path: "runs/x" })).status).toBe(400); + }); + + test("the review finds clutter and marks what the agent still names", async () => { + const big = Buffer.alloc(4096, 7); + put("workspace/app/package.json", "{}"); + put("workspace/app/node_modules/left-pad/index.js", "module.exports = 1;"); + put("workspace/app/dist/bundle.js", "x"); + put("workspace/shots/one.png", big, 5); + put("workspace/shots/copy-of-one.png", big, 1); + put("workspace/.DS_Store", "x"); + put("workspace/empty.txt", ""); + mkdirSync(at("workspace/nothing-here"), { recursive: true }); + put("workspace/archive/2026-08/notes.md", "# August\n", 60); + put("workspace/loose-report.md", "# Loose\n", 10); + put("firebase-debug.log", "debug"); + put("memory/forgotten.md", "# Nobody links here\n"); + writeFileSync(at("MEMORY.md"), `${readFileSync(at("MEMORY.md"), "utf8")}- [Gone](memory/gone.md)\n- Shots: workspace/shots/one.png\n`); + + const review = (await call("GET", `/api/agents/${agent.id}/files/review?fresh=1`)).data; + const kind = (path: string) => review.items.find((i) => i.path === path)?.kind; + expect(kind("workspace/app/node_modules")).toBe("build"); + expect(kind("workspace/app/dist")).toBe("build"); + expect(kind("workspace/app/node_modules/left-pad/index.js")).toBeUndefined(); + expect(kind("workspace/.DS_Store")).toBe("junk"); + expect(kind("firebase-debug.log")).toBe("junk"); + expect(kind("workspace/empty.txt")).toBe("empty"); + expect(kind("workspace/nothing-here")).toBe("empty"); + const copy = review.items.find((i) => i.path === "workspace/shots/copy-of-one.png")!; + expect(copy.kind).toBe("duplicate"); + expect(copy.related).toBe("workspace/shots/one.png"); + expect(kind("workspace/archive")).toBe("stale"); + expect(kind("workspace/loose-report.md")).toBe("loose"); + expect(kind("memory/forgotten.md")).toBe("unlinked-note"); + expect(kind("memory/topics/main.md")).toBeUndefined(); + const broken = review.items.find((i) => i.kind === "broken-link")!; + expect(broken.path).toBe("memory/gone.md"); + expect(broken.related).toBe("MEMORY.md"); + expect(review.items.some((i) => i.path === "workspace/uploads" || i.path === "workspace/tmp")).toBe(false); + }); + + test("notes list and search", async () => { + const notes = (await call("GET", `/api/agents/${agent.id}/files/notes`)).data; + const by = Object.fromEntries(notes.map((n) => [n.path, n])); + expect(by["MEMORY.md"]!.linked).toBe(true); + expect(by["memory/topics/main.md"]!.linked).toBe(true); + expect(by["memory/forgotten.md"]!.linked).toBe(false); + expect(by["workspace/plans/q4.md"]!.title).toBe("Plan"); + expect(by["workspace/plans/q4.md"]!.namedBy).toBe("MEMORY.md"); + + const hits = (await call("GET", `/api/agents/${agent.id}/files/search?q=nobody%20links`)).data; + expect(hits).toEqual([{ path: "memory/forgotten.md", type: "file", line: 1, snippet: "# Nobody links here" }]); + const byName = (await call("GET", `/api/agents/${agent.id}/files/search?q=q4`)).data; + expect(byName.map((h) => h.path)).toContain("workspace/plans/q4.md"); + expect((await call("GET", `/api/agents/${agent.id}/files/search?q=left-pad`)).data).toEqual([]); + }); + + test("nothing moves while the agent dreams", async () => { + run("INSERT INTO runs (id, agent_id, conversation_id, trigger, status, prompt, created_at) VALUES ('run_dream', ?, 'cnv_dream', 'dream', 'running', '', datetime('now'))", agent.id); + run("INSERT INTO dreams (id, agent_id, run_id, status, reason, created_at) VALUES ('drm_x', ?, 'run_dream', 'running', 'manual', datetime('now'))", agent.id); + try { + expect((await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: ["workspace/loose-report.md"] })).status).toBe(409); + expect((await call("POST", `/api/agents/${agent.id}/files/move`, { from: "workspace/loose-report.md", to: "workspace/r.md" })).status).toBe(409); + } finally { + run("DELETE FROM dreams WHERE id = 'drm_x'"); + run("DELETE FROM runs WHERE id = 'run_dream'"); + } + }); + + test("the cleanup empties what waited in the bin for 30 days", async () => { + put("workspace/expired.md", "bye"); + const del = (await call("POST", `/api/agents/${agent.id}/files/delete`, { paths: ["workspace/expired.md"] })).data; + const entry = del.deleted[0]!; + const binEntry = join(dataDir, "agents", ".deleted-files", agent.id, entry.id); + writeFileSync(join(binEntry, "entry.json"), JSON.stringify({ ...entry, deletedAt: new Date(Date.now() - 31 * 86_400_000).toISOString() })); + expect(expiredDeletedFiles().map((e) => e.path)).toEqual(["workspace/expired.md"]); + const item = (await scanCleanup()).items.find((i) => i.id === "deleted-files")!; + expect(item.count).toBe(1); + expect(item.recommended).toBe(true); + await runCleanup(["deleted-files"]); + expect(existsSync(binEntry)).toBe(false); + expect(readdirSync(join(dataDir, "agents", ".deleted-files", agent.id))).toEqual([]); + }); + + test("overview row per agent", async () => { + const rows = (await call<{ agentId: string; notes: number; bytes: number }[]>("GET", "/api/files/overview")).data; + const row = rows.find((r) => r.agentId === agent.id)!; + expect(row.notes).toBeGreaterThanOrEqual(4); + expect(row.bytes).toBeGreaterThan(0); + }); +}); From a4d659895bc4f15b81a858a16c8fcc7bc879b9b0 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 20 Oct 2026 03:52:39 +0200 Subject: [PATCH 4/9] Files page: shared file editor with split view, API client --- .../components/agents/detail/memory-tab.tsx | 316 +--------------- .../src/components/files/file-editor.tsx | 358 ++++++++++++++++++ .../src/components/settings/cleanup-items.tsx | 2 + apps/desktop/src/lib/api.ts | 28 ++ apps/desktop/src/lib/queryKeys.ts | 7 + 5 files changed, 406 insertions(+), 305 deletions(-) create mode 100644 apps/desktop/src/components/files/file-editor.tsx diff --git a/apps/desktop/src/components/agents/detail/memory-tab.tsx b/apps/desktop/src/components/agents/detail/memory-tab.tsx index 256e136a..8f3f52ad 100644 --- a/apps/desktop/src/components/agents/detail/memory-tab.tsx +++ b/apps/desktop/src/components/agents/detail/memory-tab.tsx @@ -1,34 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { formatDistanceToNowStrict } from "date-fns"; -import { - Brain, - ChevronRight, - Eye, - FileCode2, - FileJson, - FileText, - Folder, - FolderOpen, - Lock, - Moon, - PencilLine, - RotateCcw, - Save, -} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, Folder, FolderOpen, FolderSearch } from "lucide-react"; import type { Agent, AgentFileEntry } from "@godmode/shared"; -import { api, ApiRequestError, errorMessage } from "@/lib/api"; +import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { isMac, modKey } from "@/lib/desktop"; import { cn } from "@/lib/utils"; -import { Kbd } from "@/components/common"; -import { Markdown } from "@/components/chat/markdown"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { Spinner } from "@/components/ui/spinner"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { AlertDialog, AlertDialogAction, @@ -39,39 +18,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { FileEditor, fileIcon, nameOf, parentOf } from "@/components/files/file-editor"; import { DreamsPanel, useDreamingNow } from "./dreams-panel"; -import { BrokenLinks, brokenMemoryLinks, MemoryBudget, memoryLinkTarget, memorySections, SectionMenu, type MemorySection } from "./memory-editor-parts"; const PINNED = ["MEMORY.md", "CLAUDE.md"]; const HIDDEN = new Set([".git"]); -const MAX_EDITABLE = 512 * 1024; - -/** Why a file can't be edited here (null = editable). */ -function readOnlyReason(path: string, size: number): string | null { - if (path === "CLAUDE.md") return "Generated from the agent's settings — edit the instructions in Settings instead."; - if (path.startsWith("state/")) return "Snapshot written by Godmode — changes would be overwritten."; - if (path.startsWith("runs/")) return "Raw run log — read-only."; - if (path.startsWith("conversations/")) return "Conversation transcript — read-only."; - if (size > MAX_EDITABLE) return "This file is too large to edit here."; - return null; -} - -/** MEMORY.md and memory/** — a running dream owns these (the core refuses edits meanwhile). */ -function isMemoryPath(path: string) { - return path === "MEMORY.md" || path.startsWith("memory/"); -} - -/** The file changed on the server since the draft started — saving would overwrite someone else's edit. */ -class StaleDraftError extends Error {} - -function parentOf(path: string) { - const i = path.lastIndexOf("/"); - return i === -1 ? "" : path.slice(0, i); -} - -function nameOf(path: string) { - return path.slice(path.lastIndexOf("/") + 1); -} /** Normalise listing entries to repo-relative paths directly inside `dir`. */ function childrenOf(entries: AgentFileEntry[], dir: string): AgentFileEntry[] { @@ -96,20 +47,6 @@ function childrenOf(entries: AgentFileEntry[], dir: string): AgentFileEntry[] { }); } -function fileIcon(path: string) { - if (path === "MEMORY.md") return ; - if (path === "CLAUDE.md") return ; - if (/\.(json|jsonl)$/.test(path)) return ; - if (/\.(ts|tsx|js|py|sh|yaml|yml|toml)$/.test(path)) return ; - return ; -} - -function formatSize(n: number) { - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(1)} MB`; -} - export function MemoryTab({ agent }: { agent: Agent }) { const [selected, setSelected] = useState<{ path: string; size: number } | null>({ path: "MEMORY.md", size: 0 }); const [expanded, setExpanded] = useState>(() => new Set(["memory"])); @@ -140,11 +77,16 @@ export function MemoryTab({ agent }: { agent: Agent }) {