diff --git a/src/__tests__/auto-recall.test.ts b/src/__tests__/auto-recall.test.ts index a8004c6..4ce1cd2 100644 --- a/src/__tests__/auto-recall.test.ts +++ b/src/__tests__/auto-recall.test.ts @@ -11,7 +11,10 @@ import { extractWebFetchQuery, shouldSkipQuery, isReadOnlyCommand, + autoRecallFromInput, + parseHookInput, } from '../auto-recall.js'; +import { deriveSessionId } from '../utils/session-id.js'; // ─── Test helpers ────────────────────────────────────────── @@ -19,6 +22,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 +446,359 @@ 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', () => { + 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]'); + }); + + 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 ─────────────── describe('autoRecall TEAMAI_RECALL_DISABLED', () => { diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 01527a6..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', () => ({ - autoRecall: 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), @@ -121,6 +125,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/__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 6378cad..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,15 +412,50 @@ export function shouldSkipQuery(sessionId: string, query: string): boolean { return false; } -// ─── STDIN parsing ─────────────────────────────────────── +// ─── Hook input parsing ────────────────────────────────── -interface HookInput { +export interface HookInput { toolName: string; toolInput: Record; toolOutput: string; 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; } @@ -476,8 +481,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 +494,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 +517,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 +539,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 +569,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 +623,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 +642,6 @@ export async function autoRecall(): Promise { additionalContext: context, }, }); - process.stdout.write(hookOutput + '\n'); // Best-effort auto-upvote (non-blocking) try { @@ -654,6 +652,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/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 5f72ba9..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,34 +167,12 @@ 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, parseHookInput } = await import('./auto-recall.js'); + + const input = parseHookInput(stdin); + if (!input) return null; - return capturedOutput; + return autoRecallFromInput(input); }, }; @@ -206,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}`; +}