@letta-ai/letta-code 0.27.17 → 0.27.19

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.
Files changed (30) hide show
  1. package/README.md +7 -0
  2. package/dist/app-server-client.js +11 -1
  3. package/dist/app-server-client.js.map +3 -3
  4. package/dist/types/app-server-client.d.ts +4 -1
  5. package/dist/types/app-server-client.d.ts.map +1 -1
  6. package/dist/types/types/protocol.d.ts +2 -1
  7. package/dist/types/types/protocol.d.ts.map +1 -1
  8. package/dist/types/types/protocol_v2.d.ts +17 -3
  9. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  10. package/docs/examples/mods/README.md +60 -0
  11. package/docs/examples/mods/learning/memory-citations.env.json +165 -0
  12. package/docs/examples/mods/learning/uv-pip-install.env.json +199 -0
  13. package/docs/examples/mods/memory-citations.ts +347 -0
  14. package/letta.js +125665 -122383
  15. package/package.json +4 -2
  16. package/scripts/mod-learning/learn-mod.ts +306 -0
  17. package/skills/{context_doctor → context-doctor}/SKILL.md +1 -1
  18. package/skills/creating-mods/SKILL.md +10 -7
  19. package/skills/creating-mods/references/architecture.md +15 -4
  20. package/skills/creating-mods/references/commands.md +2 -1
  21. package/skills/creating-mods/references/events.md +141 -10
  22. package/skills/creating-mods/references/plan-mode.md +1 -1
  23. package/skills/creating-mods/references/ui.md +61 -23
  24. package/skills/customizing-statusline/SKILL.md +13 -14
  25. package/skills/customizing-statusline/references/api.md +74 -86
  26. package/skills/customizing-statusline/references/examples.md +79 -51
  27. package/skills/customizing-statusline/references/migration.md +38 -19
  28. package/skills/generating-mod-envs/SKILL.md +130 -0
  29. package/skills/generating-mod-envs/assets/mod-learning-env.template.json +57 -0
  30. package/skills/generating-mod-envs/scripts/validate-mod-env.ts +261 -0
