diff --git a/README.md b/README.md index e9f6190..b14bf90 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 (minimum 500). + ## Usage Create `AGENTS.md` files in your project to provide context to Claude Code: diff --git a/hook.ts b/hook.ts index f61d654..9cd5420 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; @@ -82,6 +83,39 @@ function findClosestAgentsMd(filePath: string, projectRoot: string): string | nu return null; } +const DEFAULT_MAX_CHARS = 8000; +const MIN_MAX_CHARS = 500; + +function getMaxChars(): number { + const configured = Number(process.env.AGENTS_MD_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 +// 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, +// 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}]`.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#')); + const cut = boundary > 0 ? boundary : budget; + const truncated = content.slice(0, cut).trimEnd(); + + return `${truncated}${notice}`; +} + function outputContext(eventName: string, content: string): void { const output: HookOutput = { hookSpecificOutput: { @@ -111,7 +145,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 +171,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 +197,8 @@ function main(): void { } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + 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..8cfb4c0 --- /dev/null +++ b/test/cap.ts @@ -0,0 +1,71 @@ +import { describe, it } from 'node:test'; +import * as assert from 'node:assert'; +import { capContent, getMaxChars } 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(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(300)}`, + maxChars: 200, + 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('bbb'), 'expected content past the boundary to be dropped'); + 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'); + }); +}); + +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; + } + } + }); +});