diff --git a/src/cli/helpers/reflectionTranscript.ts b/src/cli/helpers/reflectionTranscript.ts index 1235947..2c4871d 100644 --- a/src/cli/helpers/reflectionTranscript.ts +++ b/src/cli/helpers/reflectionTranscript.ts @@ -9,6 +9,7 @@ import { import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { MEMORY_SYSTEM_DIR } from "../../agent/memoryFilesystem"; +import { getDirectoryLimits } from "../../utils/directoryLimits"; import { parseFrontmatter } from "../../utils/frontmatter"; import { type Line, linesToTranscript } from "./accumulator"; @@ -192,24 +193,96 @@ function buildParentMemoryTree(files: ParentMemoryFile[]): string { }, ); - const lines: string[] = ["/memory/"]; + const limits = getDirectoryLimits(); + const maxLines = Math.max(2, limits.memfsTreeMaxLines); + const maxChars = Math.max(128, limits.memfsTreeMaxChars); + const maxChildrenPerDir = Math.max(1, limits.memfsTreeMaxChildrenPerDir); - const render = (node: TreeNode, prefix: string) => { - const entries = sortedEntries(node); - for (const [index, [name, child]] of entries.entries()) { - const isLast = index === entries.length - 1; - const branch = isLast ? "└──" : "├──"; - const suffix = child.isFile ? "" : "/"; - const description = child.description ? ` (${child.description})` : ""; - lines.push(`${prefix}${branch} ${name}${suffix}${description}`); + const rootLine = "/memory/"; + const lines: string[] = [rootLine]; + let totalChars = rootLine.length; + + const countTreeEntries = (node: TreeNode): number => { + let total = 0; + for (const [, child] of node.children) { + total += 1; if (child.children.size > 0) { - const nextPrefix = `${prefix}${isLast ? " " : "│ "}`; - render(child, nextPrefix); + total += countTreeEntries(child); } } + return total; }; - render(root, ""); + const canAppendLine = (line: string): boolean => { + const nextLineCount = lines.length + 1; + const nextCharCount = totalChars + 1 + line.length; + return nextLineCount <= maxLines && nextCharCount <= maxChars; + }; + + const render = (node: TreeNode, prefix: string): boolean => { + const entries = sortedEntries(node); + const visibleEntries = entries.slice(0, maxChildrenPerDir); + const omittedEntries = Math.max(0, entries.length - visibleEntries.length); + + const renderItems: Array< + | { kind: "entry"; name: string; child: TreeNode } + | { kind: "omitted"; omittedCount: number } + > = visibleEntries.map(([name, child]) => ({ + kind: "entry", + name, + child, + })); + + if (omittedEntries > 0) { + renderItems.push({ kind: "omitted", omittedCount: omittedEntries }); + } + + for (const [index, item] of renderItems.entries()) { + const isLast = index === renderItems.length - 1; + const branch = isLast ? "└──" : "├──"; + const line = + item.kind === "entry" + ? `${prefix}${branch} ${item.name}${item.child.isFile ? "" : "/"}${item.child.description ? ` (${item.child.description})` : ""}` + : `${prefix}${branch} … (${item.omittedCount.toLocaleString()} more entries)`; + + if (!canAppendLine(line)) { + return false; + } + + lines.push(line); + totalChars += 1 + line.length; + + if (item.kind === "entry" && item.child.children.size > 0) { + const nextPrefix = `${prefix}${isLast ? " " : "│ "}`; + if (!render(item.child, nextPrefix)) { + return false; + } + } + } + + return true; + }; + + const totalEntries = countTreeEntries(root); + const fullyRendered = render(root, ""); + + if (!fullyRendered) { + while (lines.length > 1) { + const shownEntries = Math.max(0, lines.length - 1); + const omittedEntries = Math.max(1, totalEntries - shownEntries); + const notice = `[Tree truncated: showing ${shownEntries.toLocaleString()} of ${totalEntries.toLocaleString()} entries. ${omittedEntries.toLocaleString()} omitted.]`; + + if (canAppendLine(notice)) { + lines.push(notice); + break; + } + + const removed = lines.pop(); + if (removed) { + totalChars -= 1 + removed.length; + } + } + } return lines.join("\n"); } diff --git a/src/tests/cli/reflection-transcript.test.ts b/src/tests/cli/reflection-transcript.test.ts index 1face9d..d27d055 100644 --- a/src/tests/cli/reflection-transcript.test.ts +++ b/src/tests/cli/reflection-transcript.test.ts @@ -11,6 +11,23 @@ import { finalizeAutoReflectionPayload, getReflectionTranscriptPaths, } from "../../cli/helpers/reflectionTranscript"; +import { DIRECTORY_LIMIT_ENV } from "../../utils/directoryLimits"; + +const DIRECTORY_LIMIT_ENV_KEYS = Object.values(DIRECTORY_LIMIT_ENV); +const ORIGINAL_DIRECTORY_ENV = Object.fromEntries( + DIRECTORY_LIMIT_ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record; + +function restoreDirectoryLimitEnv(): void { + for (const key of DIRECTORY_LIMIT_ENV_KEYS) { + const original = ORIGINAL_DIRECTORY_ENV[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } +} describe("reflectionTranscript helper", () => { const agentId = "agent-test"; @@ -23,6 +40,7 @@ describe("reflectionTranscript helper", () => { }); afterEach(async () => { + restoreDirectoryLimitEnv(); delete process.env.LETTA_TRANSCRIPT_ROOT; await rm(testRoot, { recursive: true, force: true }); }); @@ -182,6 +200,35 @@ describe("reflectionTranscript helper", () => { expect(snapshot).toContain(""); }); + test("buildParentMemorySnapshot collapses large users directory with omission marker", async () => { + process.env[DIRECTORY_LIMIT_ENV.memfsTreeMaxChildrenPerDir] = "3"; + + const memoryDir = join(testRoot, "memory-large-users"); + await mkdir(join(memoryDir, "system"), { recursive: true }); + await mkdir(join(memoryDir, "users"), { recursive: true }); + + await writeFile( + join(memoryDir, "system", "human.md"), + "---\ndescription: User context\n---\nSystem content\n", + "utf-8", + ); + + for (let idx = 0; idx < 10; idx += 1) { + const suffix = String(idx).padStart(2, "0"); + await writeFile( + join(memoryDir, "users", `user_${suffix}.md`), + `---\ndescription: User block ${suffix}\n---\ncontent ${suffix}\n`, + "utf-8", + ); + } + + const snapshot = await buildParentMemorySnapshot(memoryDir); + + expect(snapshot).toContain("users/"); + expect(snapshot).toContain("… (7 more entries)"); + expect(snapshot).not.toContain("user_09.md"); + }); + test("buildReflectionSubagentPrompt uses expanded reflection instructions", () => { const prompt = buildReflectionSubagentPrompt({ transcriptPath: "/tmp/transcript.txt",