diff --git a/scripts/source-file-size-baseline.json b/scripts/source-file-size-baseline.json index f5086c82f3..97f9fb5ea3 100644 --- a/scripts/source-file-size-baseline.json +++ b/scripts/source-file-size-baseline.json @@ -31,9 +31,9 @@ "src/mods/package-installer.test.ts": 1248, "src/mods/package-installer.ts": 1201, "src/mods/package-registry.ts": 1033, - "src/permissions/checker.ts": 1009, + "src/permissions/checker.ts": 1005, "src/permissions/read-only-shell.test.ts": 1303, - "src/permissions/read-only-shell.ts": 2009, + "src/permissions/read-only-shell.ts": 1990, "src/providers/chatgpt-usage-service.ts": 1112, "src/settings-manager.test.ts": 1669, "src/settings-manager.ts": 2112, diff --git a/src/agent/memory-git-hooks.ts b/src/agent/memory-git-hooks.ts index eb98fb9d4a..b97826d677 100644 --- a/src/agent/memory-git-hooks.ts +++ b/src/agent/memory-git-hooks.ts @@ -9,7 +9,13 @@ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { + DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT, + MEMORY_TOKEN_LIMIT_POLICY_PATH, + MEMORY_TOKEN_LIMIT_UPDATE_ENV, +} from "@/agent/memory-token-limit"; import { debugLog } from "@/utils/debug"; +import { SYSTEM_PROMPT_BYTES_PER_TOKEN } from "@/utils/system-prompt-size"; /** * Bash pre-commit hook that validates frontmatter in memory .md files. @@ -23,6 +29,7 @@ import { debugLog } from "@/utils/debug"; * - Only allowed agent-editable key: description * - Legacy key 'limit' is tolerated for backward compatibility * - read_only may exist (from server) but agent must not change it + * - The staged system/ context must stay below the configured token limit */ export const PRE_COMMIT_HOOK_SCRIPT = `#!/usr/bin/env bash # Validate frontmatter in staged memory .md files @@ -31,6 +38,10 @@ export const PRE_COMMIT_HOOK_SCRIPT = `#!/usr/bin/env bash AGENT_EDITABLE_KEYS="description" PROTECTED_KEYS="read_only" ALL_KNOWN_KEYS="description read_only limit" +DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT=${DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT} +SYSTEM_PROMPT_BYTES_PER_TOKEN=${SYSTEM_PROMPT_BYTES_PER_TOKEN} +MEMORY_TOKEN_LIMIT_POLICY_PATH="${MEMORY_TOKEN_LIMIT_POLICY_PATH}" +MEMORY_TOKEN_LIMIT_UPDATE_ENV="${MEMORY_TOKEN_LIMIT_UPDATE_ENV}" errors="" # Skills must always be directories: skills//SKILL.md @@ -156,8 +167,49 @@ for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E '^(memor fi done +# The tracked token-limit policy can only be changed through the approval-gated +# CLI command. This marker is an anti-accident guard, not a security boundary. +if ! git diff --cached --quiet -- "$MEMORY_TOKEN_LIMIT_POLICY_PATH"; then + if [ "\${!MEMORY_TOKEN_LIMIT_UPDATE_ENV:-}" != "1" ]; then + errors="$errors\\n memory policy is protected; use: letta memory token-limit set " + fi +fi + +# Read the policy from the staged snapshot so unstaged content cannot affect +# the commit. +system_prompt_token_limit=$DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT +if git cat-file -e ":$MEMORY_TOKEN_LIMIT_POLICY_PATH" 2>/dev/null; then + policy_content=$(git show ":$MEMORY_TOKEN_LIMIT_POLICY_PATH") + policy_lines=$(printf '%s\\n' "$policy_content" | grep -cve '^[[:space:]]*$' || true) + policy_value=$(printf '%s\\n' "$policy_content" | sed -nE 's/^[[:space:]]*system_prompt_token_limit:[[:space:]]*([0-9]+)[[:space:]]*$/\\1/p') + if [ "$policy_lines" -ne 1 ] || [ -z "$policy_value" ] || [ "$policy_value" -le 0 ] 2>/dev/null; then + errors="$errors\\n $MEMORY_TOKEN_LIMIT_POLICY_PATH: expected 'system_prompt_token_limit: '" + else + system_prompt_token_limit=$policy_value + fi +fi + +# Estimate the complete staged system prompt with the same bytes-per-token +# heuristic and current-layout preference as the letta memory tokens command. +system_pathspec='system/*.md' +if ! git ls-files -- "$system_pathspec" | grep -Ev '(^|/)\\.' | grep -q .; then + system_pathspec='memory/system/*.md' +fi +system_prompt_tokens=0 +while IFS= read -r -d '' file; do + case "$file" in */.*) continue ;; esac + bytes=$(git cat-file -s ":$file") + file_tokens=$(( (bytes + SYSTEM_PROMPT_BYTES_PER_TOKEN - 1) / SYSTEM_PROMPT_BYTES_PER_TOKEN )) + system_prompt_tokens=$((system_prompt_tokens + file_tokens)) +done < <(git ls-files -z -- "$system_pathspec") + +if [ "$system_prompt_tokens" -ge "$system_prompt_token_limit" ]; then + errors="$errors\\n system prompt is approximately $system_prompt_tokens tokens; it must be less than $system_prompt_token_limit tokens" + errors="$errors\\n Reduce files under system/, or request approval for: letta memory token-limit set " +fi + if [ -n "$errors" ]; then - echo "Frontmatter validation failed:" + echo "MemFS pre-commit validation failed:" echo -e "$errors" exit 1 fi diff --git a/src/agent/memory-git.precommit.test.ts b/src/agent/memory-git.precommit.test.ts index 260a66a0c9..b40a33c86f 100644 --- a/src/agent/memory-git.precommit.test.ts +++ b/src/agent/memory-git.precommit.test.ts @@ -13,6 +13,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PRE_COMMIT_HOOK_SCRIPT } from "@/agent/memory-git-hooks"; +import { + DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT, + formatMemoryTokenLimit, + MEMORY_TOKEN_LIMIT_POLICY_PATH, + MEMORY_TOKEN_LIMIT_UPDATE_ENV, +} from "@/agent/memory-token-limit"; let tempDir: string; @@ -24,11 +30,11 @@ const GIT_ENV = { GIT_COMMITTER_EMAIL: "test@test.com", }; -function git(args: string): string { +function git(args: string, env: NodeJS.ProcessEnv = GIT_ENV): string { return execSync(`git ${args}`, { cwd: tempDir, encoding: "utf-8", - env: GIT_ENV, + env, }); } @@ -39,9 +45,12 @@ function writeAndStage(relativePath: string, content: string): void { git(`add ${relativePath}`); } -function tryCommit(): { success: boolean; output: string } { +function tryCommit(env: NodeJS.ProcessEnv = GIT_ENV): { + success: boolean; + output: string; +} { try { - const output = git('commit -m "test"'); + const output = git('commit -m "test"', env); return { success: true, output }; } catch (err) { const output = @@ -52,6 +61,14 @@ function tryCommit(): { success: boolean; output: string } { } } +function installPolicy(limit: number): void { + writeAndStage(MEMORY_TOKEN_LIMIT_POLICY_PATH, formatMemoryTokenLimit(limit)); + git('commit -m "set policy"', { + ...GIT_ENV, + [MEMORY_TOKEN_LIMIT_UPDATE_ENV]: "1", + }); +} + /** Valid frontmatter for convenience */ const VALID_FM = "---\ndescription: Test block\n---\n\n"; @@ -309,3 +326,95 @@ describe("pre-commit hook: non-memory files", () => { expect(result.success).toBe(true); }); }); + +describe("pre-commit hook: system prompt token limit", () => { + const contentWithEstimatedTokens = (tokens: number): string => { + const frontmatter = "---\ndescription: Large block\n---\n\n"; + return `${frontmatter}${"x".repeat(tokens * 4 - Buffer.byteLength(frontmatter))}`; + }; + + test("allows a staged system prompt below the default limit", () => { + writeAndStage( + "system/context.md", + contentWithEstimatedTokens(DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT - 1), + ); + expect(tryCommit().success).toBe(true); + }); + + test("rejects a staged system prompt equal to the default limit", () => { + writeAndStage( + "system/context.md", + contentWithEstimatedTokens(DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT), + ); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain( + `must be less than ${DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT} tokens`, + ); + }); + + test("enforces the default limit for legacy memory/system repos", () => { + writeAndStage( + "memory/system/context.md", + contentWithEstimatedTokens(DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT), + ); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain( + `must be less than ${DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT} tokens`, + ); + }); + + test("uses the configured per-repo limit", () => { + installPolicy(100); + writeAndStage("system/context.md", contentWithEstimatedTokens(100)); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain("must be less than 100 tokens"); + }); + + test("sums nested system files", () => { + installPolicy(100); + writeAndStage("system/human/one.md", contentWithEstimatedTokens(50)); + writeAndStage("system/project/two.md", contentWithEstimatedTokens(50)); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain("approximately 100 tokens"); + }); + + test("estimates the staged snapshot rather than unstaged content", () => { + writeAndStage("system/context.md", contentWithEstimatedTokens(100)); + writeFileSync( + join(tempDir, "system/context.md"), + contentWithEstimatedTokens(DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT), + "utf-8", + ); + expect(tryCommit().success).toBe(true); + }); + + test("rejects changing the tracked policy without approval", () => { + installPolicy(100); + writeAndStage(MEMORY_TOKEN_LIMIT_POLICY_PATH, formatMemoryTokenLimit(200)); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain("memory policy is protected"); + }); + + test("rejects deleting the tracked policy without approval", () => { + installPolicy(100); + git(`rm ${MEMORY_TOKEN_LIMIT_POLICY_PATH}`); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain("memory policy is protected"); + }); + + test("rejects an invalid tracked policy", () => { + writeAndStage( + MEMORY_TOKEN_LIMIT_POLICY_PATH, + "system_prompt_token_limit: nope\n", + ); + const result = tryCommit(); + expect(result.success).toBe(false); + expect(result.output).toContain("positive integer"); + }); +}); diff --git a/src/agent/memory-token-limit.ts b/src/agent/memory-token-limit.ts new file mode 100644 index 0000000000..4cf5347c2c --- /dev/null +++ b/src/agent/memory-token-limit.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT = 20_000; +export const MEMORY_TOKEN_LIMIT_POLICY_PATH = "system/.letta-policy.yml"; +export const MEMORY_TOKEN_LIMIT_UPDATE_ENV = "LETTA_MEMORY_TOKEN_LIMIT_UPDATE"; + +const POLICY_KEY = "system_prompt_token_limit"; +const POLICY_LINE = /^[ \t]*system_prompt_token_limit:[ \t]*([0-9]+)[ \t]*$/; + +function parseTokenLimit(content: string): number { + const value = content.trim().match(POLICY_LINE)?.[1]; + const limit = value ? Number(value) : Number.NaN; + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error( + `Memory policy must contain '${POLICY_KEY}: '.`, + ); + } + return limit; +} + +export function formatMemoryTokenLimit(limit: number): string { + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error("System prompt token limit must be a positive integer."); + } + return `${POLICY_KEY}: ${limit}\n`; +} + +export function readMemoryTokenLimit(memoryDir: string): { + limit: number; + source: "default" | typeof MEMORY_TOKEN_LIMIT_POLICY_PATH; +} { + const policyPath = join(memoryDir, MEMORY_TOKEN_LIMIT_POLICY_PATH); + if (!existsSync(policyPath)) { + return { limit: DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT, source: "default" }; + } + return { + limit: parseTokenLimit(readFileSync(policyPath, "utf8")), + source: MEMORY_TOKEN_LIMIT_POLICY_PATH, + }; +} diff --git a/src/agent/prompts/letta.md b/src/agent/prompts/letta.md index ad9bb2d5e6..b2311b705e 100644 --- a/src/agent/prompts/letta.md +++ b/src/agent/prompts/letta.md @@ -56,7 +56,7 @@ There are two ways to change memory: - **The `memory` tool (shorthand).** Use it for small, targeted edits. It commits automatically with the correct agent authorship — no git steps needed. - **Direct file edits (full control).** For larger changes — restructuring directories, rewriting several blocks — edit the projected files directly, then commit: -Memory markdown files must start with YAML frontmatter containing a non-empty `description:` field. The `memory` and `memory_apply_patch` tools add and preserve this automatically; when using raw file edits, preserve existing frontmatter or add it before committing. The MemFS pre-commit hook enforces this requirement, rejects unknown keys, and prevents changes to protected `read_only` files. Skill `SKILL.md` files use their own skill frontmatter format. +Memory markdown files must start with YAML frontmatter containing a non-empty `description:` field. The `memory` and `memory_apply_patch` tools add and preserve this automatically; when using raw file edits, preserve existing frontmatter or add it before committing. The MemFS pre-commit hook enforces this requirement, rejects unknown keys, prevents changes to protected `read_only` files, and keeps the staged `system/` context below its configured token limit (20,000 by default). Use `letta memory tokens` to measure it and the approval-gated `letta memory token-limit set ` command to change it. Skill `SKILL.md` files use their own skill frontmatter format. `$AGENT_NAME` is normally populated when the runtime knows the current agent name, but direct shell environments can still miss it. Use a non-empty author name fallback when committing directly. diff --git a/src/agent/prompts/letta_local_memfs.md b/src/agent/prompts/letta_local_memfs.md index 0bbbe40cfe..983a6e2767 100644 --- a/src/agent/prompts/letta_local_memfs.md +++ b/src/agent/prompts/letta_local_memfs.md @@ -50,7 +50,7 @@ There are two ways to change memory: - **The `memory` tool (shorthand).** Use it for small, targeted edits. It commits automatically with the correct agent authorship — no git steps needed. - **Direct file edits (full control).** For larger changes — restructuring directories, rewriting several blocks — edit the projected files directly, then commit: -Memory markdown files must start with YAML frontmatter containing a non-empty `description:` field. The `memory` and `memory_apply_patch` tools add and preserve this automatically; when using raw file edits, preserve existing frontmatter or add it before committing. The MemFS pre-commit hook enforces this requirement, rejects unknown keys, and prevents changes to protected `read_only` files. Skill `SKILL.md` files use their own skill frontmatter format. +Memory markdown files must start with YAML frontmatter containing a non-empty `description:` field. The `memory` and `memory_apply_patch` tools add and preserve this automatically; when using raw file edits, preserve existing frontmatter or add it before committing. The MemFS pre-commit hook enforces this requirement, rejects unknown keys, prevents changes to protected `read_only` files, and keeps the staged `system/` context below its configured token limit (20,000 by default). Use `letta memory tokens` to measure it and the approval-gated `letta memory token-limit set ` command to change it. Skill `SKILL.md` files use their own skill frontmatter format. `$AGENT_NAME` is normally populated when the runtime knows the current agent name, but direct shell environments can still miss it. Use a non-empty author name fallback when committing directly. diff --git a/src/cli/subcommands/memory-token-limit.test.ts b/src/cli/subcommands/memory-token-limit.test.ts new file mode 100644 index 0000000000..0578f36f32 --- /dev/null +++ b/src/cli/subcommands/memory-token-limit.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PRE_COMMIT_HOOK_SCRIPT } from "@/agent/memory-git-hooks"; +import { + DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT, + MEMORY_TOKEN_LIMIT_POLICY_PATH, +} from "@/agent/memory-token-limit"; +import { runMemorySubcommand } from "@/cli/subcommands/memory"; + +describe("letta memory token-limit", () => { + let memoryDir: string; + let previousMemoryDir: string | undefined; + const previousGitEnv: Record = {}; + + function git(args: string[]): string { + return execFileSync("git", args, { + cwd: memoryDir, + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } + + beforeEach(() => { + memoryDir = mkdtempSync(join(tmpdir(), "memory-token-limit-")); + previousMemoryDir = process.env.MEMORY_DIR; + process.env.MEMORY_DIR = memoryDir; + for (const key of [ + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + ]) { + previousGitEnv[key] = process.env[key]; + } + process.env.GIT_AUTHOR_NAME = "Test Agent"; + process.env.GIT_AUTHOR_EMAIL = "test@example.com"; + process.env.GIT_COMMITTER_NAME = "Test Agent"; + process.env.GIT_COMMITTER_EMAIL = "test@example.com"; + + git(["init"]); + writeFileSync(join(memoryDir, ".gitkeep"), ""); + git(["add", ".gitkeep"]); + git(["commit", "-m", "init"]); + const hookPath = join(memoryDir, ".git", "hooks", "pre-commit"); + writeFileSync(hookPath, PRE_COMMIT_HOOK_SCRIPT, { mode: 0o755 }); + }); + + afterEach(() => { + rmSync(memoryDir, { recursive: true, force: true }); + if (previousMemoryDir === undefined) delete process.env.MEMORY_DIR; + else process.env.MEMORY_DIR = previousMemoryDir; + for (const [key, value] of Object.entries(previousGitEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + test("gets the default token limit", async () => { + const output: string[] = []; + const log = spyOn(console, "log").mockImplementation((value) => { + output.push(String(value)); + }); + try { + expect(await runMemorySubcommand(["token-limit", "get"])).toBe(0); + expect(JSON.parse(output.join("\n"))).toMatchObject({ + limit: DEFAULT_SYSTEM_PROMPT_TOKEN_LIMIT, + source: "default", + }); + } finally { + log.mockRestore(); + } + }); + + test("sets and commits the protected token limit", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runMemorySubcommand(["token-limit", "set", "30000"])).toBe( + 0, + ); + } finally { + log.mockRestore(); + } + + expect( + readFileSync(join(memoryDir, MEMORY_TOKEN_LIMIT_POLICY_PATH), "utf8"), + ).toBe("system_prompt_token_limit: 30000\n"); + expect(git(["log", "-1", "--pretty=%s"])).toBe( + "config: set system prompt token limit to 30000", + ); + expect(git(["status", "--porcelain"])).toBe(""); + }); + + test("sets the limit without committing unrelated changes", async () => { + const systemDir = join(memoryDir, "system"); + mkdirSync(systemDir, { recursive: true }); + const contextPath = join(systemDir, "context.md"); + writeFileSync(contextPath, "---\ndescription: Context\n---\n\nOriginal.\n"); + git(["add", "system/context.md"]); + git(["commit", "-m", "add context"]); + writeFileSync(contextPath, "---\ndescription: Context\n---\n\nModified.\n"); + + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await runMemorySubcommand(["token-limit", "set", "30000"])).toBe( + 0, + ); + } finally { + log.mockRestore(); + } + + expect(git(["status", "--porcelain", "--", "system/context.md"])).not.toBe( + "", + ); + }); + + test("rejects a non-positive token limit", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await runMemorySubcommand(["token-limit", "set", "0"])).toBe(64); + } finally { + error.mockRestore(); + } + }); +}); diff --git a/src/cli/subcommands/memory-token-limit.ts b/src/cli/subcommands/memory-token-limit.ts new file mode 100644 index 0000000000..e943eeb3b4 --- /dev/null +++ b/src/cli/subcommands/memory-token-limit.ts @@ -0,0 +1,195 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + formatMemoryTokenLimit, + MEMORY_TOKEN_LIMIT_POLICY_PATH, + MEMORY_TOKEN_LIMIT_UPDATE_ENV, + readMemoryTokenLimit, +} from "@/agent/memory-token-limit"; +import { estimateSystemPromptSize } from "@/utils/system-prompt-size"; + +const USAGE_EXIT = 64; +const IO_EXIT = 65; + +interface MemoryTokenLimitInput { + operation?: string; + value?: string; + memoryDir?: string; + agentMemoryDir?: string; +} + +function resolveMemoryDir(input: MemoryTokenLimitInput): string | null { + const candidate = + input.memoryDir || process.env.MEMORY_DIR || input.agentMemoryDir; + return candidate ? resolve(candidate) : null; +} + +function runGit( + memoryDir: string, + args: string[], + allowPolicyUpdate = false, +): string { + return execFileSync("git", args, { + cwd: memoryDir, + encoding: "utf8", + env: allowPolicyUpdate + ? { ...process.env, [MEMORY_TOKEN_LIMIT_UPDATE_ENV]: "1" } + : process.env, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function parsePositiveInteger(value: string | undefined): number | null { + if (!value || !/^[0-9]+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function printResult( + limit: number, + source: string, + changed?: boolean, + commit?: string, +): void { + console.log( + JSON.stringify( + { + ...(changed === undefined ? {} : { changed }), + limit, + source, + ...(commit ? { commit } : {}), + }, + null, + 2, + ), + ); +} + +export async function runMemoryTokenLimitAction( + input: MemoryTokenLimitInput, +): Promise { + const memoryDir = resolveMemoryDir(input); + if (!memoryDir) { + console.error( + "Missing memory dir. Pass --memory-dir, set MEMORY_DIR, or pass --agent.", + ); + return USAGE_EXIT; + } + if (!existsSync(memoryDir)) { + console.error(`Memory directory does not exist: ${memoryDir}`); + return USAGE_EXIT; + } + + const operation = input.operation ?? "get"; + if (operation === "get") { + if (input.value !== undefined) { + console.error("The get operation does not accept a value."); + return USAGE_EXIT; + } + try { + const policy = readMemoryTokenLimit(memoryDir); + printResult(policy.limit, policy.source); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return IO_EXIT; + } + } + + if (operation !== "set") { + console.error(`Unknown memory token-limit operation: ${operation}`); + return USAGE_EXIT; + } + + const limit = parsePositiveInteger(input.value); + if (limit === null) { + console.error("System prompt token limit must be a positive integer."); + return USAGE_EXIT; + } + + try { + const estimate = estimateSystemPromptSize(memoryDir); + if (estimate.total >= limit) { + console.error( + `System prompt is approximately ${estimate.total} tokens; the configured limit must be greater than the current estimate.`, + ); + return USAGE_EXIT; + } + + runGit(memoryDir, ["rev-parse", "--is-inside-work-tree"]); + if ( + runGit(memoryDir, [ + "status", + "--porcelain", + "--", + MEMORY_TOKEN_LIMIT_POLICY_PATH, + ]) + ) { + console.error( + "The token-limit policy has uncommitted changes. Commit or discard them before changing the limit.", + ); + return IO_EXIT; + } + + const policyPath = join(memoryDir, MEMORY_TOKEN_LIMIT_POLICY_PATH); + const policyExisted = existsSync(policyPath); + mkdirSync(dirname(policyPath), { recursive: true }); + writeFileSync(policyPath, formatMemoryTokenLimit(limit), "utf8"); + runGit(memoryDir, ["add", "--", MEMORY_TOKEN_LIMIT_POLICY_PATH]); + + const staged = runGit(memoryDir, [ + "diff", + "--cached", + "--name-only", + "--", + MEMORY_TOKEN_LIMIT_POLICY_PATH, + ]); + if (!staged) { + printResult(limit, MEMORY_TOKEN_LIMIT_POLICY_PATH, false); + return 0; + } + + try { + runGit( + memoryDir, + [ + "commit", + "-m", + `config: set system prompt token limit to ${limit}`, + "--", + MEMORY_TOKEN_LIMIT_POLICY_PATH, + ], + true, + ); + } catch (error) { + try { + runGit(memoryDir, [ + "reset", + "HEAD", + "--", + MEMORY_TOKEN_LIMIT_POLICY_PATH, + ]); + if (policyExisted) { + runGit(memoryDir, ["checkout", "--", MEMORY_TOKEN_LIMIT_POLICY_PATH]); + } else { + rmSync(policyPath, { force: true }); + } + } catch { + // Preserve the original commit error; cleanup is best-effort. + } + throw error; + } + + printResult( + limit, + MEMORY_TOKEN_LIMIT_POLICY_PATH, + true, + runGit(memoryDir, ["rev-parse", "HEAD"]), + ); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return IO_EXIT; + } +} diff --git a/src/cli/subcommands/memory.ts b/src/cli/subcommands/memory.ts index 34154cd0bc..0fb181b7f6 100644 --- a/src/cli/subcommands/memory.ts +++ b/src/cli/subcommands/memory.ts @@ -5,6 +5,7 @@ import { parseArgs } from "node:util"; import { getScopedMemoryFilesystemRoot } from "@/agent/memory-filesystem"; import { getMemoryGitStatus, isGitRepo, pullMemory } from "@/agent/memory-git"; import { isLocalBackendEnvEnabled } from "@/backend/local/paths"; +import { runMemoryTokenLimitAction } from "./memory-token-limit"; import { runMemoryTokensAction } from "./memory-tokens"; function printUsage(): void { @@ -20,12 +21,15 @@ Usage: letta memory pull [--agent ] letta memory tokens [--memory-dir ] [--agent ] [--top ] [--format text|json] [--quiet] + letta memory token-limit get [--memory-dir ] [--agent ] + letta memory token-limit set [--memory-dir ] [--agent ] Notes: - Most actions require agent id via --agent or LETTA_AGENT_ID and output JSON. - \`tokens\` additionally accepts --memory-dir or $MEMORY_DIR; reports the - estimated token size of system/. Policy (whether a size is concerning) is - up to the caller. + estimated token size of system/. + - \`token-limit get\` reads memory policy. \`token-limit set\` is approval-gated and + commits the protected policy change. - Memory is git-backed. Use git commands for commit/push. Examples: @@ -35,6 +39,8 @@ Examples: letta memory export --agent agent-123 --out /tmp/letta-memory-agent-123 letta memory tokens letta memory tokens --memory-dir ~/.letta/agents/agent-123/memory --format json + letta memory token-limit get + letta memory token-limit set 30000 `.trim(), ); } @@ -153,6 +159,20 @@ export async function runMemorySubcommand(argv: string[]): Promise { }); } + if (action === "token-limit") { + if (parsed.positionals.length > 3) { + console.error("Too many arguments for memory token-limit."); + return 64; + } + const [, operation, value] = parsed.positionals; + return runMemoryTokenLimitAction({ + operation, + value, + memoryDir: parsed.values["memory-dir"], + agentMemoryDir: agentId ? getMemoryRoot(agentId) : undefined, + }); + } + if (!agentId) { console.error( "Missing agent id. Set LETTA_AGENT_ID or pass --agent/--agent-id.", diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index 790f6c8fd4..e28fced13b 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -109,18 +109,14 @@ interface ModPermissionCheckOptions { toolCallId?: string | null; } -function envFlagEnabled(name: string): boolean { - const value = process.env[name]; - if (!value) return false; - return value === "1" || value.toLowerCase() === "true"; -} +const envFlagEnabled = (name: string): boolean => + ["1", "true"].includes(process.env[name]?.toLowerCase() ?? ""); function isPermissionsV2Enabled(): boolean { const value = process.env.LETTA_PERMISSIONS_V2; if (!value) return true; return !(value === "0" || value.toLowerCase() === "false"); } - function shouldAttachTrace(result: PermissionCheckResult): boolean { if (envFlagEnabled("LETTA_PERMISSION_TRACE_ALL")) { return true; diff --git a/src/permissions/loader.ts b/src/permissions/loader.ts index 35fd9d072f..d4fb1f64ff 100644 --- a/src/permissions/loader.ts +++ b/src/permissions/loader.ts @@ -42,6 +42,11 @@ type PermissionCacheEntry = { signatures: Map; }; +const BUILTIN_ALWAYS_ASK_RULES = [ + "Bash(letta memory token-limit set:*)", + "Bash(letta memfs token-limit set:*)", +]; + const permissionCache = new Map(); const watchers = new Map(); @@ -241,7 +246,7 @@ export async function loadPermissions( allow: [], deny: [], ask: [], - alwaysAsk: [], + alwaysAsk: [...BUILTIN_ALWAYS_ASK_RULES], additionalDirectories: [], }; diff --git a/src/permissions/protected-memory-config.test.ts b/src/permissions/protected-memory-config.test.ts new file mode 100644 index 0000000000..ceaf223c38 --- /dev/null +++ b/src/permissions/protected-memory-config.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, test } from "bun:test"; +import { checkPermission } from "@/permissions/checker"; +import { loadPermissions } from "@/permissions/loader"; +import { permissionMode } from "@/permissions/mode"; +import { isReadOnlyShellCommand } from "@/permissions/read-only-shell"; + +afterEach(() => permissionMode.reset()); + +test("only declared token-limit reads are classified as read-only", () => { + expect( + isReadOnlyShellCommand( + "letta memory token-limit get --memory-dir /tmp/mem", + ), + ).toBe(true); + expect( + isReadOnlyShellCommand( + "letta memory token-limit set 30000 --memory-dir /tmp/mem", + ), + ).toBe(false); + expect(isReadOnlyShellCommand("letta agents token-limit get")).toBe(false); +}); + +test("the native alwaysAsk rule survives unrestricted mode", async () => { + permissionMode.setMode("unrestricted"); + const permissions = await loadPermissions("/Users/test/project"); + const result = checkPermission( + "Bash", + { command: "letta memory token-limit set 30000" }, + permissions, + "/Users/test/project", + ); + expect(result.decision).toBe("alwaysAsk"); + expect(result.matchedRule).toBe("Bash(letta memory token-limit set:*)"); +}); + +test("alwaysAsk normalizes executable paths and quoted words", async () => { + permissionMode.setMode("unrestricted"); + const permissions = await loadPermissions("/Users/test/project"); + for (const command of [ + "/usr/local/bin/letta memory token-limit set 30000", + '"/usr/local/bin/letta" "memory" "token-limit" "set" 30000', + ]) { + expect( + checkPermission("Bash", { command }, permissions, "/Users/test/project") + .decision, + ).toBe("alwaysAsk"); + } +}); + +test("unrelated shell text does not trigger token-limit approval", async () => { + permissionMode.setMode("unrestricted"); + const permissions = await loadPermissions("/Users/test/project"); + const result = checkPermission( + "Bash", + { command: "echo memory token-limit set" }, + permissions, + "/Users/test/project", + ); + expect(result.decision).toBe("allow"); +}); diff --git a/src/permissions/read-only-letta-cli.ts b/src/permissions/read-only-letta-cli.ts new file mode 100644 index 0000000000..a7f6197dd0 --- /dev/null +++ b/src/permissions/read-only-letta-cli.ts @@ -0,0 +1,33 @@ +const READ_ONLY_COMMAND_PATHS: Record = + { + memory: [ + ["status"], + ["help"], + ["backups"], + ["export"], + ["tokens"], + ["token-limit", "get"], + ], + memfs: [ + ["status"], + ["help"], + ["backups"], + ["export"], + ["tokens"], + ["token-limit", "get"], + ], + agents: [["list"], ["help"]], + messages: [["search"], ["list"], ["help"]], + }; + +export function isReadOnlyLettaCliInvocation(tokens: string[]): boolean { + const group = tokens[1]; + if (!group) return false; + + const commandArgs = tokens.slice(2); + return ( + READ_ONLY_COMMAND_PATHS[group]?.some((safePath) => + safePath.every((part, index) => commandArgs[index] === part), + ) ?? false + ); +} diff --git a/src/permissions/read-only-shell.ts b/src/permissions/read-only-shell.ts index db10f5d2d6..85dac3243f 100644 --- a/src/permissions/read-only-shell.ts +++ b/src/permissions/read-only-shell.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; import { isPathWithinRoots, normalizeMemoryPath } from "./memory-paths"; +import { isReadOnlyLettaCliInvocation } from "./read-only-letta-cli"; import { extractDashCArgument, isShellExecutor, @@ -174,15 +175,6 @@ const SAFE_MEMORY_COMMANDS = new Set([ "sleep", ]); -// letta CLI read-only subcommands: group -> allowed actions -const SAFE_LETTA_COMMANDS: Record> = { - memory: new Set(["status", "help", "backups", "export", "tokens"]), - // Legacy alias for `letta memory ...`. - memfs: new Set(["status", "help", "backups", "export", "tokens"]), - agents: new Set(["list", "help"]), - messages: new Set(["search", "list", "help"]), -}; - // gh CLI read-only commands: category -> allowed actions // null means any action is allowed for that category export const SAFE_GH_COMMANDS: Record | null> = { @@ -1182,18 +1174,7 @@ function isSafeSegment( } if (command === "letta") { - const group = tokens[1]; - if (!group) { - return false; - } - if (!(group in SAFE_LETTA_COMMANDS)) { - return false; - } - const action = tokens[2]; - if (!action) { - return false; - } - return SAFE_LETTA_COMMANDS[group]?.has(action) ?? false; + return isReadOnlyLettaCliInvocation(tokens); } if (command === "find") { diff --git a/src/permissions/shell-command-normalization.ts b/src/permissions/shell-command-normalization.ts index d6a81f9cec..3771048856 100644 --- a/src/permissions/shell-command-normalization.ts +++ b/src/permissions/shell-command-normalization.ts @@ -255,6 +255,14 @@ export function extractPrimaryShellCommand(command: string): string { return unwrapped.trim(); } +function normalizeShellWords(command: string): string { + const tokens = tokenizeShell(command); + const executable = tokens[0]; + if (!executable) return command.trim(); + tokens[0] = normalizeExecutableToken(executable); + return tokens.join(" "); +} + export function normalizeBashRulePayload(payload: string): string { const trimmed = payload.trim(); if (!trimmed) { @@ -266,7 +274,7 @@ export function normalizeBashRulePayload(payload: string): string { ? trimmed.slice(0, -2).trimEnd() : trimmed; const unwrapped = unwrapShellLauncherCommand(withoutWildcard); - const normalized = normalizeGitCommandPrefix(unwrapped); + const normalized = normalizeGitCommandPrefix(normalizeShellWords(unwrapped)); if (hasWildcardSuffix) { return `${normalized}:*`; diff --git a/src/skills/builtin/self-configuration/SKILL.md b/src/skills/builtin/self-configuration/SKILL.md index 2b09aa5195..947da4953d 100644 --- a/src/skills/builtin/self-configuration/SKILL.md +++ b/src/skills/builtin/self-configuration/SKILL.md @@ -106,6 +106,25 @@ cd "$MEMORY_DIR" && git add && git commit --author="$AGENT_NAME Do not use API system-prompt replacement for ordinary learning. That can clobber the compiled prompt. Edit memory instead. +### MemFS system prompt token limit + +MemFS memory repos enforce an estimated token limit for the complete staged `system/` context in their pre-commit hook. The default is strictly fewer than 20,000 tokens. This is separate from the model's context window: it limits durable in-context memory, not the total context available for messages and tool results. + +Measure the current working-tree estimate with the shared estimator: + +```bash +letta memory tokens --format json --quiet --memory-dir "$MEMORY_DIR" +``` + +The hook evaluates the staged Git snapshot, so stage the intended memory changes before comparing its result with the CLI estimate. Read or configure the tracked memory policy with: + +```bash +letta memory token-limit get --memory-dir "$MEMORY_DIR" +letta memory token-limit set 30000 --memory-dir "$MEMORY_DIR" +``` + +The configured value is exclusive: `20000` requires an estimate below 20,000 tokens. Prefer moving non-core material out of `system/` rather than raising the limit automatically. The setting lives in tracked `system/.letta-policy.yml`, syncs with the memory repo, and cannot be changed or removed by an ordinary commit. `token-limit set` always requires fresh human approval when invoked through an agent tool and creates the protected policy commit; never edit the policy file directly or bypass its hook. + ## Server-side agent and conversation settings Server fields control model execution and agent metadata. Use the agent endpoint for persistent defaults. Use the conversation endpoint for scoped experiments. diff --git a/src/skills/builtin/syncing-memory-filesystem/SKILL.md b/src/skills/builtin/syncing-memory-filesystem/SKILL.md index e155820275..286cead710 100644 --- a/src/skills/builtin/syncing-memory-filesystem/SKILL.md +++ b/src/skills/builtin/syncing-memory-filesystem/SKILL.md @@ -66,7 +66,16 @@ git -c "http.extraHeader=$AUTH_HEADER" clone "$LETTA_BASE_URL/v1/git// ## Pre-Commit Hook (Frontmatter Validation) -The harness installs a git pre-commit hook that validates `.md` files under `memory/` before each commit. This prevents pushes that the server would reject. +The harness installs a git pre-commit hook that validates `.md` files under `memory/` before each commit. This prevents pushes that the server would reject. It also estimates the complete staged context under `system/` and requires it to stay below 20,000 tokens by default, using the same heuristic as `letta memory tokens`. + +Count the current working-tree estimate and check the configured limit before committing: + +```bash +letta memory tokens --format json --quiet --memory-dir "$MEMORY_DIR" +letta memory token-limit get --memory-dir "$MEMORY_DIR" +``` + +The token count command measures the working tree, while the hook evaluates the staged Git snapshot. Before comparing them, ensure the intended files under `system/` are staged with no additional unstaged changes. The limit is exclusive: a configured value of `20000` requires fewer than 20,000 estimated tokens. **Rules:** - Every `.md` file must have YAML frontmatter (`---` header and closing `---`) diff --git a/src/utils/system-prompt-size.test.ts b/src/utils/system-prompt-size.test.ts index 5aaabdd626..872254ccfe 100644 --- a/src/utils/system-prompt-size.test.ts +++ b/src/utils/system-prompt-size.test.ts @@ -68,6 +68,26 @@ describe("estimateSystemPromptSize", () => { ]); }); + test("falls back to the legacy memory/system layout", () => { + mkdirSync(join(tmpRoot, "memory", "system"), { recursive: true }); + writeFileSync(join(tmpRoot, "memory", "system", "persona.md"), "abcdefgh"); + + const { total, files } = estimateSystemPromptSize(tmpRoot); + expect(total).toBe(2); + expect(files).toEqual([{ path: "memory/system/persona.md", tokens: 2 }]); + }); + + test("prefers current system blocks over the legacy layout", () => { + mkdirSync(join(tmpRoot, "system"), { recursive: true }); + mkdirSync(join(tmpRoot, "memory", "system"), { recursive: true }); + writeFileSync(join(tmpRoot, "system", "persona.md"), "abcd"); + writeFileSync(join(tmpRoot, "memory", "system", "persona.md"), "abcdefgh"); + + const { total, files } = estimateSystemPromptSize(tmpRoot); + expect(total).toBe(1); + expect(files).toEqual([{ path: "system/persona.md", tokens: 1 }]); + }); + test("walks nested directories", () => { mkdirSync(join(tmpRoot, "system", "project"), { recursive: true }); mkdirSync(join(tmpRoot, "system", "human", "prefs"), { recursive: true }); diff --git a/src/utils/system-prompt-size.ts b/src/utils/system-prompt-size.ts index 3ebb8bc21b..822297e8ca 100644 --- a/src/utils/system-prompt-size.ts +++ b/src/utils/system-prompt-size.ts @@ -62,19 +62,18 @@ function walkMarkdownFiles(dir: string): string[] { } /** - * Estimate total token usage of files under `/system/`, with a per-file breakdown. - * - * Returns { total: 0, files: [] } when `system/` does not exist (instead of throwing). + * Estimate total token usage under the current `system/` layout, falling back + * to legacy `memory/system/` repositories when no current-layout blocks exist. */ export function estimateSystemPromptSize( memoryDir: string, ): SystemPromptSizeEstimate { - const systemDir = join(memoryDir, "system"); - if (!existsSync(systemDir)) { - return { total: 0, files: [] }; - } - - const files = walkMarkdownFiles(systemDir).sort(); + const currentFiles = walkMarkdownFiles(join(memoryDir, "system")); + const files = ( + currentFiles.length > 0 + ? currentFiles + : walkMarkdownFiles(join(memoryDir, "memory", "system")) + ).sort(); const rows: FileEstimate[] = []; for (const filePath of files) {