@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,412 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Memory Filesystem Conflict Resolver
4
+ *
5
+ * Resolves all memFS sync conflicts in a single stateless call.
6
+ * The agent provides all resolutions up front as JSON.
7
+ * Analogous to `git merge` / `git checkout --theirs/--ours`.
8
+ *
9
+ * Usage:
10
+ * npx tsx memfs-resolve.ts <agent-id> --resolutions '<JSON>'
11
+ *
12
+ * Example:
13
+ * npx tsx memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"label":"persona/soul","resolution":"block"},{"label":"human/prefs","resolution":"file"}]'
14
+ *
15
+ * Resolution options per conflict:
16
+ * "file" — Overwrite the memory block with the file contents
17
+ * "block" — Overwrite the file with the memory block contents
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { readdir, readFile } from "node:fs/promises";
23
+ import { createRequire } from "node:module";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join, relative } from "node:path";
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const Letta = require("@letta-ai/letta-client")
29
+ .default as typeof import("@letta-ai/letta-client").default;
30
+
31
+ function getApiKey(): string {
32
+ if (process.env.LETTA_API_KEY) {
33
+ return process.env.LETTA_API_KEY;
34
+ }
35
+
36
+ const settingsPath = join(homedir(), ".letta", "settings.json");
37
+ try {
38
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
39
+ if (settings.env?.LETTA_API_KEY) {
40
+ return settings.env.LETTA_API_KEY;
41
+ }
42
+ } catch {
43
+ // Settings file doesn't exist or is invalid
44
+ }
45
+
46
+ throw new Error(
47
+ "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.",
48
+ );
49
+ }
50
+
51
+ interface Resolution {
52
+ label: string;
53
+ resolution: "file" | "block";
54
+ }
55
+
56
+ const MEMORY_FS_STATE_FILE = ".sync-state.json";
57
+
58
+ type SyncState = {
59
+ systemBlocks: Record<string, string>;
60
+ systemFiles: Record<string, string>;
61
+ detachedBlocks: Record<string, string>;
62
+ detachedFiles: Record<string, string>;
63
+ detachedBlockIds: Record<string, string>;
64
+ lastSync: string | null;
65
+ };
66
+
67
+ function hashContent(content: string): string {
68
+ return createHash("sha256").update(content).digest("hex");
69
+ }
70
+
71
+ function getMemoryRoot(agentId: string): string {
72
+ return join(homedir(), ".letta", "agents", agentId, "memory");
73
+ }
74
+
75
+ function loadSyncState(agentId: string): SyncState {
76
+ const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
77
+ if (!existsSync(statePath)) {
78
+ return {
79
+ systemBlocks: {},
80
+ systemFiles: {},
81
+ detachedBlocks: {},
82
+ detachedFiles: {},
83
+ detachedBlockIds: {},
84
+ lastSync: null,
85
+ };
86
+ }
87
+
88
+ try {
89
+ const raw = readFileSync(statePath, "utf-8");
90
+ const parsed = JSON.parse(raw) as Partial<SyncState> & {
91
+ blocks?: Record<string, string>;
92
+ files?: Record<string, string>;
93
+ };
94
+ return {
95
+ systemBlocks: parsed.systemBlocks || parsed.blocks || {},
96
+ systemFiles: parsed.systemFiles || parsed.files || {},
97
+ detachedBlocks: parsed.detachedBlocks || {},
98
+ detachedFiles: parsed.detachedFiles || {},
99
+ detachedBlockIds: parsed.detachedBlockIds || {},
100
+ lastSync: parsed.lastSync || null,
101
+ };
102
+ } catch {
103
+ return {
104
+ systemBlocks: {},
105
+ systemFiles: {},
106
+ detachedBlocks: {},
107
+ detachedFiles: {},
108
+ detachedBlockIds: {},
109
+ lastSync: null,
110
+ };
111
+ }
112
+ }
113
+
114
+ function saveSyncState(state: SyncState, agentId: string): void {
115
+ const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
116
+ writeFileSync(statePath, JSON.stringify(state, null, 2), "utf-8");
117
+ }
118
+
119
+ async function scanMdFiles(dir: string, baseDir = dir): Promise<string[]> {
120
+ if (!existsSync(dir)) return [];
121
+ const entries = await readdir(dir, { withFileTypes: true });
122
+ const results: string[] = [];
123
+ for (const entry of entries) {
124
+ const fullPath = join(dir, entry.name);
125
+ if (entry.isDirectory()) {
126
+ results.push(...(await scanMdFiles(fullPath, baseDir)));
127
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
128
+ results.push(relative(baseDir, fullPath));
129
+ }
130
+ }
131
+ return results;
132
+ }
133
+
134
+ function labelFromPath(relativePath: string): string {
135
+ return relativePath.replace(/\\/g, "/").replace(/\.md$/, "");
136
+ }
137
+
138
+ async function readMemoryFiles(
139
+ dir: string,
140
+ ): Promise<Map<string, { content: string }>> {
141
+ const files = await scanMdFiles(dir);
142
+ const entries = new Map<string, { content: string }>();
143
+ for (const rel of files) {
144
+ const label = labelFromPath(rel);
145
+ const content = await readFile(join(dir, rel), "utf-8");
146
+ entries.set(label, { content });
147
+ }
148
+ return entries;
149
+ }
150
+
151
+ function writeMemoryFile(dir: string, label: string, content: string): void {
152
+ const filePath = join(dir, `${label}.md`);
153
+ const parent = dirname(filePath);
154
+ if (!existsSync(parent)) {
155
+ mkdirSync(parent, { recursive: true });
156
+ }
157
+ writeFileSync(filePath, content, "utf-8");
158
+ }
159
+
160
+ interface ResolveResult {
161
+ resolved: Array<{ label: string; resolution: string; action: string }>;
162
+ errors: Array<{ label: string; error: string }>;
163
+ }
164
+
165
+ async function resolveConflicts(
166
+ agentId: string,
167
+ resolutions: Resolution[],
168
+ ): Promise<ResolveResult> {
169
+ const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
170
+ const client = new Letta({ apiKey: getApiKey(), baseUrl });
171
+
172
+ const root = getMemoryRoot(agentId);
173
+ const systemDir = join(root, "system");
174
+ // Detached files go at root level (flat structure)
175
+ const detachedDir = root;
176
+
177
+ for (const dir of [root, systemDir]) {
178
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
179
+ }
180
+
181
+ // Read current state
182
+ const systemFiles = await readMemoryFiles(systemDir);
183
+ const detachedFiles = await readMemoryFiles(detachedDir);
184
+ systemFiles.delete("memory_filesystem");
185
+
186
+ const blocksResponse = await client.agents.blocks.list(agentId, {
187
+ limit: 1000,
188
+ });
189
+ const blocks = Array.isArray(blocksResponse)
190
+ ? blocksResponse
191
+ : ((blocksResponse as { items?: unknown[] }).items as Array<{
192
+ id?: string;
193
+ label?: string;
194
+ value?: string;
195
+ }>) || [];
196
+
197
+ const systemBlockMap = new Map(
198
+ blocks
199
+ .filter((b: { label?: string }) => b.label)
200
+ .map((b: { id?: string; label?: string; value?: string }) => [
201
+ b.label as string,
202
+ { id: b.id || "", value: b.value || "" },
203
+ ]),
204
+ );
205
+
206
+ const lastState = loadSyncState(agentId);
207
+ const detachedBlockMap = new Map<string, { id: string; value: string }>();
208
+ for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) {
209
+ try {
210
+ const block = await client.blocks.retrieve(blockId);
211
+ detachedBlockMap.set(label, {
212
+ id: block.id || "",
213
+ value: block.value || "",
214
+ });
215
+ } catch {
216
+ // Block no longer exists
217
+ }
218
+ }
219
+
220
+ const result: ResolveResult = { resolved: [], errors: [] };
221
+
222
+ for (const { label, resolution } of resolutions) {
223
+ try {
224
+ // Check system blocks/files first, then user blocks/files
225
+ const systemBlock = systemBlockMap.get(label);
226
+ const systemFile = systemFiles.get(label);
227
+ const detachedBlock = detachedBlockMap.get(label);
228
+ const detachedFile = detachedFiles.get(label);
229
+
230
+ const block = systemBlock || detachedBlock;
231
+ const file = systemFile || detachedFile;
232
+ const dir =
233
+ systemBlock || systemFile
234
+ ? systemDir
235
+ : detachedBlock || detachedFile
236
+ ? detachedDir
237
+ : null;
238
+
239
+ if (!block || !file || !dir) {
240
+ result.errors.push({
241
+ label,
242
+ error: `Could not find both block and file for label "${label}"`,
243
+ });
244
+ continue;
245
+ }
246
+
247
+ if (resolution === "file") {
248
+ // Overwrite block with file content
249
+ await client.blocks.update(block.id, { value: file.content });
250
+ result.resolved.push({
251
+ label,
252
+ resolution: "file",
253
+ action: "Updated block with file content",
254
+ });
255
+ } else if (resolution === "block") {
256
+ // Overwrite file with block content
257
+ writeMemoryFile(dir, label, block.value);
258
+ result.resolved.push({
259
+ label,
260
+ resolution: "block",
261
+ action: "Updated file with block content",
262
+ });
263
+ }
264
+ } catch (error) {
265
+ result.errors.push({
266
+ label,
267
+ error: error instanceof Error ? error.message : String(error),
268
+ });
269
+ }
270
+ }
271
+
272
+ // Update sync state after resolving all conflicts
273
+ // Re-read everything to capture the new state
274
+ const updatedSystemFiles = await readMemoryFiles(systemDir);
275
+ const updatedDetachedFiles = await readMemoryFiles(detachedDir);
276
+ updatedSystemFiles.delete("memory_filesystem");
277
+
278
+ const updatedBlocksResponse = await client.agents.blocks.list(agentId, {
279
+ limit: 1000,
280
+ });
281
+ const updatedBlocks = Array.isArray(updatedBlocksResponse)
282
+ ? updatedBlocksResponse
283
+ : ((updatedBlocksResponse as { items?: unknown[] }).items as Array<{
284
+ label?: string;
285
+ value?: string;
286
+ }>) || [];
287
+
288
+ const systemBlockHashes: Record<string, string> = {};
289
+ const systemFileHashes: Record<string, string> = {};
290
+ const detachedBlockHashes: Record<string, string> = {};
291
+ const detachedFileHashes: Record<string, string> = {};
292
+
293
+ for (const block of updatedBlocks.filter(
294
+ (b: { label?: string }) => b.label && b.label !== "memory_filesystem",
295
+ )) {
296
+ systemBlockHashes[block.label as string] = hashContent(
297
+ (block as { value?: string }).value || "",
298
+ );
299
+ }
300
+
301
+ for (const [label, file] of updatedSystemFiles) {
302
+ systemFileHashes[label] = hashContent(file.content);
303
+ }
304
+
305
+ for (const [label, blockId] of Object.entries(lastState.detachedBlockIds)) {
306
+ try {
307
+ const block = await client.blocks.retrieve(blockId);
308
+ detachedBlockHashes[label] = hashContent(block.value || "");
309
+ } catch {
310
+ // Block gone
311
+ }
312
+ }
313
+
314
+ for (const [label, file] of updatedDetachedFiles) {
315
+ detachedFileHashes[label] = hashContent(file.content);
316
+ }
317
+
318
+ saveSyncState(
319
+ {
320
+ systemBlocks: systemBlockHashes,
321
+ systemFiles: systemFileHashes,
322
+ detachedBlocks: detachedBlockHashes,
323
+ detachedFiles: detachedFileHashes,
324
+ detachedBlockIds: lastState.detachedBlockIds,
325
+ lastSync: new Date().toISOString(),
326
+ },
327
+ agentId,
328
+ );
329
+
330
+ return result;
331
+ }
332
+
333
+ // CLI Entry Point
334
+ const isMainModule = import.meta.url === `file://${process.argv[1]}`;
335
+ if (isMainModule) {
336
+ const args = process.argv.slice(2);
337
+
338
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
339
+ console.log(`
340
+ Usage: npx tsx memfs-resolve.ts <agent-id> --resolutions '<JSON>'
341
+
342
+ Resolves all memory filesystem sync conflicts in one call.
343
+ Analogous to 'git merge' with explicit resolution choices.
344
+
345
+ Arguments:
346
+ agent-id Agent ID (can use $LETTA_AGENT_ID)
347
+ --resolutions JSON array of resolutions
348
+
349
+ Resolution format:
350
+ [{"label": "persona/soul", "resolution": "block"}, {"label": "human/prefs", "resolution": "file"}]
351
+
352
+ Resolution options:
353
+ "file" — Overwrite the memory block with the file contents
354
+ "block" — Overwrite the file with the memory block contents
355
+
356
+ Example:
357
+ npx tsx memfs-resolve.ts $LETTA_AGENT_ID --resolutions '[{"label":"persona/soul","resolution":"block"}]'
358
+ `);
359
+ process.exit(0);
360
+ }
361
+
362
+ const agentId = args[0];
363
+ if (!agentId) {
364
+ console.error("Error: agent-id is required");
365
+ process.exit(1);
366
+ }
367
+
368
+ // Parse --resolutions flag
369
+ const resolutionsIdx = args.indexOf("--resolutions");
370
+ if (resolutionsIdx === -1 || resolutionsIdx + 1 >= args.length) {
371
+ console.error("Error: --resolutions '<JSON>' is required");
372
+ process.exit(1);
373
+ }
374
+
375
+ let resolutions: Resolution[];
376
+ try {
377
+ resolutions = JSON.parse(args[resolutionsIdx + 1]);
378
+ if (!Array.isArray(resolutions)) {
379
+ throw new Error("Resolutions must be a JSON array");
380
+ }
381
+ for (const r of resolutions) {
382
+ if (!r.label || !r.resolution) {
383
+ throw new Error(
384
+ `Each resolution must have "label" and "resolution" fields`,
385
+ );
386
+ }
387
+ if (r.resolution !== "file" && r.resolution !== "block") {
388
+ throw new Error(
389
+ `Resolution must be "file" or "block", got "${r.resolution}"`,
390
+ );
391
+ }
392
+ }
393
+ } catch (error) {
394
+ console.error(
395
+ "Error parsing resolutions:",
396
+ error instanceof Error ? error.message : String(error),
397
+ );
398
+ process.exit(1);
399
+ }
400
+
401
+ resolveConflicts(agentId, resolutions)
402
+ .then((result) => {
403
+ console.log(JSON.stringify(result, null, 2));
404
+ })
405
+ .catch((error) => {
406
+ console.error(
407
+ "Error resolving conflicts:",
408
+ error instanceof Error ? error.message : String(error),
409
+ );
410
+ process.exit(1);
411
+ });
412
+ }