@letta-ai/letta-code 0.13.11 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Memory Filesystem Diff
4
+ *
5
+ * Shows the full content of conflicting blocks and files.
6
+ * Writes a formatted markdown diff to a file for review.
7
+ * Analogous to `git diff`.
8
+ *
9
+ * Usage:
10
+ * npx tsx memfs-diff.ts <agent-id>
11
+ *
12
+ * Output: Path to the diff file (or "No conflicts" message)
13
+ */
14
+
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { readdir, readFile } from "node:fs/promises";
18
+ import { createRequire } from "node:module";
19
+ import { homedir } from "node:os";
20
+ import { join, normalize, relative } from "node:path";
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const Letta = require("@letta-ai/letta-client")
24
+ .default as typeof import("@letta-ai/letta-client").default;
25
+
26
+ function getApiKey(): string {
27
+ if (process.env.LETTA_API_KEY) {
28
+ return process.env.LETTA_API_KEY;
29
+ }
30
+
31
+ const settingsPath = join(homedir(), ".letta", "settings.json");
32
+ try {
33
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
34
+ if (settings.env?.LETTA_API_KEY) {
35
+ return settings.env.LETTA_API_KEY;
36
+ }
37
+ } catch {
38
+ // Settings file doesn't exist or is invalid
39
+ }
40
+
41
+ throw new Error(
42
+ "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
43
+ );
44
+ }
45
+
46
+ const MEMORY_FS_STATE_FILE = ".sync-state.json";
47
+
48
+ type SyncState = {
49
+ systemBlocks: Record<string, string>;
50
+ systemFiles: Record<string, string>;
51
+ detachedBlocks: Record<string, string>;
52
+ detachedFiles: Record<string, string>;
53
+ detachedBlockIds: Record<string, string>;
54
+ lastSync: string | null;
55
+ };
56
+
57
+ function hashContent(content: string): string {
58
+ return createHash("sha256").update(content).digest("hex");
59
+ }
60
+
61
+ function getMemoryRoot(agentId: string): string {
62
+ return join(homedir(), ".letta", "agents", agentId, "memory");
63
+ }
64
+
65
+ function loadSyncState(agentId: string): SyncState {
66
+ const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
67
+ if (!existsSync(statePath)) {
68
+ return {
69
+ systemBlocks: {},
70
+ systemFiles: {},
71
+ detachedBlocks: {},
72
+ detachedFiles: {},
73
+ detachedBlockIds: {},
74
+ lastSync: null,
75
+ };
76
+ }
77
+
78
+ try {
79
+ const raw = readFileSync(statePath, "utf-8");
80
+ const parsed = JSON.parse(raw) as Partial<SyncState> & {
81
+ blocks?: Record<string, string>;
82
+ files?: Record<string, string>;
83
+ };
84
+ return {
85
+ systemBlocks: parsed.systemBlocks || parsed.blocks || {},
86
+ systemFiles: parsed.systemFiles || parsed.files || {},
87
+ detachedBlocks: parsed.detachedBlocks || {},
88
+ detachedFiles: parsed.detachedFiles || {},
89
+ detachedBlockIds: parsed.detachedBlockIds || {},
90
+ lastSync: parsed.lastSync || null,
91
+ };
92
+ } catch {
93
+ return {
94
+ systemBlocks: {},
95
+ systemFiles: {},
96
+ detachedBlocks: {},
97
+ detachedFiles: {},
98
+ detachedBlockIds: {},
99
+ lastSync: null,
100
+ };
101
+ }
102
+ }
103
+
104
+ async function scanMdFiles(dir: string, baseDir = dir): Promise<string[]> {
105
+ if (!existsSync(dir)) return [];
106
+ const entries = await readdir(dir, { withFileTypes: true });
107
+ const results: string[] = [];
108
+ for (const entry of entries) {
109
+ const fullPath = join(dir, entry.name);
110
+ if (entry.isDirectory()) {
111
+ results.push(...(await scanMdFiles(fullPath, baseDir)));
112
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
113
+ results.push(relative(baseDir, fullPath));
114
+ }
115
+ }
116
+ return results;
117
+ }
118
+
119
+ function labelFromPath(relativePath: string): string {
120
+ return relativePath.replace(/\\/g, "/").replace(/\.md$/, "");
121
+ }
122
+
123
+ async function readMemoryFiles(
124
+ dir: string,
125
+ ): Promise<Map<string, { content: string }>> {
126
+ const files = await scanMdFiles(dir);
127
+ const entries = new Map<string, { content: string }>();
128
+ for (const rel of files) {
129
+ const label = labelFromPath(rel);
130
+ const content = await readFile(join(dir, rel), "utf-8");
131
+ entries.set(label, { content });
132
+ }
133
+ return entries;
134
+ }
135
+
136
+ const MANAGED_LABELS = new Set([
137
+ "memory_filesystem",
138
+ "skills",
139
+ "loaded_skills",
140
+ ]);
141
+
142
+ interface Conflict {
143
+ label: string;
144
+ fileContent: string;
145
+ blockContent: string;
146
+ }
147
+
148
+ /**
149
+ * Get the overflow directory following the same pattern as tool output overflow.
150
+ * Pattern: ~/.letta/projects/<project-path>/agent-tools/
151
+ */
152
+ function getOverflowDirectory(): string {
153
+ const cwd = process.cwd();
154
+ const normalizedPath = normalize(cwd);
155
+ const sanitizedPath = normalizedPath
156
+ .replace(/^[/\\]/, "")
157
+ .replace(/[/\\:]/g, "_")
158
+ .replace(/\s+/g, "_");
159
+
160
+ return join(homedir(), ".letta", "projects", sanitizedPath, "agent-tools");
161
+ }
162
+
163
+ async function findConflicts(agentId: string): Promise<Conflict[]> {
164
+ const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
165
+ const client = new Letta({ apiKey: getApiKey(), baseUrl });
166
+
167
+ const root = getMemoryRoot(agentId);
168
+ const systemDir = join(root, "system");
169
+ // Detached files go at root level (flat structure)
170
+ const detachedDir = root;
171
+
172
+ for (const dir of [root, systemDir]) {
173
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
174
+ }
175
+
176
+ const systemFiles = await readMemoryFiles(systemDir);
177
+ const detachedFiles = await readMemoryFiles(detachedDir);
178
+ systemFiles.delete("memory_filesystem");
179
+
180
+ const blocksResponse = await client.agents.blocks.list(agentId, {
181
+ limit: 1000,
182
+ });
183
+ const blocks = Array.isArray(blocksResponse)
184
+ ? blocksResponse
185
+ : ((blocksResponse as { items?: unknown[] }).items as Array<{
186
+ label?: string;
187
+ value?: string;
188
+ }>) || [];
189
+
190
+ const systemBlockMap = new Map(
191
+ blocks
192
+ .filter((b: { label?: string }) => b.label)
193
+ .map((b: { label?: string; value?: string }) => [
194
+ b.label as string,
195
+ b.value || "",
196
+ ]),
197
+ );
198
+ systemBlockMap.delete("memory_filesystem");
199
+
200
+ const lastState = loadSyncState(agentId);
201
+
202
+ const detachedBlockMap = new Map<string, string>();
203
+ for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) {
204
+ try {
205
+ const block = await client.blocks.retrieve(blockId);
206
+ detachedBlockMap.set(label, block.value || "");
207
+ } catch {
208
+ // Block no longer exists
209
+ }
210
+ }
211
+
212
+ const conflicts: Conflict[] = [];
213
+
214
+ function checkConflict(
215
+ label: string,
216
+ fileContent: string | null,
217
+ blockValue: string | null,
218
+ lastFileHash: string | null,
219
+ lastBlockHash: string | null,
220
+ ) {
221
+ if (fileContent === null || blockValue === null) return;
222
+ const fileHash = hashContent(fileContent);
223
+ const blockHash = hashContent(blockValue);
224
+ if (fileHash === blockHash) return;
225
+ const fileChanged = fileHash !== lastFileHash;
226
+ const blockChanged = blockHash !== lastBlockHash;
227
+ if (fileChanged && blockChanged) {
228
+ conflicts.push({ label, fileContent, blockContent: blockValue });
229
+ }
230
+ }
231
+
232
+ // Check system labels
233
+ const systemLabels = new Set([
234
+ ...systemFiles.keys(),
235
+ ...systemBlockMap.keys(),
236
+ ...Object.keys(lastState.systemBlocks),
237
+ ...Object.keys(lastState.systemFiles),
238
+ ]);
239
+
240
+ for (const label of [...systemLabels].sort()) {
241
+ if (MANAGED_LABELS.has(label)) continue;
242
+ checkConflict(
243
+ label,
244
+ systemFiles.get(label)?.content ?? null,
245
+ systemBlockMap.get(label) ?? null,
246
+ lastState.systemFiles[label] ?? null,
247
+ lastState.systemBlocks[label] ?? null,
248
+ );
249
+ }
250
+
251
+ // Check user labels
252
+ const userLabels = new Set([
253
+ ...detachedFiles.keys(),
254
+ ...detachedBlockMap.keys(),
255
+ ...Object.keys(lastState.detachedBlocks),
256
+ ...Object.keys(lastState.detachedFiles),
257
+ ]);
258
+
259
+ for (const label of [...userLabels].sort()) {
260
+ checkConflict(
261
+ label,
262
+ detachedFiles.get(label)?.content ?? null,
263
+ detachedBlockMap.get(label) ?? null,
264
+ lastState.detachedFiles[label] ?? null,
265
+ lastState.detachedBlocks[label] ?? null,
266
+ );
267
+ }
268
+
269
+ return conflicts;
270
+ }
271
+
272
+ function formatDiffFile(conflicts: Conflict[], agentId: string): string {
273
+ const lines: string[] = [
274
+ `# Memory Filesystem Diff`,
275
+ ``,
276
+ `Agent: ${agentId}`,
277
+ `Generated: ${new Date().toISOString()}`,
278
+ `Conflicts: ${conflicts.length}`,
279
+ ``,
280
+ `---`,
281
+ ``,
282
+ ];
283
+
284
+ for (const conflict of conflicts) {
285
+ lines.push(`## Conflict: ${conflict.label}`);
286
+ lines.push(``);
287
+ lines.push(`### File Version`);
288
+ lines.push(`\`\`\``);
289
+ lines.push(conflict.fileContent);
290
+ lines.push(`\`\`\``);
291
+ lines.push(``);
292
+ lines.push(`### Block Version`);
293
+ lines.push(`\`\`\``);
294
+ lines.push(conflict.blockContent);
295
+ lines.push(`\`\`\``);
296
+ lines.push(``);
297
+ lines.push(`---`);
298
+ lines.push(``);
299
+ }
300
+
301
+ return lines.join("\n");
302
+ }
303
+
304
+ // CLI Entry Point
305
+ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
306
+ if (isMainModule) {
307
+ const args = process.argv.slice(2);
308
+
309
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
310
+ console.log(`
311
+ Usage: npx tsx memfs-diff.ts <agent-id>
312
+
313
+ Shows the full content of conflicting memory blocks and files.
314
+ Writes a formatted diff to a file for review.
315
+ Analogous to 'git diff'.
316
+
317
+ Arguments:
318
+ agent-id Agent ID to check (can use $LETTA_AGENT_ID)
319
+
320
+ Output: Path to the diff file, or a message if no conflicts exist.
321
+ `);
322
+ process.exit(0);
323
+ }
324
+
325
+ const agentId = args[0];
326
+ if (!agentId) {
327
+ console.error("Error: agent-id is required");
328
+ process.exit(1);
329
+ }
330
+
331
+ findConflicts(agentId)
332
+ .then((conflicts) => {
333
+ if (conflicts.length === 0) {
334
+ console.log("No conflicts found. Memory filesystem is clean.");
335
+ return;
336
+ }
337
+
338
+ const diffContent = formatDiffFile(conflicts, agentId);
339
+
340
+ // Write to overflow directory (same pattern as tool output overflow)
341
+ const overflowDir = getOverflowDirectory();
342
+ if (!existsSync(overflowDir)) {
343
+ mkdirSync(overflowDir, { recursive: true });
344
+ }
345
+
346
+ const filename = `memfs-diff-${randomUUID()}.md`;
347
+ const diffPath = join(overflowDir, filename);
348
+ writeFileSync(diffPath, diffContent, "utf-8");
349
+
350
+ console.log(
351
+ `Diff (${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}) written to: ${diffPath}`,
352
+ );
353
+ })
354
+ .catch((error) => {
355
+ console.error(
356
+ "Error generating memFS diff:",
357
+ error instanceof Error ? error.message : String(error),
358
+ );
359
+ process.exit(1);
360
+ });
361
+ }