Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/source-file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion src/agent/memory-git-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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/<name>/SKILL.md
Expand Down Expand Up @@ -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 <tokens>"
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: <positive integer>'"
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 <tokens>"
fi

if [ -n "$errors" ]; then
echo "Frontmatter validation failed:"
echo "MemFS pre-commit validation failed:"
echo -e "$errors"
exit 1
fi
Expand Down
117 changes: 113 additions & 4 deletions src/agent/memory-git.precommit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
});
}

Expand All @@ -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 =
Expand All @@ -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";

Expand Down Expand Up @@ -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");
});
});
41 changes: 41 additions & 0 deletions src/agent/memory-token-limit.ts
Original file line number Diff line number Diff line change
@@ -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}: <positive integer>'.`,
);
}
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,
};
}
2 changes: 1 addition & 1 deletion src/agent/prompts/letta.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tokens>` 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.

Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts/letta_local_memfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tokens>` 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.

Expand Down
Loading
Loading