From 7afc80de20f39b0c05fee6747e51a4db08149758 Mon Sep 17 00:00:00 2001 From: Ben Drucker Date: Tue, 1 Sep 2026 20:01:05 -0700 Subject: [PATCH 1/4] Cap AGENTS.md injection at a configurable character limit SessionStart injected the full AGENTS.md file with no size cap, averaging 34K characters per fire and dwarfing every other hook's injection. Truncate at a heading or paragraph boundary past the cap (default 8000 chars, override with AGENTS_MD_MAX_CHARS) and note where to read the rest. Apply the same cap in the PostToolUse path. Claude-Session: https://claude.ai/code/session_01LUfdDWmgTCUGUQuwxUEbtr --- README.md | 2 ++ hook.ts | 32 +++++++++++++++++++++++++++++--- package.json | 2 +- test/cap.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 test/cap.ts diff --git a/README.md b/README.md index e9f6190..0fa4a10 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ The plugin uses two hooks to inject `AGENTS.md` content into Claude Code session Each `AGENTS.md` is injected only once per session to avoid context duplication. +Injected content is capped at 8000 characters by default, truncated at a heading or paragraph boundary with a notice pointing at the full file. Set `AGENTS_MD_MAX_CHARS` to change the cap. + ## Usage Create `AGENTS.md` files in your project to provide context to Claude Code: diff --git a/hook.ts b/hook.ts index f61d654..67a981d 100755 --- a/hook.ts +++ b/hook.ts @@ -82,6 +82,28 @@ function findClosestAgentsMd(filePath: string, projectRoot: string): string | nu return null; } +const DEFAULT_MAX_CHARS = 8000; + +function getMaxChars(): number { + const configured = Number(process.env.AGENTS_MD_MAX_CHARS); + return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_CHARS; +} + +// Cuts at the last paragraph or heading break before maxChars so we don't +// truncate mid-sentence, then appends a notice pointing at the full file. +function capContent(content: string, maxChars: number, filePath: string): string { + if (content.length <= maxChars) { + return content; + } + + const head = content.slice(0, maxChars); + const boundary = Math.max(head.lastIndexOf('\n\n'), head.lastIndexOf('\n#')); + const cut = boundary > 0 ? boundary : maxChars; + const truncated = content.slice(0, cut).trimEnd(); + + return `${truncated}\n\n[AGENTS.md truncated at ${maxChars} characters. Read the full file at ${filePath}]`; +} + function outputContext(eventName: string, content: string): void { const output: HookOutput = { hookSpecificOutput: { @@ -111,7 +133,7 @@ function handleSessionStart(input: HookInput): void { const content = fs.readFileSync(agentsPath, 'utf-8'); saveInjectedPath(session_id, agentsPath); - outputContext('SessionStart', content); + outputContext('SessionStart', capContent(content, getMaxChars(), agentsPath)); } function handlePostToolUse(input: HookInput): void { @@ -137,7 +159,7 @@ function handlePostToolUse(input: HookInput): void { const content = fs.readFileSync(agentsPath, 'utf-8'); saveInjectedPath(session_id, agentsPath); - outputContext('PostToolUse', content); + outputContext('PostToolUse', capContent(content, getMaxChars(), agentsPath)); } function main(): void { @@ -163,4 +185,8 @@ function main(): void { } } -main(); +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { capContent, getMaxChars, DEFAULT_MAX_CHARS }; diff --git a/package.json b/package.json index 4c8eea4..155db74 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "test": "node test/run.ts" + "test": "node --test test/run.ts test/cap.ts" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.67", diff --git a/test/cap.ts b/test/cap.ts new file mode 100644 index 0000000..6cc90db --- /dev/null +++ b/test/cap.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import * as assert from 'node:assert'; +import { capContent } from '../hook.ts'; + +const NOTICE = '[AGENTS.md truncated'; + +describe('capContent', () => { + const cases = [ + { + name: 'injects the whole file when under cap', + content: 'short content', + maxChars: 8000, + expectTruncated: false, + }, + { + name: 'truncates at a paragraph boundary when over cap', + content: `${'a'.repeat(50)}\n\n${'b'.repeat(50)}`, + maxChars: 60, + expectTruncated: true, + expectBoundary: 'a'.repeat(50), + }, + { + name: 'truncates at a heading boundary when over cap', + content: `${'a'.repeat(50)}\n# Next section\n${'b'.repeat(50)}`, + maxChars: 60, + expectTruncated: true, + expectBoundary: 'a'.repeat(50), + }, + ]; + + for (const testCase of cases) { + it(testCase.name, () => { + const result = capContent(testCase.content, testCase.maxChars, '/tmp/AGENTS.md'); + + if (!testCase.expectTruncated) { + assert.strictEqual(result, testCase.content); + return; + } + + assert.ok(result.includes(NOTICE), 'expected truncation notice'); + assert.ok(result.includes('/tmp/AGENTS.md'), 'expected notice to point at the file'); + assert.ok(result.startsWith(testCase.expectBoundary as string), 'expected cut at boundary'); + assert.ok(!result.includes('b'.repeat(50)), 'expected content past the boundary to be dropped'); + }); + } +}); From dfb901676c42e9758651a96dfb0c5331d31c3a2f Mon Sep 17 00:00:00 2001 From: Ben Drucker Date: Wed, 2 Sep 2026 21:10:50 -0700 Subject: [PATCH 2/4] Fix cap math to budget for the truncation notice itself capContent cut content at maxChars first, then appended the notice, so the total output could exceed the cap. Reserve the notice's length from the budget before finding the cut boundary. Claude-Session: https://claude.ai/code/session_01LUfdDWmgTCUGUQuwxUEbtr --- hook.ts | 14 +++++++++----- test/cap.ts | 11 ++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/hook.ts b/hook.ts index 67a981d..7b949af 100755 --- a/hook.ts +++ b/hook.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { pathToFileURL } from 'url'; interface HookInput { session_id: string; @@ -86,22 +87,25 @@ const DEFAULT_MAX_CHARS = 8000; function getMaxChars(): number { const configured = Number(process.env.AGENTS_MD_MAX_CHARS); - return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_CHARS; + return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_CHARS; } // Cuts at the last paragraph or heading break before maxChars so we don't // truncate mid-sentence, then appends a notice pointing at the full file. +// The notice's own length comes out of the budget so the result still fits maxChars. function capContent(content: string, maxChars: number, filePath: string): string { if (content.length <= maxChars) { return content; } - const head = content.slice(0, maxChars); + const notice = `\n\n[AGENTS.md truncated at ${maxChars} characters. Read the full file at ${filePath}]`; + const budget = Math.max(maxChars - notice.length, 0); + const head = content.slice(0, budget); const boundary = Math.max(head.lastIndexOf('\n\n'), head.lastIndexOf('\n#')); - const cut = boundary > 0 ? boundary : maxChars; + const cut = boundary > 0 ? boundary : budget; const truncated = content.slice(0, cut).trimEnd(); - return `${truncated}\n\n[AGENTS.md truncated at ${maxChars} characters. Read the full file at ${filePath}]`; + return `${truncated}${notice}`; } function outputContext(eventName: string, content: string): void { @@ -185,7 +189,7 @@ function main(): void { } } -if (import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } diff --git a/test/cap.ts b/test/cap.ts index 6cc90db..c018f98 100644 --- a/test/cap.ts +++ b/test/cap.ts @@ -14,15 +14,15 @@ describe('capContent', () => { }, { name: 'truncates at a paragraph boundary when over cap', - content: `${'a'.repeat(50)}\n\n${'b'.repeat(50)}`, - maxChars: 60, + content: `${'a'.repeat(50)}\n\n${'b'.repeat(300)}`, + maxChars: 200, expectTruncated: true, expectBoundary: 'a'.repeat(50), }, { name: 'truncates at a heading boundary when over cap', - content: `${'a'.repeat(50)}\n# Next section\n${'b'.repeat(50)}`, - maxChars: 60, + content: `${'a'.repeat(50)}\n# Next section\n${'b'.repeat(300)}`, + maxChars: 200, expectTruncated: true, expectBoundary: 'a'.repeat(50), }, @@ -40,7 +40,8 @@ describe('capContent', () => { assert.ok(result.includes(NOTICE), 'expected truncation notice'); assert.ok(result.includes('/tmp/AGENTS.md'), 'expected notice to point at the file'); assert.ok(result.startsWith(testCase.expectBoundary as string), 'expected cut at boundary'); - assert.ok(!result.includes('b'.repeat(50)), 'expected content past the boundary to be dropped'); + assert.ok(!result.includes('bbb'), 'expected content past the boundary to be dropped'); + assert.ok(result.length <= testCase.maxChars, 'expected result to fit within the cap'); }); } }); From 8ddc1bd53b22b61836adc6c8dd29b76bb9f11c27 Mon Sep 17 00:00:00 2001 From: Ben Drucker Date: Wed, 2 Sep 2026 21:15:22 -0700 Subject: [PATCH 3/4] Clamp the truncation notice so total output never exceeds the cap capContent budgeted content length around the notice but never clamped the notice itself, so a long AGENTS.md path or a small AGENTS_MD_MAX_CHARS could make the notice alone exceed maxChars, defeating the cap. Slice the notice to maxChars before computing the content budget, and add a regression test. Claude-Session: https://claude.ai/code/session_01LUfdDWmgTCUGUQuwxUEbtr --- hook.ts | 8 ++++++-- test/cap.ts | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/hook.ts b/hook.ts index 7b949af..55dc785 100755 --- a/hook.ts +++ b/hook.ts @@ -92,13 +92,17 @@ function getMaxChars(): number { // Cuts at the last paragraph or heading break before maxChars so we don't // truncate mid-sentence, then appends a notice pointing at the full file. -// The notice's own length comes out of the budget so the result still fits maxChars. +// The notice's own length comes out of the budget so the result still fits maxChars, +// even when a long filePath or a small maxChars would otherwise make the notice alone overflow it. function capContent(content: string, maxChars: number, filePath: string): string { if (content.length <= maxChars) { return content; } - const notice = `\n\n[AGENTS.md truncated at ${maxChars} characters. Read the full file at ${filePath}]`; + const notice = `\n\n[AGENTS.md truncated at ${maxChars} characters. Read the full file at ${filePath}]`.slice( + 0, + maxChars + ); const budget = Math.max(maxChars - notice.length, 0); const head = content.slice(0, budget); const boundary = Math.max(head.lastIndexOf('\n\n'), head.lastIndexOf('\n#')); diff --git a/test/cap.ts b/test/cap.ts index c018f98..eda9a16 100644 --- a/test/cap.ts +++ b/test/cap.ts @@ -44,4 +44,11 @@ describe('capContent', () => { assert.ok(result.length <= testCase.maxChars, 'expected result to fit within the cap'); }); } + + it('fits within the cap even when the notice alone would overflow it', () => { + const longPath = `/very/long/path/${'segment/'.repeat(20)}AGENTS.md`; + const result = capContent('a'.repeat(500), 40, longPath); + + assert.ok(result.length <= 40, 'expected result to fit within the cap'); + }); }); From a493e9996626b2639fe88ad4516058026f7b8ab2 Mon Sep 17 00:00:00 2001 From: Ben Drucker Date: Wed, 2 Sep 2026 21:16:44 -0700 Subject: [PATCH 4/4] Floor AGENTS_MD_MAX_CHARS to keep tiny configs from wiping the notice github:copilot flagged that a pathologically small AGENTS_MD_MAX_CHARS (e.g. 1) still passed getMaxChars's validation, and after clamping the notice to fit, left neither AGENTS.md content nor a usable pointer to the full file. Floor configured values at 500 characters. Claude-Session: https://claude.ai/code/session_01LUfdDWmgTCUGUQuwxUEbtr --- README.md | 2 +- hook.ts | 6 +++++- test/cap.ts | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0fa4a10..b14bf90 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The plugin uses two hooks to inject `AGENTS.md` content into Claude Code session Each `AGENTS.md` is injected only once per session to avoid context duplication. -Injected content is capped at 8000 characters by default, truncated at a heading or paragraph boundary with a notice pointing at the full file. Set `AGENTS_MD_MAX_CHARS` to change the cap. +Injected content is capped at 8000 characters by default, truncated at a heading or paragraph boundary with a notice pointing at the full file. Set `AGENTS_MD_MAX_CHARS` to change the cap (minimum 500). ## Usage diff --git a/hook.ts b/hook.ts index 55dc785..9cd5420 100755 --- a/hook.ts +++ b/hook.ts @@ -84,10 +84,14 @@ function findClosestAgentsMd(filePath: string, projectRoot: string): string | nu } const DEFAULT_MAX_CHARS = 8000; +const MIN_MAX_CHARS = 500; function getMaxChars(): number { const configured = Number(process.env.AGENTS_MD_MAX_CHARS); - return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_CHARS; + if (!Number.isInteger(configured) || configured <= 0) { + return DEFAULT_MAX_CHARS; + } + return Math.max(configured, MIN_MAX_CHARS); } // Cuts at the last paragraph or heading break before maxChars so we don't diff --git a/test/cap.ts b/test/cap.ts index eda9a16..8cfb4c0 100644 --- a/test/cap.ts +++ b/test/cap.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import * as assert from 'node:assert'; -import { capContent } from '../hook.ts'; +import { capContent, getMaxChars } from '../hook.ts'; const NOTICE = '[AGENTS.md truncated'; @@ -52,3 +52,20 @@ describe('capContent', () => { assert.ok(result.length <= 40, 'expected result to fit within the cap'); }); }); + +describe('getMaxChars', () => { + it('floors a configured value too small to leave room for content and the notice', () => { + const original = process.env.AGENTS_MD_MAX_CHARS; + process.env.AGENTS_MD_MAX_CHARS = '1'; + + try { + assert.ok(getMaxChars() >= 500, 'expected a floor above the pathological low value'); + } finally { + if (original === undefined) { + delete process.env.AGENTS_MD_MAX_CHARS; + } else { + process.env.AGENTS_MD_MAX_CHARS = original; + } + } + }); +});