@letta-ai/letta-code 0.28.11 → 0.28.12

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,384 @@
1
+ #!/usr/bin/env tsx
2
+ import { existsSync, readFileSync } from "node:fs";
3
+
4
+ type Args = Record<string, string | boolean>;
5
+ type JsonObject = Record<string, unknown>;
6
+
7
+ const SECRET_FIELD_PATTERN =
8
+ /api[_-]?key|access[_-]?key|secret|token|credential|password|authorization/i;
9
+
10
+ function usage(): never {
11
+ console.error(`Usage:
12
+ npx tsx scripts/update-agent-settings.ts --target agent|conversation [options]
13
+
14
+ Options:
15
+ --target <agent|conversation> Required
16
+ --agent-id <id> Defaults to AGENT_ID
17
+ --conversation-id <id> Defaults to CONVERSATION_ID
18
+ --base-url <url> Defaults to LETTA_BASE_URL; required for server operations
19
+ --name <name> Rename the agent (agent target only)
20
+ --description <text> Update agent description (agent target only)
21
+ --model <provider/model> Model handle
22
+ --context-window-limit <int|null> Top-level context_window_limit
23
+ --model-settings-file <json> JSON object for model_settings
24
+ --merge-model-settings Merge file into current model_settings; fetches current state
25
+ --system-file <path> Full replacement system prompt (agent target only)
26
+ --compaction-settings-file <json> JSON object for compaction_settings (agent target only)
27
+ --merge-compaction-settings Merge file into current compaction_settings; fetches current state
28
+ --allow-other-agent Permit server operations on a different agent/conversation than the current env ID
29
+ --confirm-system-replacement Required for live non-dry-run --system-file writes
30
+ --show GET and print safe effective fields only
31
+ --dry-run Print patch body without PATCHing
32
+ `);
33
+ process.exit(2);
34
+ }
35
+
36
+ function parseArgs(argv: string[]): Args {
37
+ const out: Args = {};
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const arg = argv[i];
40
+ if (arg === "--help" || arg === "-h") usage();
41
+ if (!arg.startsWith("--"))
42
+ throw new Error(`Unexpected positional argument: ${arg}`);
43
+ const key = arg.slice(2);
44
+ if (
45
+ [
46
+ "dry-run",
47
+ "merge-model-settings",
48
+ "merge-compaction-settings",
49
+ "allow-other-agent",
50
+ "confirm-system-replacement",
51
+ "show",
52
+ ].includes(key)
53
+ ) {
54
+ out[key] = true;
55
+ continue;
56
+ }
57
+ const value = argv[++i];
58
+ if (!value || value.startsWith("--"))
59
+ throw new Error(`Missing value for --${key}`);
60
+ out[key] = value;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ async function requestJson(
66
+ url: string,
67
+ init: RequestInit,
68
+ ): Promise<JsonObject> {
69
+ const response = await fetch(url, init);
70
+ const text = await response.text();
71
+ let body: unknown = null;
72
+ try {
73
+ body = text ? JSON.parse(text) : null;
74
+ } catch {
75
+ body = text;
76
+ }
77
+ if (!response.ok) {
78
+ const rendered = typeof body === "string" ? body : JSON.stringify(body);
79
+ throw new Error(
80
+ `HTTP ${response.status} ${response.statusText}: ${rendered}`,
81
+ );
82
+ }
83
+ return (body ?? {}) as JsonObject;
84
+ }
85
+
86
+ function readJsonObject(path: string): JsonObject {
87
+ if (!existsSync(path)) throw new Error(`File not found: ${path}`);
88
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
89
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
90
+ throw new Error(`${path} must contain a JSON object`);
91
+ }
92
+ return parsed as JsonObject;
93
+ }
94
+
95
+ function parseContextWindow(raw: string): number | null {
96
+ if (raw === "null") return null;
97
+ if (!/^\d+$/.test(raw))
98
+ throw new Error("--context-window-limit must be an integer or null");
99
+ return Number.parseInt(raw, 10);
100
+ }
101
+
102
+ function requireString(value: unknown, message: string): string {
103
+ const str = String(value ?? "");
104
+ if (!str) throw new Error(message);
105
+ return str;
106
+ }
107
+
108
+ function optionalNonEmptyString(args: Args, key: string): string | undefined {
109
+ if (args[key] === undefined) return undefined;
110
+ const value = String(args[key]);
111
+ if (!value.trim()) throw new Error(`--${key} must be non-empty`);
112
+ return value;
113
+ }
114
+
115
+ function redactSecretFields(value: unknown): unknown {
116
+ if (Array.isArray(value)) return value.map(redactSecretFields);
117
+ if (!value || typeof value !== "object") return value;
118
+
119
+ const output: JsonObject = {};
120
+ for (const [key, nested] of Object.entries(value as JsonObject)) {
121
+ output[key] = SECRET_FIELD_PATTERN.test(key)
122
+ ? "[redacted]"
123
+ : redactSecretFields(nested);
124
+ }
125
+ return output;
126
+ }
127
+
128
+ function safeLlmConfig(value: unknown): JsonObject | undefined {
129
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
130
+ return undefined;
131
+ }
132
+ const source = value as JsonObject;
133
+ if (source.context_window === undefined) return undefined;
134
+ return { context_window: source.context_window };
135
+ }
136
+
137
+ function safeServerFields(value: JsonObject): JsonObject {
138
+ const output: JsonObject = {};
139
+ for (const key of [
140
+ "id",
141
+ "name",
142
+ "description",
143
+ "model",
144
+ "context_window_limit",
145
+ ]) {
146
+ if (value[key] !== undefined) output[key] = value[key];
147
+ }
148
+
149
+ const agentId = value.agent_id ?? value.agentId;
150
+ if (agentId !== undefined) output.agent_id = agentId;
151
+
152
+ const llmConfig = safeLlmConfig(value.llm_config);
153
+ if (llmConfig) output.llm_config = llmConfig;
154
+ if (value.model_settings !== undefined) {
155
+ output.model_settings = redactSecretFields(value.model_settings);
156
+ }
157
+ if (value.compaction_settings !== undefined) {
158
+ output.compaction_settings = redactSecretFields(value.compaction_settings);
159
+ }
160
+ return output;
161
+ }
162
+
163
+ async function main() {
164
+ const args = parseArgs(process.argv.slice(2));
165
+ const target = String(args.target ?? "");
166
+ if (target !== "agent" && target !== "conversation") usage();
167
+
168
+ const dryRun = args["dry-run"] === true;
169
+ const show = args.show === true;
170
+ const allowOtherAgent = args["allow-other-agent"] === true;
171
+ const confirmSystemReplacement = args["confirm-system-replacement"] === true;
172
+ const configuredBaseUrl = args["base-url"] || process.env.LETTA_BASE_URL;
173
+ const apiKey = process.env.LETTA_API_KEY;
174
+ const mergeModelSettings = args["merge-model-settings"] === true;
175
+ const mergeCompactionSettings = args["merge-compaction-settings"] === true;
176
+ const updateFlagNames = [
177
+ "name",
178
+ "description",
179
+ "model",
180
+ "context-window-limit",
181
+ "model-settings-file",
182
+ "merge-model-settings",
183
+ "system-file",
184
+ "compaction-settings-file",
185
+ "merge-compaction-settings",
186
+ ].filter((key) => args[key] !== undefined);
187
+
188
+ if (show) {
189
+ const invalidFlagNames = [
190
+ ...updateFlagNames,
191
+ ...(dryRun ? ["dry-run"] : []),
192
+ ...(confirmSystemReplacement ? ["confirm-system-replacement"] : []),
193
+ ];
194
+ if (invalidFlagNames.length > 0) {
195
+ throw new Error(
196
+ `--show cannot be combined with --${invalidFlagNames.join(", --")}`,
197
+ );
198
+ }
199
+ }
200
+
201
+ if (
202
+ confirmSystemReplacement &&
203
+ (dryRun || args["system-file"] === undefined)
204
+ ) {
205
+ throw new Error(
206
+ "--confirm-system-replacement is only valid for live --system-file writes",
207
+ );
208
+ }
209
+
210
+ if (mergeModelSettings && !args["model-settings-file"]) {
211
+ throw new Error("--merge-model-settings requires --model-settings-file");
212
+ }
213
+ if (mergeCompactionSettings && !args["compaction-settings-file"]) {
214
+ throw new Error(
215
+ "--merge-compaction-settings requires --compaction-settings-file",
216
+ );
217
+ }
218
+
219
+ const id =
220
+ target === "agent"
221
+ ? requireString(
222
+ args["agent-id"] || process.env.AGENT_ID,
223
+ "Set AGENT_ID or pass --agent-id",
224
+ )
225
+ : requireString(
226
+ args["conversation-id"] || process.env.CONVERSATION_ID,
227
+ "Set CONVERSATION_ID or pass --conversation-id",
228
+ );
229
+
230
+ if (
231
+ target === "conversation" &&
232
+ (args.name !== undefined ||
233
+ args.description !== undefined ||
234
+ args["system-file"] !== undefined ||
235
+ args["compaction-settings-file"] !== undefined)
236
+ ) {
237
+ throw new Error(
238
+ "name, description, system, and compaction settings are agent-level only",
239
+ );
240
+ }
241
+
242
+ if (
243
+ !dryRun &&
244
+ args["system-file"] !== undefined &&
245
+ !confirmSystemReplacement
246
+ ) {
247
+ throw new Error(
248
+ "Live --system-file writes require --confirm-system-replacement; use --dry-run to preview without confirmation",
249
+ );
250
+ }
251
+
252
+ const needsCurrent = show || mergeModelSettings || mergeCompactionSettings;
253
+ const usesServer = needsCurrent || !dryRun;
254
+ const currentId =
255
+ target === "agent" ? process.env.AGENT_ID : process.env.CONVERSATION_ID;
256
+ const currentIdName = target === "agent" ? "AGENT_ID" : "CONVERSATION_ID";
257
+ const idFlagName = target === "agent" ? "agent-id" : "conversation-id";
258
+ const targetMismatch = Boolean(currentId && id !== currentId);
259
+
260
+ if (allowOtherAgent && (!usesServer || !targetMismatch)) {
261
+ throw new Error(
262
+ "--allow-other-agent is only valid when a server operation targets a different agent/conversation than the current env ID",
263
+ );
264
+ }
265
+ if (targetMismatch && usesServer && !allowOtherAgent) {
266
+ throw new Error(
267
+ `Refusing to target ${target} ${id} because ${currentIdName} is ${currentId}. Pass --allow-other-agent only after verifying the cross-${target} operation is intentional. If ${currentIdName} is unset, explicit --${idFlagName} remains allowed for out-of-band recovery.`,
268
+ );
269
+ }
270
+
271
+ if (usesServer && !configuredBaseUrl) {
272
+ throw new Error(
273
+ "Set LETTA_BASE_URL or pass --base-url so the request targets the current Letta server",
274
+ );
275
+ }
276
+ const baseUrl = String(configuredBaseUrl ?? "").replace(/\/$/, "");
277
+
278
+ if (usesServer && !apiKey) {
279
+ throw new Error(
280
+ show
281
+ ? "Set LETTA_API_KEY for --show"
282
+ : needsCurrent && dryRun
283
+ ? "Set LETTA_API_KEY for merge-preserving dry runs"
284
+ : "Set LETTA_API_KEY",
285
+ );
286
+ }
287
+ const headers = {
288
+ Authorization: `Bearer ${apiKey}`,
289
+ "Content-Type": "application/json",
290
+ };
291
+
292
+ let current: JsonObject = {};
293
+ if (needsCurrent) {
294
+ current = await requestJson(
295
+ `${baseUrl}/v1/${target === "agent" ? "agents" : "conversations"}/${id}`,
296
+ { headers },
297
+ );
298
+ }
299
+
300
+ if (show) {
301
+ console.log(JSON.stringify(safeServerFields(current), null, 2));
302
+ return;
303
+ }
304
+
305
+ const patch: JsonObject = {};
306
+
307
+ const name = optionalNonEmptyString(args, "name");
308
+ if (name !== undefined) patch.name = name;
309
+ const description = optionalNonEmptyString(args, "description");
310
+ if (description !== undefined) patch.description = description;
311
+
312
+ if (args.model) patch.model = String(args.model);
313
+ if (args["context-window-limit"] !== undefined) {
314
+ patch.context_window_limit = parseContextWindow(
315
+ String(args["context-window-limit"]),
316
+ );
317
+ }
318
+
319
+ if (args["model-settings-file"]) {
320
+ const next = readJsonObject(String(args["model-settings-file"]));
321
+ patch.model_settings = mergeModelSettings
322
+ ? {
323
+ ...((current.model_settings as JsonObject | undefined) ?? {}),
324
+ ...next,
325
+ }
326
+ : next;
327
+ }
328
+
329
+ if (args["system-file"]) {
330
+ const systemPath = String(args["system-file"]);
331
+ if (!existsSync(systemPath))
332
+ throw new Error(`File not found: ${systemPath}`);
333
+ const system = readFileSync(systemPath, "utf8");
334
+ if (!system.trim()) throw new Error("System prompt file is empty");
335
+ patch.system = system;
336
+ }
337
+
338
+ if (args["compaction-settings-file"]) {
339
+ const next = readJsonObject(String(args["compaction-settings-file"]));
340
+ patch.compaction_settings = mergeCompactionSettings
341
+ ? {
342
+ ...((current.compaction_settings as JsonObject | undefined) ?? {}),
343
+ ...next,
344
+ }
345
+ : next;
346
+ }
347
+
348
+ if (Object.keys(patch).length === 0)
349
+ throw new Error("No settings requested; pass at least one update flag");
350
+
351
+ if (dryRun) {
352
+ console.log(
353
+ JSON.stringify(
354
+ {
355
+ target,
356
+ id,
357
+ preview: needsCurrent
358
+ ? "effective_merged_patch"
359
+ : "offline_partial_patch",
360
+ patch,
361
+ },
362
+ null,
363
+ 2,
364
+ ),
365
+ );
366
+ return;
367
+ }
368
+
369
+ const updated = await requestJson(
370
+ `${baseUrl}/v1/${target === "agent" ? "agents" : "conversations"}/${id}`,
371
+ {
372
+ method: "PATCH",
373
+ headers,
374
+ body: JSON.stringify(patch),
375
+ },
376
+ );
377
+
378
+ console.log(JSON.stringify(safeServerFields(updated), null, 2));
379
+ }
380
+
381
+ main().catch((error) => {
382
+ console.error(error instanceof Error ? error.message : String(error));
383
+ process.exit(1);
384
+ });
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env tsx
2
+ import { existsSync, readFileSync } from "node:fs";
3
+
4
+ type Args = Record<string, string | boolean>;
5
+
6
+ function parseArgs(argv: string[]): Args {
7
+ const out: Args = {};
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const arg = argv[i];
10
+ if (arg === "--help" || arg === "-h") {
11
+ usage();
12
+ }
13
+ if (!arg.startsWith("--")) {
14
+ throw new Error(`Unexpected positional argument: ${arg}`);
15
+ }
16
+ const key = arg.slice(2);
17
+ if (
18
+ [
19
+ "dry-run",
20
+ "prompt-acknowledgement",
21
+ "allow-other-agent",
22
+ "confirm-compaction-prompt",
23
+ ].includes(key)
24
+ ) {
25
+ out[key] = true;
26
+ continue;
27
+ }
28
+ const value = argv[++i];
29
+ if (!value || value.startsWith("--")) {
30
+ throw new Error(`Missing value for --${key}`);
31
+ }
32
+ out[key] = value;
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function usage(): never {
38
+ console.error(`Usage:
39
+ npx tsx scripts/update-compaction-prompt.ts --prompt-file prompt.txt [options]
40
+
41
+ Options:
42
+ --agent-id <id> Defaults to AGENT_ID
43
+ --base-url <url> Defaults to LETTA_BASE_URL; required
44
+ --mode <mode> sliding_window | all | self_compact_sliding_window | self_compact_all
45
+ --model <provider/model> Optional summarizer model
46
+ --clip-chars <int|null> Optional summary character cap
47
+ --sliding-window-percentage <n> Optional fraction for sliding-window modes
48
+ --prompt-acknowledgement Set prompt_acknowledgement=true
49
+ --allow-other-agent Permit server operations on a different agent than AGENT_ID
50
+ --confirm-compaction-prompt Required for live non-dry-run compaction prompt writes
51
+ --dry-run Fetch current settings and print patch body without PATCHing
52
+ `);
53
+ process.exit(2);
54
+ }
55
+
56
+ async function requestJson(url: string, init: RequestInit) {
57
+ const response = await fetch(url, init);
58
+ const text = await response.text();
59
+ let body: unknown = null;
60
+ try {
61
+ body = text ? JSON.parse(text) : null;
62
+ } catch {
63
+ body = text;
64
+ }
65
+ if (!response.ok) {
66
+ throw new Error(
67
+ `HTTP ${response.status} ${response.statusText}: ${typeof body === "string" ? body : JSON.stringify(body)}`,
68
+ );
69
+ }
70
+ return body as Record<string, unknown>;
71
+ }
72
+
73
+ const VALID_MODES = new Set([
74
+ "sliding_window",
75
+ "all",
76
+ "self_compact_sliding_window",
77
+ "self_compact_all",
78
+ ]);
79
+
80
+ function parseIntegerOption(name: string, value: string): number {
81
+ if (!/^\d+$/.test(value)) {
82
+ throw new Error(`--${name} must be a non-negative integer or null`);
83
+ }
84
+ return Number.parseInt(value, 10);
85
+ }
86
+
87
+ function parseNumberOption(name: string, value: string): number {
88
+ const parsed = Number.parseFloat(value);
89
+ if (!Number.isFinite(parsed)) {
90
+ throw new Error(`--${name} must be a number`);
91
+ }
92
+ return parsed;
93
+ }
94
+
95
+ async function main() {
96
+ const args = parseArgs(process.argv.slice(2));
97
+ if (!args["prompt-file"]) usage();
98
+
99
+ const dryRun = args["dry-run"] === true;
100
+ const allowOtherAgent = args["allow-other-agent"] === true;
101
+ const confirmCompactionPrompt = args["confirm-compaction-prompt"] === true;
102
+
103
+ if (confirmCompactionPrompt && dryRun) {
104
+ throw new Error(
105
+ "--confirm-compaction-prompt is only valid for live compaction prompt writes",
106
+ );
107
+ }
108
+ if (!dryRun && !confirmCompactionPrompt) {
109
+ throw new Error(
110
+ "Live compaction prompt writes require --confirm-compaction-prompt; use --dry-run to preview without confirmation",
111
+ );
112
+ }
113
+
114
+ const agentId = String(args["agent-id"] || process.env.AGENT_ID || "");
115
+ if (!agentId) throw new Error("Set AGENT_ID or pass --agent-id");
116
+
117
+ const currentAgentId = process.env.AGENT_ID;
118
+ const targetMismatch = Boolean(currentAgentId && agentId !== currentAgentId);
119
+ if (allowOtherAgent && !targetMismatch) {
120
+ throw new Error(
121
+ "--allow-other-agent is only valid when targeting a different agent than AGENT_ID",
122
+ );
123
+ }
124
+ if (targetMismatch && !allowOtherAgent) {
125
+ throw new Error(
126
+ `Refusing to target agent ${agentId} because AGENT_ID is ${currentAgentId}. Pass --allow-other-agent only after verifying the cross-agent operation is intentional. If AGENT_ID is unset, explicit --agent-id remains allowed for out-of-band recovery.`,
127
+ );
128
+ }
129
+
130
+ const apiKey = process.env.LETTA_API_KEY;
131
+ if (!apiKey) {
132
+ throw new Error(
133
+ dryRun
134
+ ? "Set LETTA_API_KEY for merge-preserving dry runs"
135
+ : "Set LETTA_API_KEY",
136
+ );
137
+ }
138
+
139
+ const configuredBaseUrl = args["base-url"] || process.env.LETTA_BASE_URL;
140
+ if (!configuredBaseUrl) {
141
+ throw new Error(
142
+ "Set LETTA_BASE_URL or pass --base-url so the request targets the current Letta server",
143
+ );
144
+ }
145
+ const baseUrl = String(configuredBaseUrl).replace(/\/$/, "");
146
+ const promptFile = String(args["prompt-file"]);
147
+ if (!existsSync(promptFile)) {
148
+ throw new Error(`Prompt file not found: ${promptFile}`);
149
+ }
150
+ const prompt = readFileSync(promptFile, "utf8");
151
+ if (!prompt.trim()) throw new Error("Prompt file is empty");
152
+
153
+ const headers = {
154
+ Authorization: `Bearer ${apiKey}`,
155
+ "Content-Type": "application/json",
156
+ };
157
+ const currentAgent = await requestJson(`${baseUrl}/v1/agents/${agentId}`, {
158
+ headers,
159
+ });
160
+ const currentSettings = (currentAgent.compaction_settings ?? {}) as Record<
161
+ string,
162
+ unknown
163
+ >;
164
+
165
+ const nextSettings: Record<string, unknown> = { ...currentSettings, prompt };
166
+
167
+ if (args.mode) {
168
+ const mode = String(args.mode);
169
+ if (!VALID_MODES.has(mode)) {
170
+ throw new Error(`Invalid --mode: ${mode}`);
171
+ }
172
+ nextSettings.mode = mode;
173
+ }
174
+ if (args.model) nextSettings.model = args.model;
175
+ if (args["prompt-acknowledgement"])
176
+ nextSettings.prompt_acknowledgement = true;
177
+ if (args["clip-chars"] !== undefined) {
178
+ const raw = String(args["clip-chars"]);
179
+ nextSettings.clip_chars =
180
+ raw === "null" ? null : parseIntegerOption("clip-chars", raw);
181
+ }
182
+ if (args["sliding-window-percentage"] !== undefined) {
183
+ nextSettings.sliding_window_percentage = parseNumberOption(
184
+ "sliding-window-percentage",
185
+ String(args["sliding-window-percentage"]),
186
+ );
187
+ }
188
+
189
+ const patch = { compaction_settings: nextSettings };
190
+
191
+ if (dryRun) {
192
+ console.log(
193
+ JSON.stringify(
194
+ {
195
+ agent_id: agentId,
196
+ preview: "effective_merged_patch",
197
+ patch,
198
+ },
199
+ null,
200
+ 2,
201
+ ),
202
+ );
203
+ return;
204
+ }
205
+
206
+ const updated = await requestJson(`${baseUrl}/v1/agents/${agentId}`, {
207
+ method: "PATCH",
208
+ headers,
209
+ body: JSON.stringify(patch),
210
+ });
211
+
212
+ console.log(
213
+ JSON.stringify(
214
+ {
215
+ id: updated.id,
216
+ compaction_settings: updated.compaction_settings,
217
+ },
218
+ null,
219
+ 2,
220
+ ),
221
+ );
222
+ }
223
+
224
+ main().catch((error) => {
225
+ console.error(error instanceof Error ? error.message : String(error));
226
+ process.exit(1);
227
+ });