@letta-ai/letta-code 0.14.3 → 0.14.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@letta-ai/letta-client": "^1.7.6",
33
+ "@letta-ai/letta-client": "^1.7.7",
34
34
  "glob": "^13.0.0",
35
35
  "ink-link": "^5.0.0",
36
36
  "open": "^10.2.0",
@@ -0,0 +1,47 @@
1
+ import { createHash } from "node:crypto";
2
+ import { READ_ONLY_BLOCK_LABELS } from "../../../../../agent/memoryConstants";
3
+
4
+ /**
5
+ * Read-only block labels. These are API-authoritative.
6
+ */
7
+ export const READ_ONLY_LABELS = new Set(
8
+ READ_ONLY_BLOCK_LABELS as readonly string[],
9
+ );
10
+
11
+ /**
12
+ * Parse MDX-style frontmatter from content.
13
+ * This is a copy of parseMdxFrontmatter from src/agent/memory.ts.
14
+ * The test ensures this stays in sync with the original.
15
+ */
16
+ export function parseFrontmatter(content: string): {
17
+ frontmatter: Record<string, string>;
18
+ body: string;
19
+ } {
20
+ const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
21
+ const match = content.match(frontmatterRegex);
22
+
23
+ if (!match || !match[1] || !match[2]) {
24
+ return { frontmatter: {}, body: content };
25
+ }
26
+
27
+ const frontmatterText = match[1];
28
+ const body = match[2];
29
+ const frontmatter: Record<string, string> = {};
30
+
31
+ // Parse YAML-like frontmatter (simple key: value pairs)
32
+ for (const line of frontmatterText.split("\n")) {
33
+ const colonIndex = line.indexOf(":");
34
+ if (colonIndex > 0) {
35
+ const key = line.slice(0, colonIndex).trim();
36
+ const value = line.slice(colonIndex + 1).trim();
37
+ frontmatter[key] = value;
38
+ }
39
+ }
40
+
41
+ return { frontmatter, body: body.trim() };
42
+ }
43
+
44
+ export function hashFileBody(content: string): string {
45
+ const { body } = parseFrontmatter(content);
46
+ return createHash("sha256").update(body).digest("hex");
47
+ }
@@ -18,6 +18,7 @@ import { readdir, readFile } from "node:fs/promises";
18
18
  import { createRequire } from "node:module";
19
19
  import { homedir } from "node:os";
20
20
  import { join, normalize, relative } from "node:path";
21
+ import { hashFileBody, READ_ONLY_LABELS } from "./lib/frontmatter";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const Letta = require("@letta-ai/letta-client")
@@ -45,12 +46,11 @@ function getApiKey(): string {
45
46
 
46
47
  const MEMORY_FS_STATE_FILE = ".sync-state.json";
47
48
 
49
+ // Unified sync state format (matches main memoryFilesystem.ts)
48
50
  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>;
51
+ blockHashes: Record<string, string>;
52
+ fileHashes: Record<string, string>;
53
+ blockIds: Record<string, string>;
54
54
  lastSync: string | null;
55
55
  };
56
56
 
@@ -58,6 +58,8 @@ function hashContent(content: string): string {
58
58
  return createHash("sha256").update(content).digest("hex");
59
59
  }
60
60
 
61
+ // parseFrontmatter/hashFileBody provided by shared helper
62
+
61
63
  function getMemoryRoot(agentId: string): string {
62
64
  return join(homedir(), ".letta", "agents", agentId, "memory");
63
65
  }
@@ -66,49 +68,45 @@ function loadSyncState(agentId: string): SyncState {
66
68
  const statePath = join(getMemoryRoot(agentId), MEMORY_FS_STATE_FILE);
67
69
  if (!existsSync(statePath)) {
68
70
  return {
69
- systemBlocks: {},
70
- systemFiles: {},
71
- detachedBlocks: {},
72
- detachedFiles: {},
73
- detachedBlockIds: {},
71
+ blockHashes: {},
72
+ fileHashes: {},
73
+ blockIds: {},
74
74
  lastSync: null,
75
75
  };
76
76
  }
77
77
 
78
78
  try {
79
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
- };
80
+ const parsed = JSON.parse(raw);
84
81
  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 || {},
82
+ blockHashes: parsed.blockHashes || {},
83
+ fileHashes: parsed.fileHashes || {},
84
+ blockIds: parsed.blockIds || {},
90
85
  lastSync: parsed.lastSync || null,
91
86
  };
