Skip to content

Commit 18176c5

Browse files
fix(memfs): collapse large dirs in reflection snapshot tree (letta-ai#1481)
Co-authored-by: Letta Code <noreply@letta.com>
1 parent 5de95e9 commit 18176c5

2 files changed

Lines changed: 130 additions & 10 deletions

File tree

src/cli/helpers/reflectionTranscript.ts

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { homedir, tmpdir } from "node:os";
1010
import { join } from "node:path";
1111
import { MEMORY_SYSTEM_DIR } from "../../agent/memoryFilesystem";
12+
import { getDirectoryLimits } from "../../utils/directoryLimits";
1213
import { parseFrontmatter } from "../../utils/frontmatter";
1314
import { type Line, linesToTranscript } from "./accumulator";
1415

@@ -192,24 +193,96 @@ function buildParentMemoryTree(files: ParentMemoryFile[]): string {
192193
},
193194
);
194195

195-
const lines: string[] = ["/memory/"];
196+
const limits = getDirectoryLimits();
197+
const maxLines = Math.max(2, limits.memfsTreeMaxLines);
198+
const maxChars = Math.max(128, limits.memfsTreeMaxChars);
199+
const maxChildrenPerDir = Math.max(1, limits.memfsTreeMaxChildrenPerDir);
196200

