Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 41 additions & 3 deletions hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions test/cap.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
});
Loading