92
87
  } catch {
93
88
  return {
94
- systemBlocks: {},
95
- systemFiles: {},
96
- detachedBlocks: {},
97
- detachedFiles: {},
98
- detachedBlockIds: {},
89
+ blockHashes: {},
90
+ fileHashes: {},
91
+ blockIds: {},
99
92
  lastSync: null,
100
93
  };
101
94
  }
102
95
  }
103
96
 
104
- async function scanMdFiles(dir: string, baseDir = dir): Promise<string[]> {
97
+ async function scanMdFiles(
98
+ dir: string,
99
+ baseDir = dir,
100
+ excludeDirs: string[] = [],
101
+ ): Promise<string[]> {
105
102
  if (!existsSync(dir)) return [];
106
103
  const entries = await readdir(dir, { withFileTypes: true });
107
104
  const results: string[] = [];
108
105
  for (const entry of entries) {
109
106
  const fullPath = join(dir, entry.name);
110
107
  if (entry.isDirectory()) {
111
- results.push(...(await scanMdFiles(fullPath, baseDir)));
108
+ if (excludeDirs.includes(entry.name)) continue;
109
+ results.push(...(await scanMdFiles(fullPath, baseDir, excludeDirs)));
112
110
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
113
111
  results.push(relative(baseDir, fullPath));
114
112
  }
@@ -122,8 +120,9 @@ function labelFromPath(relativePath: string): string {
122
120
 
123
121
  async function readMemoryFiles(
124
122
  dir: string,
123
+ excludeDirs: string[] = [],
125
124
  ): Promise<Map<string, { content: string }>> {
126
- const files = await scanMdFiles(dir);
125
+ const files = await scanMdFiles(dir, dir, excludeDirs);
127
126
  const entries = new Map<string, { content: string }>();
128
127
  for (const rel of files) {
129
128
  const label = labelFromPath(rel);
@@ -133,11 +132,8 @@ async function readMemoryFiles(
133
132
  return entries;
134
133
  }
135
134
 
136
- const MANAGED_LABELS = new Set([
137
- "memory_filesystem",
138
- "skills",
139
- "loaded_skills",
140
- ]);
135
+ // Only memory_filesystem is managed by memfs itself
136
+ const MEMFS_MANAGED_LABELS = new Set(["memory_filesystem"]);
141
137
 
142
138
  interface Conflict {
143
139
  label: string;
@@ -145,6 +141,12 @@ interface Conflict {
145
141
  blockContent: string;
146
142
  }
147
143
 
144
+ interface MetadataChange {
145
+ label: string;
146
+ fileContent: string;
147
+ blockContent: string;
148
+ }
149
+
148
150
  /**
149
151
  * Get the overflow directory following the same pattern as tool output overflow.
150
152
  * Pattern: ~/.letta/projects/<project-path>/agent-tools/
@@ -160,122 +162,163 @@ function getOverflowDirectory(): string {
160
162
  return join(homedir(), ".letta", "projects", sanitizedPath, "agent-tools");
161
163
  }
162
164
 
163
- async function findConflicts(agentId: string): Promise<Conflict[]> {
165
+ async function findConflicts(agentId: string): Promise<{
166
+ conflicts: Conflict[];
167
+ metadataOnly: MetadataChange[];
168
+ }> {
164
169
  const baseUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
165
170
  const client = new Letta({ apiKey: getApiKey(), baseUrl });
166
171
 
167
172
  const root = getMemoryRoot(agentId);
168
173
  const systemDir = join(root, "system");
169
- // Detached files go at root level (flat structure)
170
174
  const detachedDir = root;
171
175
 
172
176
  for (const dir of [root, systemDir]) {
173
177
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
174
178
  }
175
179
 
180
+ // Read files from both locations
176
181
  const systemFiles = await readMemoryFiles(systemDir);
177
- const detachedFiles = await readMemoryFiles(detachedDir);
178
- systemFiles.delete("memory_filesystem");
182
+ const detachedFiles = await readMemoryFiles(detachedDir, ["system", "user"]);
179
183
 
184
+ // Fetch attached blocks
180
185
  const blocksResponse = await client.agents.blocks.list(agentId, {
181
186
  limit: 1000,
182
187
  });
183
- const blocks = Array.isArray(blocksResponse)
188
+ const attachedBlocks = Array.isArray(blocksResponse)
184
189
  ? blocksResponse
185
190
  : ((blocksResponse as { items?: unknown[] }).items as Array<{
191
+ id?: string;
186
192
  label?: string;
187
193
  value?: string;
194
+ read_only?: boolean;
188
195
  }>) || [];
189
196
 
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
197
+ const systemBlockMap = new Map<
198
+ string,
199
+ { value: string; id: string; read_only?: boolean }
200
+ >();
201
+ for (const block of attachedBlocks) {
202
+ if (block.label && block.id) {
203
+ systemBlockMap.set(block.label, {
204
+ value: block.value || "",
205
+ id: block.id,
206
+ read_only: block.read_only,
207
+ });
209
208
  }
210
209
  }
211
210
 
212
- const conflicts: Conflict[] = [];
211
+ // Fetch detached blocks via owner tag
212
+ const ownedBlocksResponse = await client.blocks.list({
213
+ tags: [`owner:${agentId}`],
214
+ limit: 1000,
215
+ });
216
+ const ownedBlocks = Array.isArray(ownedBlocksResponse)
217
+ ? ownedBlocksResponse
218
+ : ((ownedBlocksResponse as { items?: unknown[] }).items as Array<{
219
+ id?: string;
220
+ label?: string;
221
+ value?: string;
222
+ read_only?: boolean;
223
+ }>) || [];
213
224
 
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 });
225
+ const attachedIds = new Set(attachedBlocks.map((b) => b.id));
226
+ const detachedBlockMap = new Map<
227
+ string,
228
+ { value: string; id: string; read_only?: boolean }
229
+ >();
230
+ for (const block of ownedBlocks) {
231
+ if (block.label && block.id && !attachedIds.has(block.id)) {
232
+ if (!systemBlockMap.has(block.label)) {
233
+ detachedBlockMap.set(block.label, {
234
+ value: block.value || "",
235
+ id: block.id,
236
+ read_only: block.read_only,
237
+ });
238
+ }
229
239
  }
230
240
  }
231
241
 
232
- // Check system labels
233
- const systemLabels = new Set([
242
+ const lastState = loadSyncState(agentId);
243
+ const conflicts: Conflict[] = [];
244
+ const metadataOnly: MetadataChange[] = [];
245
+
246
+ // Collect all labels
247
+ const allLabels = new Set<string>([
234
248
  ...systemFiles.keys(),
249
+ ...detachedFiles.keys(),
235
250
  ...systemBlockMap.keys(),
236
- ...Object.keys(lastState.systemBlocks),
237
- ...Object.keys(lastState.systemFiles),
251
+ ...detachedBlockMap.keys(),
252
+ ...Object.keys(lastState.blockHashes),
253
+ ...Object.keys(lastState.fileHashes),
238
254
  ]);
239
255
 
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
- }
256
+ for (const label of [...allLabels].sort()) {
257
+ if (MEMFS_MANAGED_LABELS.has(label)) continue;
250
258
 
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
- ]);
259
+ const systemFile = systemFiles.get(label);
260
+ const detachedFile = detachedFiles.get(label);
261
+ const attachedBlock = systemBlockMap.get(label);
262
+ const detachedBlock = detachedBlockMap.get(label);
258
263
 
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
- );
264
+ const fileEntry = systemFile || detachedFile;
265
+ const blockEntry = attachedBlock || detachedBlock;
266
+
267
+ if (!fileEntry || !blockEntry) continue;
268
+
269
+ // read_only blocks are API-authoritative; no conflicts possible
270
+ const effectiveReadOnly =
271
+ !!blockEntry.read_only || READ_ONLY_LABELS.has(label);
272
+ if (effectiveReadOnly) continue;
273
+
274
+ // Full file hash for "file changed" check
275
+ const fileHash = hashContent(fileEntry.content);
276
+ // Body hash for "content matches" check
277
+ const fileBodyHash = hashFileBody(fileEntry.content);
278
+ const blockHash = hashContent(blockEntry.value);
279
+
280
+ const lastFileHash = lastState.fileHashes[label] ?? null;
281
+ const lastBlockHash = lastState.blockHashes[label] ?? null;
282
+ const fileChanged = fileHash !== lastFileHash;
283
+ const blockChanged = blockHash !== lastBlockHash;
284
+
285
+ // Content matches - check for frontmatter-only changes
286
+ if (fileBodyHash === blockHash) {
287
+ if (fileChanged) {
288
+ metadataOnly.push({
289
+ label,
290
+ fileContent: fileEntry.content,
291
+ blockContent: blockEntry.value,
292
+ });
293
+ }
294
+ continue;
295
+ }
296
+
297
+ // Conflict only if both changed
298
+ if (fileChanged && blockChanged) {
299
+ conflicts.push({
300
+ label,
301
+ fileContent: fileEntry.content,
302
+ blockContent: blockEntry.value,
303
+ });
304
+ }
267
305
  }
268
306
 
269
- return conflicts;
307
+ return { conflicts, metadataOnly };
270
308
  }
271
309
 
272
- function formatDiffFile(conflicts: Conflict[], agentId: string): string {
310
+ function formatDiffFile(
311
+ conflicts: Conflict[],
312
+ metadataOnly: MetadataChange[],
313
+ agentId: string,
314
+ ): string {
273
315
  const lines: string[] = [
274
316
  `# Memory Filesystem Diff`,
275
317
  ``,
276
318
  `Agent: ${agentId}`,
277
319
  `Generated: ${new Date().toISOString()}`,
278
320
  `Conflicts: ${conflicts.length}`,
321
+ `Metadata-only changes: ${metadataOnly.length}`,
279
322
  ``,
280
323
  `---`,
281
324
  ``,
@@ -298,6 +341,32 @@ function formatDiffFile(conflicts: Conflict[], agentId: string): string {
298
341
  lines.push(``);
299
342
  }
300
343
 
344
+ if (metadataOnly.length > 0) {
345
+ lines.push(`## Metadata-only Changes`);
346
+ lines.push(``);
347
+ lines.push(
348
+ `Frontmatter changed while body content stayed the same (file wins).`,
349
+ );
350
+ lines.push(``);
351
+
352
+ for (const change of metadataOnly) {
353
+ lines.push(`### ${change.label}`);
354
+ lines.push(``);
355
+ lines.push(`#### File Version (with frontmatter)`);
356
+ lines.push(`\`\`\``);
357
+ lines.push(change.fileContent);
358
+ lines.push(`\`\`\``);
359
+ lines.push(``);
360
+ lines.push(`#### Block Version (body only)`);
361
+ lines.push(`\`\`\``);
362
+ lines.push(change.blockContent);
363
+ lines.push(`\`\`\``);
364
+ lines.push(``);
365
+ lines.push(`---`);
366
+ lines.push(``);
367
+ }
368
+ }
369
+
301
370
  return lines.join("\n");
302
371
  }
303
372
 
@@ -329,13 +398,13 @@ Output: Path to the diff file, or a message if no conflicts exist.
329
398
  }
330
399
 
331
400
  findConflicts(agentId)
332
- .then((conflicts) => {
333
- if (conflicts.length === 0) {
401
+ .then(({ conflicts, metadataOnly }) => {
402
+ if (conflicts.length === 0 && metadataOnly.length === 0) {
334
403
  console.log("No conflicts found. Memory filesystem is clean.");
335
404
  return;
336
405
  }
337
406
 
338
- const diffContent = formatDiffFile(conflicts, agentId);
407
+ const diffContent = formatDiffFile(conflicts, metadataOnly, agentId);
339
408
 
340
409
  // Write to overflow directory (same pattern as tool output overflow)
341
410
  const overflowDir = getOverflowDirectory();
@@ -348,7 +417,7 @@ Output: Path to the diff file, or a message if no conflicts exist.
348
417
  writeFileSync(diffPath, diffContent, "utf-8");
349
418
 
350
419
  console.log(
351
- `Diff (${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}) written to: ${diffPath}`,
420
+ `Diff (${conflicts.length} conflict${conflicts.length === 1 ? "" : "s"}, ${metadataOnly.length} metadata-only change${metadataOnly.length === 1 ? "" : "s"}) written to: ${diffPath}`,
352
421
  );
353
422
  })
354
423
  .catch((error) => {