Просмотр исходного кода

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 <colbymchenry@users.noreply.github.com>
Colby Mchenry 6 часов назад
Родитель
Сommit
4105249843
4 измененных файлов с 61 добавлено и 4 удалено
  1. 2 0
      CHANGELOG.md
  2. 26 1
      __tests__/frontload-hook.test.ts
  3. 6 3
      src/bin/codegraph.ts
  4. 27 0
      src/directory.ts

+ 2 - 0
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 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)
 - **`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.
 - 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.

+ 26 - 1
__tests__/frontload-hook.test.ts

@@ -12,7 +12,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
 import * as fs from 'fs';
 import * as fs from 'fs';
 import * as os from 'os';
 import * as os from 'os';
 import * as path from 'path';
 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`). */
 /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
 function mkIndexed(dir: string): string {
 function mkIndexed(dir: string): string {
@@ -321,3 +321,28 @@ describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)',
     expect(isStructuralPrompt('')).toBe(false);
     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 <codegraph_context> 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);
+  });
+});

+ 6 - 3
src/bin/codegraph.ts

@@ -41,7 +41,7 @@ try {
 import { Command } from 'commander';
 import { Command } from 'commander';
 import * as path from 'path';
 import * as path from 'path';
 import * as fs from 'fs';
 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 { extractProseCandidates } from '../search/identifier-segments';
 import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
 import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
 import { createShimmerProgress } from '../ui/shimmer-progress';
 import { createShimmerProgress } from '../ui/shimmer-progress';
@@ -1412,8 +1412,11 @@ program
             const text = result.content[0]?.text ?? '';
             const text = result.content[0]?.text ?? '';
             if (!result.isError && text.trim()) {
             if (!result.isError && text.trim()) {
               // Cap the injection so a large-repo explore can't flood the prompt.
               // 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.
               // For a front-loaded SUB-project, a follow-up explore needs its path.
               const more = plan.viaSubScan
               const more = plan.viaSubScan
                 ? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more`
                 ? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more`

+ 27 - 0
src/directory.ts

@@ -568,6 +568,33 @@ export function isStructuralPrompt(prompt: string): boolean {
   return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0;
   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
+ * `<codegraph_context>` 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.
  * What the front-load hook should do for a prompt issued from a directory.
  */
  */