From 41052498436040d639fe89c77619fdb64ec3f925 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 09:15:13 -0500 Subject: [PATCH] fix(prompt-hook): cap injection under Claude Code's 10k inline limit (#1694) (#1769) Claude Code persists hook stdout over 10,000 characters to a file and shows the model a 2 KB preview. The prompt-hook MAX of 16,000 always hit that path once explore filled the budget. Cap at 9,000 (exported + unit-tested) so the payload lands inline, with headroom for the wrapper and projectPath nudges. Lands the approach from #1695 with a testable helper. Co-authored-by: Colby McHenry --- CHANGELOG.md | 2 ++ __tests__/frontload-hook.test.ts | 27 ++++++++++++++++++++++++++- src/bin/codegraph.ts | 9 ++++++--- src/directory.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a3447..3e809bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root. +- **The Claude Code prompt hook's context now arrives inline.** The hook capped its injection at 16,000 characters, but Claude Code shows hook output inline only up to 10,000 and otherwise persists it to a file with a 2 KB preview, so on any repo where explore filled the cap the model saw a file path and the first 2 KB. The cap is now 9,000 characters, under the limit with room for the wrapper. (#1694) + - **`codegraph_explore` is loaded from the first prompt in Claude Code.** Claude Code defers every MCP tool behind a tool-search step, so a fresh session saw only the tool's name until the model searched for it, and the server's "call `codegraph_explore` instead of Read" had nothing loaded to act on. The tool now carries `anthropic/alwaysLoad` in its `_meta`, which exempts it on existing installs, and `codegraph install` writes `alwaysLoad: true` on the Claude Code server entry (re-run it to add the key). Copilot CLI's tool search holds MCP tools back the same way once ~30 tools are connected, so its entry now carries `deferTools: "never"`. (#1696) - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it. diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index c8698cf..fc57bc2 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens } from '../src/directory'; +import { planFrontload, findIndexedSubprojectRoots, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */ function mkIndexed(dir: string): string { @@ -321,3 +321,28 @@ describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', expect(isStructuralPrompt('')).toBe(false); }); }); + +describe('prompt-hook injection cap (#1694)', () => { + it('PROMPT_HOOK_INJECTION_MAX stays under Claude Code\'s 10k inline hook-output limit', () => { + expect(PROMPT_HOOK_INJECTION_MAX).toBe(9000); + expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT).toBe(10_000); + expect(PROMPT_HOOK_INJECTION_MAX).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + // Leave headroom for the wrapper + projectPath nudge lines. + expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT - PROMPT_HOOK_INJECTION_MAX).toBeGreaterThanOrEqual(500); + }); + + it('capPromptHookInjection leaves short payloads intact', () => { + expect(capPromptHookInjection('hello')).toBe('hello'); + expect(capPromptHookInjection('x'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe('x'.repeat(PROMPT_HOOK_INJECTION_MAX)); + }); + + it('capPromptHookInjection truncates oversize payloads with the explore notice', () => { + const over = 'a'.repeat(PROMPT_HOOK_INJECTION_MAX + 500); + const out = capPromptHookInjection(over); + expect(out.length).toBeLessThan(over.length); + expect(out.startsWith('a'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe(true); + expect(out).toContain('…(truncated; call codegraph_explore for the rest)'); + // Capped body alone must still fit under the host inline limit. + expect(out.length).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index a4dccb9..5da6598 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -41,7 +41,7 @@ try { import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; -import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens } from '../directory'; +import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection } from '../directory'; import { extractProseCandidates } from '../search/identifier-segments'; import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; import { createShimmerProgress } from '../ui/shimmer-progress'; @@ -1412,8 +1412,11 @@ program const text = result.content[0]?.text ?? ''; if (!result.isError && text.trim()) { // Cap the injection so a large-repo explore can't flood the prompt. - const MAX = 16000; - const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text; + // Claude Code shows hook stdout inline only up to 10,000 characters; + // above that it persists the output to a file and the model sees a + // 2 KB preview (#1694). PROMPT_HOOK_INJECTION_MAX (9,000) leaves + // room for the wrapper and the projectPath nudge lines below. + const body = capPromptHookInjection(text); // For a front-loaded SUB-project, a follow-up explore needs its path. const more = plan.viaSubScan ? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more` diff --git a/src/directory.ts b/src/directory.ts index ff5c3b8..b419495 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -568,6 +568,33 @@ export function isStructuralPrompt(prompt: string): boolean { return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0; } +/** + * Claude Code persists `UserPromptSubmit` hook stdout above this many + * characters to a file and shows the model a ~2 KB preview instead (#1694). + * Measured on Claude Code 2.1.261; documented in the hooks reference as a + * 10,000-character cap on hook output strings. + */ +export const CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT = 10_000; + +/** + * Max characters of explore text injected by `codegraph prompt-hook` before + * truncation. Must stay under {@link CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT} so + * the host delivers the payload inline. 9,000 leaves ~1k for the + * `` wrapper and the `projectPath` nudge lines appended + * after the cap is applied. + */ +export const PROMPT_HOOK_INJECTION_MAX = 9_000; + +/** + * Cap explore text for the prompt-hook injection, preserving the existing + * "call codegraph_explore for the rest" notice when truncated. + */ +export function capPromptHookInjection(text: string, max = PROMPT_HOOK_INJECTION_MAX): string { + return text.length > max + ? `${text.slice(0, max)}\n…(truncated; call codegraph_explore for the rest)` + : text; +} + /** * What the front-load hook should do for a prompt issued from a directory. */