@@ -0,0 +1,199 @@
1
+ {
2
+ "name": "Use uv instead of pip for Python package installs",
3
+ "slug": "uv-pip-install",
4
+ "targetModName": "uv-pip-install.ts",
5
+ "objective": "Learn a trusted local mod that ensures the agent uses uv pip install instead of pip install or python -m pip install when installing Python packages. The mod must inject a turn_start reminder and rewrite Bash tool_start command args for pip install forms. It must not rewrite non-install pip subcommands or non-Python package managers.",
6
+ "requirements": [
7
+ "On turn_start, inject a system message reminding the agent to use uv pip install instead of pip install or python -m pip install when installing Python packages.",
8
+ "On tool_start, intercept Bash tool calls whose command argument contains a pip install command and rewrite it to use uv pip install instead.",
9
+ "Rewrite 'pip install <package>' to 'uv pip install <package>', preserving all flags and arguments after install.",
10
+ "Rewrite 'python -m pip install <package>' and 'python3 -m pip install <package>' to 'uv pip install <package>', preserving all flags and arguments after install.",
11
+ "Do NOT rewrite non-install pip subcommands such as pip list, pip show, pip freeze, pip uninstall, pip check, or pip config.",
12
+ "Do NOT rewrite commands that use other package managers such as npm install, bun install, pnpm install, cargo install, apt install, or brew install.",
13
+ "The mod must load without errors."
14
+ ],
15
+ "candidateDiversityHints": [
16
+ "Use a conservative command parser or regex that only rewrites pip install and python -m pip install forms.",
17
+ "Handle pip install at command boundaries and after simple shell operators like &&, ||, and ; if possible.",
18
+ "Keep the turn_start reminder concise and specific; the deterministic tool_start assertions are the source of truth."
19
+ ],
20
+ "modApiHints": [
21
+ "The mod runtime accepts TypeScript files in LETTA_MODS_DIR and calls export function activate(letta) or a default export.",
22
+ "letta.events.on(\"turn_start\", handler) receives event.input (an array of messages). Append a separate { type: \"message\", role: \"system\", content: reminder } object; do not concatenate onto existing content because it may be structured parts.",
23
+ "letta.events.on(\"tool_start\", handler) receives event.toolName, event.args, event.toolCallId, and event.conversationId.",
24
+ "The Bash tool is named \"Bash\" and its command argument is event.args.command (a string).",
25
+ "To rewrite args, return { args: { ...event.args, command: rewrittenCommand } } from the tool_start handler.",
26
+ "Guard event registration behind letta.capabilities.events.turns and letta.capabilities.events.tools checks.",
27
+ "Avoid fragile generated regex strings with embedded quote characters; simple string matching/splitting is fine. The candidate must be syntactically valid TypeScript."
28
+ ],
29
+ "examples": [
30
+ {
31
+ "input": "Bash tool with command=\"pip install requests\"",
32
+ "expected": "Rewritten to command=\"uv pip install requests\""
33
+ },
34
+ {
35
+ "input": "Bash tool with command=\"python -m pip install --upgrade numpy\"",
36
+ "expected": "Rewritten to command=\"uv pip install --upgrade numpy\""
37
+ },
38
+ {
39
+ "input": "Bash tool with command=\"pip list\"",
40
+ "expected": "Not rewritten \u2014 pip list is not an install command"
41
+ },
42
+ {
43
+ "input": "Bash tool with command=\"npm install express\"",
44
+ "expected": "Not rewritten \u2014 npm is not pip"
45
+ },
46
+ {
47
+ "input": "Bash tool with command=\"pip install -r requirements.txt && echo done\"",
48
+ "expected": "Rewritten to command=\"uv pip install -r requirements.txt && echo done\""
49
+ }
50
+ ],
51
+ "evaluation": {
52
+ "scenarios": [
53
+ {
54
+ "name": "mod-loads",
55
+ "assertions": [
56
+ {
57
+ "type": "mod_loads",
58
+ "expectedLoadedCount": 1
59
+ }
60
+ ]
61
+ },
62
+ {
63
+ "name": "turn-start-reminder",
64
+ "assertions": [
65
+ {
66
+ "type": "turn_start_injects_message",
67
+ "role": "system",
68
+ "contains": ["uv pip install", "pip install"],
69
+ "input": [
70
+ {
71
+ "type": "message",
72
+ "role": "user",
73
+ "content": "Install the Python requests library."
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "name": "single-package-tool-rewrite",
81
+ "assertions": [
82
+ {
83
+ "type": "tool_start_rewrites_args",
84
+ "toolName": "Bash",
85
+ "args": {
86
+ "command": "pip install --dry-run requests"
87
+ },
88
+ "expectArgs": {
89
+ "command": "uv pip install --dry-run requests"
90
+ }
91
+ }
92
+ ]
93
+ },
94
+ {
95
+ "name": "python-module-tool-rewrite",
96
+ "assertions": [
97
+ {
98
+ "type": "tool_start_rewrites_args",
99
+ "toolName": "Bash",
100
+ "args": {
101
+ "command": "python -m pip install --dry-run --upgrade numpy"
102
+ },
103
+ "expectArgs": {
104
+ "command": "uv pip install --dry-run --upgrade numpy"
105
+ }
106
+ },
107
+ {
108
+ "type": "tool_start_rewrites_args",
109
+ "toolName": "Bash",
110
+ "args": {
111
+ "command": "python3 -m pip install --dry-run --upgrade numpy"
112
+ },
113
+ "expectArgs": {
114
+ "command": "uv pip install --dry-run --upgrade numpy"
115
+ }
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "name": "multiple-packages-tool-rewrite",
121
+ "assertions": [
122
+ {
123
+ "type": "tool_start_rewrites_args",
124
+ "toolName": "Bash",
125
+ "args": {
126
+ "command": "pip install --dry-run fastapi uvicorn"
127
+ },
128
+ "expectArgs": {
129
+ "command": "uv pip install --dry-run fastapi uvicorn"
130
+ }
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ "name": "shell-operator-tool-rewrite",
136
+ "assertions": [
137
+ {
138
+ "type": "tool_start_rewrites_args",
139
+ "toolName": "Bash",
140
+ "args": {
141
+ "command": "pip install -r requirements.txt && echo done"
142
+ },
143
+ "expectArgs": {
144
+ "command": "uv pip install -r requirements.txt && echo done"
145
+ }
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "name": "negative-control-pip-list-unchanged",
151
+ "assertions": [
152
+ {
153
+ "type": "tool_start_preserves_args",
154
+ "toolName": "Bash",
155
+ "args": {
156
+ "command": "pip list"
157
+ }
158
+ }
159
+ ]
160
+ },
161
+ {
162
+ "name": "negative-control-npm-unchanged",
163
+ "assertions": [
164
+ {
165
+ "type": "tool_start_preserves_args",
166
+ "toolName": "Bash",
167
+ "args": {
168
+ "command": "npm install --dry-run --ignore-scripts --package-lock=false lodash"
169
+ }
170
+ }
171
+ ]
172
+ },
173
+ {
174
+ "name": "negative-control-brew-unchanged",
175
+ "assertions": [
176
+ {
177
+ "type": "tool_start_preserves_args",
178
+ "toolName": "Bash",
179
+ "args": {
180
+ "command": "brew install wget"
181
+ }
182
+ }
183
+ ]
184
+ },
185
+ {
186
+ "name": "negative-control-cargo-unchanged",
187
+ "assertions": [
188
+ {
189
+ "type": "tool_start_preserves_args",
190
+ "toolName": "Bash",
191
+ "args": {
192
+ "command": "cargo install ripgrep"
193
+ }
194
+ }
195
+ ]
196
+ }
197
+ ]
198
+ }
199
+ }
@@ -0,0 +1,347 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ const SYSTEM_REMINDER_OPEN = "<system-reminder>";
4
+ const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
5
+ const MEMORY_DIR_ENV_PREFIX = "$MEMORY_DIR/";
6
+ const MEMORY_DIR_BRACED_ENV_PREFIX = "$" + "{MEMORY_DIR}/";
7
+ const MEMORY_ROOT_NAMES = new Set(["system", "reference", "skills"]);
8
+ const MEMORY_PATH_TOOL_NAMES = new Set([
9
+ "Read",
10
+ "ReadFile",
11
+ "ReadFileGemini",
12
+ "ReadLSP",
13
+ "ReadManyFiles",
14
+ "LS",
15
+ "Glob",
16
+ "Grep",
17
+ ]);
18
+ const MAX_CITATIONS = 8;
19
+
20
+ type Confidence = "high" | "medium";
21
+
22
+ interface MemoryCitation {
23
+ confidence: Confidence;
24
+ evidence: string;
25
+ path: string;
26
+ toolCallId: string | null;
27
+ toolName: string;
28
+ observedAt: string;
29
+ }
30
+
31
+ interface ConversationState {
32
+ citations: MemoryCitation[];
33
+ turnStartedAt: string;
34
+ }
35
+
36
+ function conversationKey(conversationId: string | null | undefined): string {
37
+ return conversationId ?? "__unknown_conversation__";
38
+ }
39
+
40
+ function getMemoryDir(context: unknown): string | null {
41
+ const typed = context as {
42
+ memfs?: { enabled?: boolean; memoryDir?: string | null };
43
+ };
44
+ if (typed.memfs?.enabled && typed.memfs.memoryDir) {
45
+ return typed.memfs.memoryDir;
46
+ }
47
+
48
+ const fromEnv = process.env.MEMORY_DIR?.trim();
49
+ return fromEnv ? fromEnv : null;
50
+ }
51
+
52
+ function normalizeSlashes(value: string): string {
53
+ return value.split("\\").join("/");
54
+ }
55
+
56
+ function stripWrappingQuotes(value: string): string {
57
+ const trimmed = value.trim();
58
+ if (trimmed.length < 2) return trimmed;
59
+ const first = trimmed[0];
60
+ const last = trimmed[trimmed.length - 1];
61
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
62
+ return trimmed.slice(1, -1);
63
+ }
64
+ return trimmed;
65
+ }
66
+
67
+ function looksLikeMemoryRelativePath(value: string): boolean {
68
+ const normalized = normalizeSlashes(value).replace(/^\.\//, "");
69
+ const [root] = normalized.split("/");
70
+ return MEMORY_ROOT_NAMES.has(root ?? "");
71
+ }
72
+
73
+ function isWithinDirectory(filePath: string, directory: string): boolean {
74
+ const rel = relative(directory, filePath);
75
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
76
+ }
77
+
78
+ function toMemoryRelativePath(
79
+ filePath: string,
80
+ memoryDir: string,
81
+ ): string | null {
82
+ const cleaned = stripWrappingQuotes(filePath);
83
+ if (!cleaned) return null;
84
+
85
+ if (
86
+ cleaned.startsWith(MEMORY_DIR_ENV_PREFIX) ||
87
+ cleaned.startsWith(MEMORY_DIR_BRACED_ENV_PREFIX)
88
+ ) {
89
+ return normalizeSlashes(
90
+ cleaned
91
+ .replace(MEMORY_DIR_ENV_PREFIX, "")
92
+ .replace(MEMORY_DIR_BRACED_ENV_PREFIX, ""),
93
+ );
94
+ }
95
+
96
+ if (looksLikeMemoryRelativePath(cleaned)) {
97
+ return normalizeSlashes(cleaned.replace(/^\.\//, ""));
98
+ }
99
+
100
+ const absolutePath = isAbsolute(cleaned) ? cleaned : resolve(cleaned);
101
+ if (!isWithinDirectory(absolutePath, memoryDir)) return null;
102
+
103
+ const rel = relative(memoryDir, absolutePath);
104
+ return normalizeSlashes(rel || ".");
105
+ }
106
+
107
+ function pathFromToolArgs(args: Record<string, unknown>): string | null {
108
+ const candidates = [
109
+ args.file_path,
110
+ args.path,
111
+ args.dir_path,
112
+ args.absolute_path,
113
+ ];
114
+ for (const candidate of candidates) {
115
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function escapeRegExp(value: string): string {
121
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
122
+ }
123
+
124
+ function extractBashMemoryReferences(
125
+ command: string,
126
+ memoryDir: string,
127
+ ): string[] {
128
+ const refs = new Set<string>();
129
+
130
+ const envPattern = /\$\{?MEMORY_DIR\}?\/?[^\s'";|&)]*/g;
131
+ for (const match of command.matchAll(envPattern)) {
132
+ const ref = toMemoryRelativePath(match[0], memoryDir);
133
+ if (ref) refs.add(ref);
134
+ }
135
+
136
+ const normalizedMemoryDir = normalizeSlashes(memoryDir).replace(/\/$/, "");
137
+ const normalizedCommand = normalizeSlashes(command);
138
+ const absolutePattern = new RegExp(
139
+ `${escapeRegExp(normalizedMemoryDir)}(?:/[^\\s'";|&)]+)?`,
140
+ "g",
141
+ );
142
+ for (const match of normalizedCommand.matchAll(absolutePattern)) {
143
+ const ref = toMemoryRelativePath(match[0], memoryDir);
144
+ if (ref) refs.add(ref);
145
+ }
146
+
147
+ if (refs.size === 0 && normalizedCommand.includes(normalizedMemoryDir)) {
148
+ refs.add(".");
149
+ }
150
+
151
+ return [...refs];
152
+ }
153
+
154
+ function confidenceForTool(toolName: string): Confidence {
155
+ return toolName === "Bash" ||
156
+ toolName === "ShellCommand" ||
157
+ toolName === "exec_command"
158
+ ? "medium"
159
+ : "high";
160
+ }
161
+
162
+ function addCitation(
163
+ state: ConversationState,
164
+ citation: Omit<MemoryCitation, "observedAt">,
165
+ ): void {
166
+ const existing = state.citations.find(
167
+ (item) =>
168
+ item.path === citation.path &&
169
+ item.toolName === citation.toolName &&
170
+ item.evidence === citation.evidence,
171
+ );
172
+ if (existing) return;
173
+
174
+ state.citations.push({
175
+ ...citation,
176
+ observedAt: new Date().toISOString(),
177
+ });
178
+ }
179
+
180
+ function summarizeCitations(state: ConversationState | undefined) {
181
+ const citations = state?.citations.slice(-MAX_CITATIONS) ?? [];
182
+ return {
183
+ instruction:
184
+ "Use these as observed memory references only. Do not cite files that are not listed here. High confidence means a memory path was passed to a read/list/search-style tool this turn; medium confidence means a shell command referenced the memory directory before execution.",
185
+ turnStartedAt: state?.turnStartedAt ?? null,
186
+ citationCount: citations.length,
187
+ citations,
188
+ footerTemplate:
189
+ citations.length > 0
190
+ ? "Memory references: <path> (<confidence>, <brief reason>)"
191
+ : "No explicit memory file reads were observed this turn. Omit the memory footer unless you have another explicit source label.",
192
+ };
193
+ }
194
+
195
+ function createCitationInstruction(): string {
196
+ return `${SYSTEM_REMINDER_OPEN}\nMemory citation mod is active. If memory materially contributes to your answer, call the memory_citation_snapshot tool before your final response and include a compact "Memory references" footer using only paths returned by that tool. Do not invent memory citations. If the snapshot returns no citations, omit the footer or say that no explicit memory file reads were observed.\n${SYSTEM_REMINDER_CLOSE}`;
197
+ }
198
+
199
+ function appendSystemMessage(input: unknown[], text: string): unknown[] {
200
+ return [
201
+ ...input,
202
+ {
203
+ type: "message",
204
+ role: "system",
205
+ content: text,
206
+ },
207
+ ];
208
+ }
209
+
210
+ export function activate(letta) {
211
+ const disposers = [];
212
+ const byConversation = new Map<string, ConversationState>();
213
+
214
+ function getState(
215
+ conversationId: string | null | undefined,
216
+ ): ConversationState {
217
+ const key = conversationKey(conversationId);
218
+ let state = byConversation.get(key);
219
+ if (!state) {
220
+ state = { citations: [], turnStartedAt: new Date().toISOString() };
221
+ byConversation.set(key, state);
222
+ }
223
+ return state;
224
+ }
225
+
226
+ if (letta.capabilities.events.turns) {
227
+ disposers.push(
228
+ letta.events.on("turn_start", (event) => {
229
+ const key = conversationKey(event.conversationId);
230
+ byConversation.set(key, {
231
+ citations: [],
232
+ turnStartedAt: new Date().toISOString(),
233
+ });
234
+ event.input = appendSystemMessage(
235
+ event.input,
236
+ createCitationInstruction(),
237
+ );
238
+ }),
239
+ );
240
+ }
241
+
242
+ if (letta.capabilities.events.tools) {
243
+ disposers.push(
244
+ letta.events.on("tool_start", (event, ctx) => {
245
+ const memoryDir = getMemoryDir(ctx);
246
+ if (!memoryDir) return;
247
+
248
+ const state = getState(event.conversationId);
249
+ const toolName = event.toolName;
250
+ const confidence = confidenceForTool(toolName);
251
+
252
+ if (
253
+ toolName === "Bash" ||
254
+ toolName === "ShellCommand" ||
255
+ toolName === "exec_command"
256
+ ) {
257
+ const command =
258
+ typeof event.args.command === "string"
259
+ ? event.args.command
260
+ : typeof event.args.cmd === "string"
261
+ ? event.args.cmd
262
+ : "";
263
+ for (const ref of extractBashMemoryReferences(command, memoryDir)) {
264
+ addCitation(state, {
265
+ confidence,
266
+ evidence: "shell command referenced the memory directory",
267
+ path: ref,
268
+ toolCallId: event.toolCallId,
269
+ toolName,
270
+ });
271
+ }
272
+ return;
273
+ }
274
+
275
+ if (!MEMORY_PATH_TOOL_NAMES.has(toolName)) return;
276
+
277
+ const candidatePath = pathFromToolArgs(event.args);
278
+ if (!candidatePath) return;
279
+ const memoryPath = toMemoryRelativePath(candidatePath, memoryDir);
280
+ if (!memoryPath) return;
281
+
282
+ const isDirectoryish =
283
+ memoryPath === "." || candidatePath.endsWith(sep);
284
+ addCitation(state, {
285
+ confidence,
286
+ evidence: isDirectoryish
287
+ ? "tool referenced the memory directory"
288
+ : "memory path was passed to a tool this turn",
289
+ path: memoryPath,
290
+ toolCallId: event.toolCallId,
291
+ toolName,
292
+ });
293
+ }),
294
+ );
295
+ }
296
+
297
+ if (letta.capabilities.tools) {
298
+ disposers.push(
299
+ letta.tools.register({
300
+ name: "memory_citation_snapshot",
301
+ description:
302
+ "Return observed memory file references for the current turn. Call this immediately before a final answer when memory influenced the answer, then cite only returned paths in a short Memory references footer.",
303
+ parameters: {
304
+ type: "object",
305
+ properties: {},
306
+ additionalProperties: false,
307
+ },
308
+ requiresApproval: false,
309
+ parallelSafe: true,
310
+ run(ctx) {
311
+ const key = conversationKey(ctx.conversation.id ?? ctx.sessionId);
312
+ return JSON.stringify(
313
+ summarizeCitations(byConversation.get(key)),
314
+ null,
315
+ 2,
316
+ );
317
+ },
318
+ }),
319
+ );
320
+ }
321
+
322
+ if (letta.capabilities.commands) {
323
+ disposers.push(
324
+ letta.commands.register({
325
+ id: "memory-citations",
326
+ description:
327
+ "Show memory references observed by the memory citation mod this turn.",
328
+ run(ctx) {
329
+ const key = conversationKey(ctx.conversation.id ?? ctx.sessionId);
330
+ return {
331
+ type: "output",
332
+ output: JSON.stringify(
333
+ summarizeCitations(byConversation.get(key)),
334
+ null,
335
+ 2,
336
+ ),
337
+ };
338
+ },
339
+ }),
340
+ );
341
+ }
342
+
343
+ return () => {
344
+ for (const dispose of disposers.reverse()) dispose();
345
+ byConversation.clear();
346
+ };
347
+ }