@letta-ai/letta-code 0.27.18 → 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.
- package/dist/app-server-client.js +11 -1
- package/dist/app-server-client.js.map +3 -3
- package/dist/types/app-server-client.d.ts +4 -1
- package/dist/types/app-server-client.d.ts.map +1 -1
- package/dist/types/types/protocol_v2.d.ts +1 -1
- package/dist/types/types/protocol_v2.d.ts.map +1 -1
- package/docs/examples/mods/README.md +60 -0
- package/docs/examples/mods/learning/memory-citations.env.json +165 -0
- package/docs/examples/mods/learning/uv-pip-install.env.json +199 -0
- package/docs/examples/mods/memory-citations.ts +347 -0
- package/letta.js +4857 -1703
- package/package.json +4 -2
- package/scripts/mod-learning/learn-mod.ts +306 -0
- package/skills/{context_doctor → context-doctor}/SKILL.md +1 -1
- package/skills/creating-mods/SKILL.md +4 -1
- package/skills/creating-mods/references/events.md +17 -3
- package/skills/creating-mods/references/ui.md +23 -0
- package/skills/generating-mod-envs/SKILL.md +130 -0
- package/skills/generating-mod-envs/assets/mod-learning-env.template.json +57 -0
- package/skills/generating-mod-envs/scripts/validate-mod-env.ts +261 -0
|
@@ -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
|
+
}
|