From d3e1b5732b3079f70bb71e866a158f3ac3a493ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=80=80=E5=A5=BD?= <2517926599@qq.com> Date: Wed, 24 Jun 2026 14:43:01 +0800 Subject: [PATCH 1/2] refactor(auto-recall): accept parsed hook input directly Remove the stdout-mocking hack in hook-handlers.ts by introducing autoRecallFromInput(input: HookInput). The CLI entry autoRecall() now reads STDIN and delegates to autoRecallFromInput, which returns the hook output JSON (or null). This makes the handler testable without intercepting process.stdout and keeps the existing CLI behavior backward-compatible. - Export HookInput type from auto-recall.ts - Add autoRecallFromInput() containing the core recall logic - Keep autoRecall() as the STDIN-based CLI wrapper - Update autoRecallHandler to parse stdin and call autoRecallFromInput - Add unit tests for autoRecallFromInput - Update hook-handlers mock/test for the new function --- src/__tests__/auto-recall.test.ts | 145 ++++++++++++++++++++++++++++ src/__tests__/hook-handlers.test.ts | 28 +++++- src/auto-recall.ts | 62 +++++++----- src/hook-handlers.ts | 53 +++++----- 4 files changed, 238 insertions(+), 50 deletions(-) diff --git a/src/__tests__/auto-recall.test.ts b/src/__tests__/auto-recall.test.ts index a8004c6..d0bc8e5 100644 --- a/src/__tests__/auto-recall.test.ts +++ b/src/__tests__/auto-recall.test.ts @@ -11,6 +11,7 @@ import { extractWebFetchQuery, shouldSkipQuery, isReadOnlyCommand, + autoRecallFromInput, } from '../auto-recall.js'; // ─── Test helpers ────────────────────────────────────────── @@ -19,6 +20,51 @@ function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-auto-recall-test-')); } +/** Write a minimal search-index.json under a temp HOME directory. */ +function writeSearchIndex(homeDir: string): void { + const indexDir = path.join(homeDir, '.teamai'); + fs.mkdirSync(indexDir, { recursive: true }); + + const index = { + builtAt: new Date().toISOString(), + elapsedMs: 10, + entries: [ + { + filename: 'k8s-oom-fix-2026-03-20-abc123.md', + title: 'K8s Pod OOMKilled 排查与修复', + author: 'testuser', + date: '2026-03-20', + tags: ['k8s', 'oom', 'troubleshooting'], + tokens: [ + 'title:k8s', 'title:pod', 'title:oomkilled', 'title:排查', 'title:修复', + 'tag:k8s', 'tag:oom', 'tag:troubleshooting', + 'oom', 'killed', 'memory', 'limit', 'container', 'restart', + ], + votes: 3, + }, + { + filename: 'module-not-found-fix-2026-03-22-def456.md', + title: 'ModuleNotFoundError 常见解决方案', + author: 'testuser', + date: '2026-03-22', + tags: ['python', 'import', 'modulenotfounderror'], + tokens: [ + 'title:modulenotfounderror', 'title:常见', 'title:解决方案', + 'tag:python', 'tag:import', 'tag:modulenotfounderror', + 'module', 'not', 'found', 'import', 'pip', 'install', + ], + votes: 2, + }, + ], + }; + + fs.writeFileSync( + path.join(indexDir, 'search-index.json'), + JSON.stringify(index), + 'utf-8', + ); +} + // ─── containsError ───────────────────────────────────────── describe('containsError', () => { @@ -398,6 +444,105 @@ describe('shouldSkipQuery', () => { }); }); +// ─── autoRecallFromInput ─────────────────────────────────── + +describe('autoRecallFromInput', () => { + let tmpHome: string; + const originalHome = process.env.HOME; + const originalDisabled = process.env.TEAMAI_RECALL_DISABLED; + + beforeEach(() => { + tmpHome = makeTmpDir(); + process.env.HOME = tmpHome; + delete process.env.TEAMAI_RECALL_DISABLED; + }); + + afterEach(() => { + process.env.HOME = originalHome; + if (originalDisabled === undefined) { + delete process.env.TEAMAI_RECALL_DISABLED; + } else { + process.env.TEAMAI_RECALL_DISABLED = originalDisabled; + } + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('returns null when TEAMAI_RECALL_DISABLED=1', async () => { + process.env.TEAMAI_RECALL_DISABLED = '1'; + const result = await autoRecallFromInput({ + toolName: 'Bash', + toolInput: { command: 'npm test' }, + toolOutput: 'Error: failed', + sessionId: 'disabled-session', + }); + expect(result).toBeNull(); + }); + + it('returns null for unknown tools', async () => { + const result = await autoRecallFromInput({ + toolName: 'Write', + toolInput: { file_path: '/tmp/test.ts' }, + toolOutput: '', + sessionId: 'unknown-tool-session', + }); + expect(result).toBeNull(); + }); + + it('returns null for read-only Bash commands', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Bash', + toolInput: { command: 'cat error.log' }, + toolOutput: 'Error: something failed\nTraceback (most recent call last):', + sessionId: 'readonly-session', + }); + expect(result).toBeNull(); + }); + + it('returns null when Bash output contains no error', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Bash', + toolInput: { command: 'npm test' }, + toolOutput: 'All tests passed', + sessionId: 'no-error-session', + }); + expect(result).toBeNull(); + }); + + it('returns hook output JSON when Bash error matches search index', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Bash', + toolInput: { command: 'kubectl get pods' }, + toolOutput: 'Error: pod my-pod OOMKilled\nContainer killed due to OOM', + sessionId: 'bash-error-session', + }); + + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.hookSpecificOutput).toBeDefined(); + expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('[teamai:auto-recall]'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('OOM'); + }); + + it('returns hook output JSON for Grep tool input', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Grep', + toolInput: { pattern: 'ModuleNotFoundError' }, + toolOutput: '', + sessionId: 'grep-session', + }); + + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('[teamai:auto-recall]'); + }); +}); + // ─── autoRecall: TEAMAI_RECALL_DISABLED flag ─────────────── describe('autoRecall TEAMAI_RECALL_DISABLED', () => { diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 01527a6..ce98be1 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -34,7 +34,7 @@ vi.mock('../usage-tracker.js', () => ({ })); vi.mock('../auto-recall.js', () => ({ - autoRecall: mockAutoRecallFromParsed, + autoRecallFromInput: mockAutoRecallFromParsed, })); vi.mock('../contribute-check.js', () => ({ @@ -121,6 +121,32 @@ describe('hook-handlers registry', () => { } }); + it('auto-recall handler parses stdin and delegates to autoRecallFromInput', async () => { + const registry = buildHandlerRegistry(); + const bashHandler = registry.find( + (r) => r.event === 'post-tool-use' && r.matcher === 'Bash', + )!.handler; + + const stdin = { + tool_name: 'Bash', + tool_input: { command: 'npm test' }, + tool_output: 'Error: module not found', + session_id: 'session-123', + }; + const expectedOutput = JSON.stringify({ hookSpecificOutput: { additionalContext: 'context' } }); + mockAutoRecallFromParsed.mockResolvedValueOnce(expectedOutput); + + const result = await bashHandler.execute(stdin, 'claude'); + + expect(mockAutoRecallFromParsed).toHaveBeenCalledWith({ + toolName: 'Bash', + toolInput: { command: 'npm test' }, + toolOutput: 'Error: module not found', + sessionId: 'session-123', + }); + expect(result).toBe(expectedOutput); + }); + it('prompt-submit has track-slash and dashboard-report', () => { const registry = buildHandlerRegistry(); const handlers = registry diff --git a/src/auto-recall.ts b/src/auto-recall.ts index 6378cad..c2d0a6d 100644 --- a/src/auto-recall.ts +++ b/src/auto-recall.ts @@ -413,7 +413,7 @@ export function shouldSkipQuery(sessionId: string, query: string): boolean { // ─── STDIN parsing ─────────────────────────────────────── -interface HookInput { +export interface HookInput { toolName: string; toolInput: Record; toolOutput: string; @@ -476,8 +476,8 @@ export async function readStdin(): Promise { // ─── Main entry point ──────────────────────────────────── /** - * Handle `teamai auto-recall --stdin`. - * Called by PostToolUse hook on every tool call. + * Core auto-recall logic given a parsed hook input. + * Called by `autoRecall()` (CLI entry) and by `autoRecallHandler` (hook dispatcher). * * Dispatch by tool type: * - Bash: error detection → extract error keywords → search @@ -489,26 +489,20 @@ export async function readStdin(): Promise { * - Known tool, no query: < 5ms * - Known tool + search: < 200ms typical * - * Output: STDOUT JSON with hookSpecificOutput.additionalContext when matching results found. - * Claude Code reads additionalContext and passes it to AI as context. + * Returns: JSON string with hookSpecificOutput.additionalContext when matching + * results found; otherwise null. */ -export async function autoRecall(): Promise { +export async function autoRecallFromInput(input: HookInput): Promise { // ─── Eval harness: disable flag ──────────────────── if (process.env.TEAMAI_RECALL_DISABLED === '1') { - return; - } - - const input = await readStdin(); - if (!input) { - log.debug('auto-recall: no STDIN data'); - return; + return null; } const { toolName, toolInput, toolOutput, sessionId } = input; // Fast path: unknown tools → exit immediately if (!RECALL_TOOLS.has(toolName)) { - return; + return null; } // ─── Extract query based on tool type ──────────────── @@ -518,16 +512,16 @@ export async function autoRecall(): Promise { // Read-only commands output file content, not errors — skip const command = typeof toolInput.command === 'string' ? toolInput.command : ''; if (isReadOnlyCommand(command)) { - return; + return null; } // Skip our own output — prevents recursive false positives when // Bash output contains auto-recall / recall results markers if (toolOutput.includes('[teamai:')) { - return; + return null; } // Bash: only recall on errors if (!containsError(toolOutput)) { - return; + return null; } query = extractQuery(toolOutput); } else if (toolName === 'Grep') { @@ -540,13 +534,13 @@ export async function autoRecall(): Promise { if (!query) { log.debug(`auto-recall: no query extracted from ${toolName}`); - return; + return null; } // Dedup: skip if same query already recalled in this session if (shouldSkipQuery(sessionId, query)) { log.debug(`auto-recall: skipping duplicate/rate-limited query: ${query.slice(0, 50)}`); - return; + return null; } // Lazy load search modules (only when we actually need to search) @@ -570,7 +564,7 @@ export async function autoRecall(): Promise { topScore: 0, hitCount: 0, missCount: 0, }; writeCache(sessionId, { ...cache, missCount: cache.missCount + 1, updatedAt: new Date().toISOString() }); - return; + return null; } // Search @@ -624,7 +618,7 @@ export async function autoRecall(): Promise { if (results.length === 0) { log.debug(`auto-recall: no results for query: ${query.slice(0, 50)}`); - return; + return null; } // Log successful recall with titles for debuggability @@ -643,7 +637,6 @@ export async function autoRecall(): Promise { additionalContext: context, }, }); - process.stdout.write(hookOutput + '\n'); // Best-effort auto-upvote (non-blocking) try { @@ -654,6 +647,31 @@ export async function autoRecall(): Promise { } catch { // Silent: upvote failure should never affect hook output } + + return hookOutput; +} + +/** + * Handle `teamai auto-recall --stdin`. + * Called by PostToolUse hook on every tool call. + * Reads STDIN, runs the core auto-recall logic, and writes any hook output to STDOUT. + */ +export async function autoRecall(): Promise { + // Fast exit for eval harness / disabled mode before touching STDIN + if (process.env.TEAMAI_RECALL_DISABLED === '1') { + return; + } + + const input = await readStdin(); + if (!input) { + log.debug('auto-recall: no STDIN data'); + return; + } + + const output = await autoRecallFromInput(input); + if (output) { + process.stdout.write(output + '\n'); + } } /** diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 5f72ba9..a4bb048 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -166,34 +166,33 @@ const contributeCheckHandler: HookHandler = { const autoRecallHandler: HookHandler = { name: 'auto-recall', async execute(stdin, _tool) { - // Auto-recall has complex internal logic (tool dispatch, error detection, rate limiting) - // For now, delegate to the existing function by temporarily mocking STDIN. - // TODO: Refactor autoRecall to accept parsed data directly. - const { autoRecall } = await import('./auto-recall.js'); - - // The auto-recall function reads STDIN internally. To avoid changing its signature - // in this phase, we capture its STDOUT output via a process.stdout.write intercept. - let capturedOutput: string | null = null; - const originalWrite = process.stdout.write.bind(process.stdout); - process.stdout.write = ((chunk: unknown) => { - if (typeof chunk === 'string') { - capturedOutput = chunk; - } else if (Buffer.isBuffer(chunk)) { - capturedOutput = chunk.toString(); - } - return true; - }) as typeof process.stdout.write; - - try { - // We can't easily pipe stdin to the function, so for this handler - // we'll rely on the environment (process.stdin being piped from Claude Code). - // In the dispatcher, auto-recall will be invoked with the raw data. - await autoRecall(); - } finally { - process.stdout.write = originalWrite; - } + const { autoRecallFromInput } = await import('./auto-recall.js'); + + const toolName = typeof stdin.tool_name === 'string' ? stdin.tool_name : ''; + const rawInput = stdin.tool_input; + const toolInput: Record = + rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? (rawInput as Record) + : {}; + + const toolResponse = stdin.tool_response as Record | undefined; + const toolOutput = typeof stdin.tool_output === 'string' + ? stdin.tool_output + : typeof stdin.tool_result === 'string' + ? stdin.tool_result + : toolResponse + ? [ + typeof toolResponse.stdout === 'string' ? toolResponse.stdout : '', + typeof toolResponse.stderr === 'string' ? toolResponse.stderr : '', + ].filter(Boolean).join('\n') + : ''; + + const sessionId = + (typeof stdin.session_id === 'string' && stdin.session_id) || + process.env.CLAUDE_SESSION_ID || + `pid-${process.ppid ?? process.pid}`; - return capturedOutput; + return autoRecallFromInput({ toolName, toolInput, toolOutput, sessionId }); }, }; From 1fdc50f511778fd2a9d90dfe03d55bec1bbf4a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E8=80=80=E5=A5=BD?= <2517926599@qq.com> Date: Wed, 24 Jun 2026 18:28:09 +0800 Subject: [PATCH 2/2] refactor(auto-recall): extract parseHookInput and shared deriveSessionId helper - Add parseHookInput() to normalize tool_name/tool_input/tool_output/session_id from hook payloads; return null for unidentified tools - Refactor readStdin() and autoRecallHandler to use parseHookInput - Extract deriveSessionId() into src/utils/session-id.ts with optional includeCwd - Adopt deriveSessionId across dashboard-collector, todowrite-hint, and contribute-check - Expand unit tests for parseHookInput, autoRecallFromInput edge cases, and deriveSessionId --- src/__tests__/auto-recall.test.ts | 256 ++++++++++++++++++++++++++++ src/__tests__/hook-handlers.test.ts | 10 +- src/__tests__/session-id.test.ts | 47 +++++ src/auto-recall.ts | 71 ++++---- src/contribute-check.ts | 8 +- src/dashboard-collector.ts | 22 +-- src/hook-handlers.ts | 38 +---- src/todowrite-hint.ts | 7 +- src/utils/session-id.ts | 41 +++++ 9 files changed, 403 insertions(+), 97 deletions(-) create mode 100644 src/__tests__/session-id.test.ts create mode 100644 src/utils/session-id.ts diff --git a/src/__tests__/auto-recall.test.ts b/src/__tests__/auto-recall.test.ts index d0bc8e5..4ce1cd2 100644 --- a/src/__tests__/auto-recall.test.ts +++ b/src/__tests__/auto-recall.test.ts @@ -12,7 +12,9 @@ import { shouldSkipQuery, isReadOnlyCommand, autoRecallFromInput, + parseHookInput, } from '../auto-recall.js'; +import { deriveSessionId } from '../utils/session-id.js'; // ─── Test helpers ────────────────────────────────────────── @@ -444,6 +446,156 @@ describe('shouldSkipQuery', () => { }); }); +// ─── deriveSessionId ─────────────────────────────────────── + +describe('deriveSessionId', () => { + const originalEnv = process.env.CLAUDE_SESSION_ID; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CLAUDE_SESSION_ID; + } else { + process.env.CLAUDE_SESSION_ID = originalEnv; + } + }); + + it('prefers explicit session_id from payload', () => { + expect(deriveSessionId({ session_id: 'explicit-session' })).toBe('explicit-session'); + }); + + it('falls back to CLAUDE_SESSION_ID env var', () => { + delete process.env.CLAUDE_SESSION_ID; + process.env.CLAUDE_SESSION_ID = 'env-session'; + expect(deriveSessionId({})).toBe('env-session'); + }); + + it('falls back to pid when nothing else is available', () => { + delete process.env.CLAUDE_SESSION_ID; + expect(deriveSessionId({})).toMatch(/^pid-/); + }); + + it('ignores non-string session_id values', () => { + delete process.env.CLAUDE_SESSION_ID; + process.env.CLAUDE_SESSION_ID = 'env-session'; + expect(deriveSessionId({ session_id: 123 })).toBe('env-session'); + }); + + it('includes cwd in pid fallback when includeCwd is true', () => { + delete process.env.CLAUDE_SESSION_ID; + const result = deriveSessionId({ cwd: '/tmp/project' }, { includeCwd: true }); + expect(result).toMatch(/^pid-\d+-\/tmp\/project$/); + }); + + it('uses process.cwd() when cwd is missing and includeCwd is true', () => { + delete process.env.CLAUDE_SESSION_ID; + const result = deriveSessionId({}, { includeCwd: true }); + expect(result).toContain(process.cwd()); + }); +}); + +// ─── parseHookInput ──────────────────────────────────────── + +describe('parseHookInput', () => { + it('parses standard PostToolUse payload', () => { + const input = parseHookInput({ + tool_name: 'Bash', + tool_input: { command: 'npm test' }, + tool_response: { stdout: 'ok', stderr: '' }, + session_id: 'session-1', + }); + expect(input).not.toBeNull(); + expect(input!.toolName).toBe('Bash'); + expect(input!.toolInput).toEqual({ command: 'npm test' }); + expect(input!.toolOutput).toBe('ok'); + expect(input!.sessionId).toBe('session-1'); + }); + + it('falls back to tool_output when tool_response is absent', () => { + const input = parseHookInput({ + tool_name: 'Grep', + tool_input: { pattern: 'foo' }, + tool_output: 'matched line', + }); + expect(input).not.toBeNull(); + expect(input!.toolOutput).toBe('matched line'); + }); + + it('falls back to tool_result alias', () => { + const input = parseHookInput({ + tool_name: 'Write', + tool_input: { file_path: '/tmp/x' }, + tool_result: 'done', + }); + expect(input).not.toBeNull(); + expect(input!.toolOutput).toBe('done'); + }); + + it('joins stdout and stderr from tool_response', () => { + const input = parseHookInput({ + tool_name: 'Bash', + tool_response: { stdout: 'out', stderr: 'err' }, + }); + expect(input).not.toBeNull(); + expect(input!.toolOutput).toBe('out\nerr'); + }); + + it('normalizes missing tool_input to empty object', () => { + const input = parseHookInput({ + tool_name: 'Bash', + }); + expect(input).not.toBeNull(); + expect(input!.toolInput).toEqual({}); + }); + + it('normalizes null tool_input to empty object', () => { + const input = parseHookInput({ + tool_name: 'Bash', + tool_input: null, + }); + expect(input).not.toBeNull(); + expect(input!.toolInput).toEqual({}); + }); + + it('derives session ID from environment when missing', () => { + const input = parseHookInput({ + tool_name: 'Bash', + }); + expect(input).not.toBeNull(); + expect(input!.sessionId).toMatch(/^pid-/); + }); + + it('returns null when tool_name is missing', () => { + expect(parseHookInput({ tool_input: { command: 'ls' } })).toBeNull(); + }); + + it('returns null when tool_name is not a string', () => { + expect(parseHookInput({ tool_name: 123, tool_input: {} })).toBeNull(); + }); + + it('returns null when tool_name is empty', () => { + expect(parseHookInput({ tool_name: '', tool_input: {} })).toBeNull(); + }); + + it('prefers tool_output over tool_result when both are present', () => { + const input = parseHookInput({ + tool_name: 'Bash', + tool_output: 'from tool_output', + tool_result: 'from tool_result', + }); + expect(input).not.toBeNull(); + expect(input!.toolOutput).toBe('from tool_output'); + }); + + it('keeps stderr when stdout is absent in tool_response', () => { + const input = parseHookInput({ + tool_name: 'Bash', + tool_response: { stderr: 'error only' }, + }); + expect(input).not.toBeNull(); + expect(input!.toolOutput).toBe('error only'); + }); +}); + // ─── autoRecallFromInput ─────────────────────────────────── describe('autoRecallFromInput', () => { @@ -541,6 +693,110 @@ describe('autoRecallFromInput', () => { expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse'); expect(parsed.hookSpecificOutput.additionalContext).toContain('[teamai:auto-recall]'); }); + + it('returns hook output JSON for WebSearch tool input', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'WebSearch', + toolInput: { query: 'K8s pod OOMKilled troubleshooting' }, + toolOutput: '', + sessionId: 'websearch-session', + }); + + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('[teamai:auto-recall]'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('OOM'); + }); + + it('returns hook output JSON for WebFetch tool input using prompt', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'WebFetch', + toolInput: { url: 'https://example.com', prompt: 'ModuleNotFoundError fix' }, + toolOutput: '', + sessionId: 'webfetch-session', + }); + + expect(result).not.toBeNull(); + const parsed = JSON.parse(result!); + expect(parsed.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(parsed.hookSpecificOutput.additionalContext).toContain('[teamai:auto-recall]'); + }); + + it('returns null when the extracted query is too short', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Grep', + toolInput: { pattern: 'ab' }, + toolOutput: '', + sessionId: 'short-query-session', + }); + expect(result).toBeNull(); + }); + + it('returns null when no search results match', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Grep', + toolInput: { pattern: 'totally-unrelated-topic' }, + toolOutput: '', + sessionId: 'no-match-session', + }); + expect(result).toBeNull(); + }); + + it('skips duplicate queries within the same session', async () => { + writeSearchIndex(tmpHome); + const input = { + toolName: 'Grep' as const, + toolInput: { pattern: 'ModuleNotFoundError' }, + toolOutput: '', + sessionId: 'dedup-session', + }; + + const first = await autoRecallFromInput(input); + expect(first).not.toBeNull(); + + const second = await autoRecallFromInput(input); + expect(second).toBeNull(); + }); + + it('skips Bash output that already contains auto-recall markers', async () => { + writeSearchIndex(tmpHome); + const result = await autoRecallFromInput({ + toolName: 'Bash', + toolInput: { command: 'kubectl get pods' }, + toolOutput: '[teamai:auto-recall] previous result\nError: pod OOMKilled', + sessionId: 'marker-session', + }); + expect(result).toBeNull(); + }); + + it('respects the per-session recall rate limit', async () => { + writeSearchIndex(tmpHome); + const sessionId = 'rate-limit-session'; + for (let i = 0; i < 10; i++) { + const result = await autoRecallFromInput({ + toolName: 'Grep', + toolInput: { pattern: `unique-${i}` }, + toolOutput: '', + sessionId, + }); + // Some queries may not match the index, but they still count toward the limit + expect(result === null || result !== null).toBe(true); + } + + // 11th recall should be rate-limited regardless of query + const overLimit = await autoRecallFromInput({ + toolName: 'Grep', + toolInput: { pattern: 'ModuleNotFoundError' }, + toolOutput: '', + sessionId, + }); + expect(overLimit).toBeNull(); + }); }); // ─── autoRecall: TEAMAI_RECALL_DISABLED flag ─────────────── diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index ce98be1..7961e07 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -33,9 +33,13 @@ vi.mock('../usage-tracker.js', () => ({ updateKnownSkills: vi.fn().mockResolvedValue(undefined), })); -vi.mock('../auto-recall.js', () => ({ - autoRecallFromInput: mockAutoRecallFromParsed, -})); +vi.mock('../auto-recall.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + autoRecallFromInput: mockAutoRecallFromParsed, + }; +}); vi.mock('../contribute-check.js', () => ({ contributeCheck: vi.fn().mockResolvedValue(undefined), diff --git a/src/__tests__/session-id.test.ts b/src/__tests__/session-id.test.ts new file mode 100644 index 0000000..f00ff3b --- /dev/null +++ b/src/__tests__/session-id.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { deriveSessionId } from '../utils/session-id.js'; + +describe('deriveSessionId', () => { + const originalEnv = process.env.CLAUDE_SESSION_ID; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.CLAUDE_SESSION_ID; + } else { + process.env.CLAUDE_SESSION_ID = originalEnv; + } + }); + + it('prefers explicit session_id from payload', () => { + expect(deriveSessionId({ session_id: 'explicit-session' })).toBe('explicit-session'); + }); + + it('falls back to CLAUDE_SESSION_ID env var', () => { + delete process.env.CLAUDE_SESSION_ID; + process.env.CLAUDE_SESSION_ID = 'env-session'; + expect(deriveSessionId({})).toBe('env-session'); + }); + + it('falls back to pid when nothing else is available', () => { + delete process.env.CLAUDE_SESSION_ID; + expect(deriveSessionId({})).toMatch(/^pid-/); + }); + + it('ignores non-string session_id values', () => { + delete process.env.CLAUDE_SESSION_ID; + process.env.CLAUDE_SESSION_ID = 'env-session'; + expect(deriveSessionId({ session_id: 123 })).toBe('env-session'); + }); + + it('includes cwd in pid fallback when includeCwd is true', () => { + delete process.env.CLAUDE_SESSION_ID; + const result = deriveSessionId({ cwd: '/tmp/project' }, { includeCwd: true }); + expect(result).toMatch(/^pid-\d+-\/tmp\/project$/); + }); + + it('uses process.cwd() when cwd is missing and includeCwd is true', () => { + delete process.env.CLAUDE_SESSION_ID; + const result = deriveSessionId({}, { includeCwd: true }); + expect(result).toContain(process.cwd()); + }); +}); diff --git a/src/auto-recall.ts b/src/auto-recall.ts index c2d0a6d..d7a8a5f 100644 --- a/src/auto-recall.ts +++ b/src/auto-recall.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { log } from './utils/logger.js'; import { getTeamaiHome } from './types.js'; +import { deriveSessionId } from './utils/session-id.js'; // ─── Auto-recall data flow ────────────────────────────── // @@ -411,7 +412,7 @@ export function shouldSkipQuery(sessionId: string, query: string): boolean { return false; } -// ─── STDIN parsing ─────────────────────────────────────── +// ─── Hook input parsing ────────────────────────────────── export interface HookInput { toolName: string; @@ -420,6 +421,41 @@ export interface HookInput { sessionId: string; } +/** + * Parse a raw hook payload into a structured HookInput. + * Normalizes multiple STDIN conventions (tool_output, tool_result, + * tool_response.stdout/stderr) and derives a session ID. + * Returns null when the payload does not identify a tool. + */ +export function parseHookInput(data: Record): HookInput | null { + const toolName = typeof data.tool_name === 'string' ? data.tool_name : ''; + if (!toolName) return null; + + // Parse tool_input (the parameters passed to the tool) + const rawInput = data.tool_input; + const toolInput: Record = + rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? rawInput as Record + : {}; + + // Claude Code PostToolUse STDIN format: + // { tool_name, tool_input, tool_response: { stdout, stderr } } + // Other formats may use tool_output or tool_result directly. + const toolResponse = data.tool_response as Record | undefined; + const toolOutput = typeof data.tool_output === 'string' + ? data.tool_output + : typeof data.tool_result === 'string' + ? data.tool_result + : toolResponse + ? [ + typeof toolResponse.stdout === 'string' ? toolResponse.stdout : '', + typeof toolResponse.stderr === 'string' ? toolResponse.stderr : '', + ].filter(Boolean).join('\n') + : ''; + + return { toolName, toolInput, toolOutput, sessionId: deriveSessionId(data) }; +} + /** * Read and parse STDIN hook JSON. * Returns null if STDIN is a TTY or JSON is invalid. @@ -436,38 +472,7 @@ export async function readStdin(): Promise { try { const data = JSON.parse(raw) as Record; - - const toolName = typeof data.tool_name === 'string' ? data.tool_name : ''; - - // Parse tool_input (the parameters passed to the tool) - const rawInput = data.tool_input; - const toolInput: Record = - rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput) - ? rawInput as Record - : {}; - - // Claude Code PostToolUse STDIN format: - // { tool_name, tool_input, tool_response: { stdout, stderr } } - // Other formats may use tool_output or tool_result directly. - const toolResponse = data.tool_response as Record | undefined; - const toolOutput = typeof data.tool_output === 'string' - ? data.tool_output - : typeof data.tool_result === 'string' - ? data.tool_result - : toolResponse - ? [ - typeof toolResponse.stdout === 'string' ? toolResponse.stdout : '', - typeof toolResponse.stderr === 'string' ? toolResponse.stderr : '', - ].filter(Boolean).join('\n') - : ''; - - // Derive session ID (same logic as contribute-check) - const sessionId = - (typeof data.session_id === 'string' && data.session_id) || - process.env.CLAUDE_SESSION_ID || - `pid-${process.ppid ?? process.pid}`; - - return { toolName, toolInput, toolOutput, sessionId }; + return parseHookInput(data); } catch { return null; } diff --git a/src/contribute-check.ts b/src/contribute-check.ts index b20bd1a..20b1fb2 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -5,6 +5,7 @@ import { log } from './utils/logger.js'; import { readJson, writeJson, ensureDir } from './utils/fs.js'; import { readEvents } from './dashboard-collector.js'; import { readRecallQuality } from './auto-recall.js'; +import { deriveSessionId } from './utils/session-id.js'; import type { ContributeState, DashboardEvent } from './types.js'; import { CONTRIBUTE_SMART_THRESHOLD, @@ -307,11 +308,8 @@ async function readStdinAndDeriveSession(): Promise<{ sessionId: string; cwd?: s try { const hookData = JSON.parse(raw) as Record; - // Derive session ID: session_id field > env > PID fallback - const sessionId = - (typeof hookData.session_id === 'string' && hookData.session_id) || - process.env.CLAUDE_SESSION_ID || - `pid-${process.ppid ?? process.pid}-${typeof hookData.cwd === 'string' ? hookData.cwd : process.cwd()}`; + // Derive session ID: session_id field > env > PID+cwd fallback + const sessionId = deriveSessionId(hookData, { includeCwd: true }); const cwd = typeof hookData.cwd === 'string' ? hookData.cwd : undefined; return { sessionId, cwd }; } catch { diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index c389cf6..200cba4 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { log } from './utils/logger.js'; +import { deriveSessionId } from './utils/session-id.js'; import { ensureDir } from './utils/fs.js'; import { resolveMonitorPid } from './pid-monitor.js'; import { @@ -100,25 +101,6 @@ export async function readLastAssistantOutput(transcriptPath: string): Promise CLAUDE_SESSION_ID env > PID+cwd composite. - */ -function deriveSessionId(hookData: Record): string { - // 1. Explicit session_id from hook STDIN - if (typeof hookData.session_id === 'string' && hookData.session_id) { - return hookData.session_id; - } - // 2. Environment variable (Claude Code sets this) - if (process.env.CLAUDE_SESSION_ID) { - return process.env.CLAUDE_SESSION_ID; - } - // 3. Fallback: PID + cwd composite - const cwd = typeof hookData.cwd === 'string' ? hookData.cwd : process.cwd(); - const ppid = process.ppid ?? process.pid; - return `pid-${ppid}-${cwd}`; -} - /** * Map hook event names to dashboard event types. * Supports Claude Code (PascalCase), Cursor and CodeBuddy (camelCase) formats. @@ -172,7 +154,7 @@ export async function parseHookEvent( return null; } - const sessionId = deriveSessionId(hookData); + const sessionId = deriveSessionId(hookData, { includeCwd: true }); const cwd = typeof hookData.cwd === 'string' ? hookData.cwd : undefined; const event: DashboardEvent = { diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index a4bb048..7b49743 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -10,6 +10,7 @@ */ import type { HookHandler } from './hook-dispatch.js'; +import { deriveSessionId } from './utils/session-id.js'; // ─── Public types ─────────────────────────────────────── @@ -166,33 +167,12 @@ const contributeCheckHandler: HookHandler = { const autoRecallHandler: HookHandler = { name: 'auto-recall', async execute(stdin, _tool) { - const { autoRecallFromInput } = await import('./auto-recall.js'); + const { autoRecallFromInput, parseHookInput } = await import('./auto-recall.js'); - const toolName = typeof stdin.tool_name === 'string' ? stdin.tool_name : ''; - const rawInput = stdin.tool_input; - const toolInput: Record = - rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput) - ? (rawInput as Record) - : {}; - - const toolResponse = stdin.tool_response as Record | undefined; - const toolOutput = typeof stdin.tool_output === 'string' - ? stdin.tool_output - : typeof stdin.tool_result === 'string' - ? stdin.tool_result - : toolResponse - ? [ - typeof toolResponse.stdout === 'string' ? toolResponse.stdout : '', - typeof toolResponse.stderr === 'string' ? toolResponse.stderr : '', - ].filter(Boolean).join('\n') - : ''; - - const sessionId = - (typeof stdin.session_id === 'string' && stdin.session_id) || - process.env.CLAUDE_SESSION_ID || - `pid-${process.ppid ?? process.pid}`; - - return autoRecallFromInput({ toolName, toolInput, toolOutput, sessionId }); + const input = parseHookInput(stdin); + if (!input) return null; + + return autoRecallFromInput(input); }, }; @@ -205,12 +185,8 @@ const todowriteHintHandler: HookHandler = { if (toolName !== 'TodoWrite') return null; const { shouldSkipTodoWriteHint, buildHintMessage } = await import('./todowrite-hint.js'); - const sessionId = - (typeof stdin.session_id === 'string' && stdin.session_id) || - process.env.CLAUDE_SESSION_ID || - `pid-${process.ppid ?? process.pid}`; - if (shouldSkipTodoWriteHint(sessionId)) return null; + if (shouldSkipTodoWriteHint(deriveSessionId(stdin))) return null; return JSON.stringify({ hookSpecificOutput: { diff --git a/src/todowrite-hint.ts b/src/todowrite-hint.ts index d837ced..b4f08f8 100644 --- a/src/todowrite-hint.ts +++ b/src/todowrite-hint.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { log } from './utils/logger.js'; +import { deriveSessionId } from './utils/session-id.js'; // ─── TodoWrite hint data flow ─────────────────────────── // @@ -98,11 +99,7 @@ export async function readStdin(): Promise { try { const data = JSON.parse(raw) as Record; const toolName = typeof data.tool_name === 'string' ? data.tool_name : ''; - const sessionId = - (typeof data.session_id === 'string' && data.session_id) || - process.env.CLAUDE_SESSION_ID || - `pid-${process.ppid ?? process.pid}`; - return { toolName, sessionId }; + return { toolName, sessionId: deriveSessionId(data) }; } catch { return null; } diff --git a/src/utils/session-id.ts b/src/utils/session-id.ts new file mode 100644 index 0000000..fdb12dc --- /dev/null +++ b/src/utils/session-id.ts @@ -0,0 +1,41 @@ +/** + * Shared session ID derivation for hook handlers. + * + * Different hooks need a stable identifier for the current AI coding session. + * This helper centralizes the priority order so callers don't duplicate the + * fallback logic. + */ + +export interface DeriveSessionIdOptions { + /** When true, include the working directory in the PID fallback. */ + includeCwd?: boolean; +} + +/** + * Derive a stable session ID from a hook payload. + * + * Priority: + * 1. Explicit `session_id` field from the hook payload + * 2. `CLAUDE_SESSION_ID` environment variable + * 3. `pid-${process.ppid ?? process.pid}` (or `pid-${ppid}-${cwd}` when includeCwd is true) + */ +export function deriveSessionId( + data: Record, + options: DeriveSessionIdOptions = {}, +): string { + if (typeof data.session_id === 'string' && data.session_id) { + return data.session_id; + } + + if (process.env.CLAUDE_SESSION_ID) { + return process.env.CLAUDE_SESSION_ID; + } + + const ppid = process.ppid ?? process.pid; + if (options.includeCwd) { + const cwd = typeof data.cwd === 'string' ? data.cwd : process.cwd(); + return `pid-${ppid}-${cwd}`; + } + + return `pid-${ppid}`; +}