197-
const render = (node: TreeNode, prefix: string) => {
201+
const rootLine = "/memory/";
202+
const lines: string[] = [rootLine];
203+
let totalChars = rootLine.length;
204+
205+
const countTreeEntries = (node: TreeNode): number => {
206+
let total = 0;
207+
for (const [, child] of node.children) {
208+
total += 1;
209+
if (child.children.size > 0) {
210+
total += countTreeEntries(child);
211+
}
212+
}
213+
return total;
214+
};
215+
216+
const canAppendLine = (line: string): boolean => {
217+
const nextLineCount = lines.length + 1;
218+
const nextCharCount = totalChars + 1 + line.length;
219+
return nextLineCount <= maxLines && nextCharCount <= maxChars;
220+
};
221+
222+
const render = (node: TreeNode, prefix: string): boolean => {
198223
const entries = sortedEntries(node);
199-
for (const [index, [name, child]] of entries.entries()) {
200-
const isLast = index === entries.length - 1;
224+
const visibleEntries = entries.slice(0, maxChildrenPerDir);
225+
const omittedEntries = Math.max(0, entries.length - visibleEntries.length);
226+
227+
const renderItems: Array<
228+
| { kind: "entry"; name: string; child: TreeNode }
229+
| { kind: "omitted"; omittedCount: number }
230+
> = visibleEntries.map(([name, child]) => ({
231+
kind: "entry",
232+
name,
233+
child,
234+
}));
235+
236+
if (omittedEntries > 0) {
237+
renderItems.push({ kind: "omitted", omittedCount: omittedEntries });
238+
}
239+
240+
for (const [index, item] of renderItems.entries()) {
241+
const isLast = index === renderItems.length - 1;
201242
const branch = isLast ? "└──" : "├──";
202-
const suffix = child.isFile ? "" : "/";
203-
const description = child.description ? ` (${child.description})` : "";
204-
lines.push(`${prefix}${branch} ${name}${suffix}${description}`);
205-
if (child.children.size > 0) {
243+
const line =
244+
item.kind === "entry"
245+
? `${prefix}${branch} ${item.name}${item.child.isFile ? "" : "/"}${item.child.description ? ` (${item.child.description})` : ""}`
246+
: `${prefix}${branch} … (${item.omittedCount.toLocaleString()} more entries)`;
247+
248+
if (!canAppendLine(line)) {
249+
return false;
250+
}
251+
252+
lines.push(line);
253+
totalChars += 1 + line.length;
254+
255+
if (item.kind === "entry" && item.child.children.size > 0) {
206256
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
207-
render(child, nextPrefix);
257+
if (!render(item.child, nextPrefix)) {
258+
return false;
259+
}
208260
}
209261
}
262+
263+
return true;
210264
};
211265

212-
render(root, "");
266+
const totalEntries = countTreeEntries(root);
267+
const fullyRendered = render(root, "");
268+
269+
if (!fullyRendered) {
270+
while (lines.length > 1) {
271+
const shownEntries = Math.max(0, lines.length - 1);
272+
const omittedEntries = Math.max(1, totalEntries - shownEntries);
273+
const notice = `[Tree truncated: showing ${shownEntries.toLocaleString()} of ${totalEntries.toLocaleString()} entries. ${omittedEntries.toLocaleString()} omitted.]`;
274+
275+
if (canAppendLine(notice)) {
276+
lines.push(notice);
277+
break;
278+
}
279+
280+
const removed = lines.pop();
281+
if (removed) {
282+
totalChars -= 1 + removed.length;
283+
}
284+
}
285+
}
213286

214287
return lines.join("\n");
215288
}

src/tests/cli/reflection-transcript.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ import {
1111
finalizeAutoReflectionPayload,
1212
getReflectionTranscriptPaths,
1313
} from "../../cli/helpers/reflectionTranscript";
14+
import { DIRECTORY_LIMIT_ENV } from "../../utils/directoryLimits";
15+
16+
const DIRECTORY_LIMIT_ENV_KEYS = Object.values(DIRECTORY_LIMIT_ENV);
17+
const ORIGINAL_DIRECTORY_ENV = Object.fromEntries(
18+
DIRECTORY_LIMIT_ENV_KEYS.map((key) => [key, process.env[key]]),
19+
) as Record<string, string | undefined>;
20+
21+
function restoreDirectoryLimitEnv(): void {
22+
for (const key of DIRECTORY_LIMIT_ENV_KEYS) {
23+
const original = ORIGINAL_DIRECTORY_ENV[key];
24+
if (original === undefined) {
25+
delete process.env[key];
26+
} else {
27+
process.env[key] = original;
28+
}
29+
}
30+
}
1431

1532
describe("reflectionTranscript helper", () => {
1633
const agentId = "agent-test";
@@ -23,6 +40,7 @@ describe("reflectionTranscript helper", () => {
2340
});
2441

2542
afterEach(async () => {
43+
restoreDirectoryLimitEnv();
2644
delete process.env.LETTA_TRANSCRIPT_ROOT;
2745
await rm(testRoot, { recursive: true, force: true });
2846
});
@@ -182,6 +200,35 @@ describe("reflectionTranscript helper", () => {
182200
expect(snapshot).toContain("</parent_memory>");
183201
});
184202

203+
test("buildParentMemorySnapshot collapses large users directory with omission marker", async () => {
204+
process.env[DIRECTORY_LIMIT_ENV.memfsTreeMaxChildrenPerDir] = "3";
205+
206+
const memoryDir = join(testRoot, "memory-large-users");
207+
await mkdir(join(memoryDir, "system"), { recursive: true });
208+
await mkdir(join(memoryDir, "users"), { recursive: true });
209+
210+
await writeFile(
211+
join(memoryDir, "system", "human.md"),
212+
"---\ndescription: User context\n---\nSystem content\n",
213+
"utf-8",
214+
);
215+
216+
for (let idx = 0; idx < 10; idx += 1) {
217+
const suffix = String(idx).padStart(2, "0");
218+
await writeFile(
219+
join(memoryDir, "users", `user_${suffix}.md`),
220+
`---\ndescription: User block ${suffix}\n---\ncontent ${suffix}\n`,
221+
"utf-8",
222+
);
223+
}
224+
225+
const snapshot = await buildParentMemorySnapshot(memoryDir);
226+
227+
expect(snapshot).toContain("users/");
228+
expect(snapshot).toContain("… (7 more entries)");
229+
expect(snapshot).not.toContain("user_09.md");
230+
});
231+
185232
test("buildReflectionSubagentPrompt uses expanded reflection instructions", () => {
186233
const prompt = buildReflectionSubagentPrompt({
187234
transcriptPath: "/tmp/transcript.txt",

0 commit comments

Comments
 (0)