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
401 changes: 401 additions & 0 deletions src/__tests__/auto-recall.test.ts

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions src/__tests__/hook-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../auto-recall.js')>();
return {
...actual,
autoRecallFromInput: mockAutoRecallFromParsed,
};
});

vi.mock('../contribute-check.js', () => ({
contributeCheck: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/session-id.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
133 changes: 78 additions & 55 deletions src/auto-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────
//
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>): 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<string, unknown> =
rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput)
? rawInput as Record<string, unknown>
: {};

// 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<string, unknown> | 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.
Expand All @@ -436,38 +472,7 @@ export async function readStdin(): Promise<HookInput | null> {

try {
const data = JSON.parse(raw) as Record<string, unknown>;

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<string, unknown> =
rawInput !== null && typeof rawInput === 'object' && !Array.isArray(rawInput)
? rawInput as Record<string, unknown>
: {};

// 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<string, unknown> | 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;
}
Expand All @@ -476,8 +481,8 @@ export async function readStdin(): Promise<HookInput | null> {
// ─── 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
Expand All @@ -489,26 +494,20 @@ export async function readStdin(): Promise<HookInput | null> {
* - 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<void> {
export async function autoRecallFromInput(input: HookInput): Promise<string | null> {
// ─── 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 ────────────────
Expand All @@ -518,16 +517,16 @@ export async function autoRecall(): Promise<void> {
// 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') {
Expand All @@ -540,13 +539,13 @@ export async function autoRecall(): Promise<void> {

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)
Expand All @@ -570,7 +569,7 @@ export async function autoRecall(): Promise<void> {
topScore: 0, hitCount: 0, missCount: 0,
};
writeCache(sessionId, { ...cache, missCount: cache.missCount + 1, updatedAt: new Date().toISOString() });
return;
return null;
}

// Search
Expand Down Expand Up @@ -624,7 +623,7 @@ export async function autoRecall(): Promise<void> {

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
Expand All @@ -643,7 +642,6 @@ export async function autoRecall(): Promise<void> {
additionalContext: context,
},
});
process.stdout.write(hookOutput + '\n');

// Best-effort auto-upvote (non-blocking)
try {
Expand All @@ -654,6 +652,31 @@ export async function autoRecall(): Promise<void> {
} 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<void> {
// 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');
}
}

/**
Expand Down
8 changes: 3 additions & 5 deletions src/contribute-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -307,11 +308,8 @@ async function readStdinAndDeriveSession(): Promise<{ sessionId: string; cwd?: s

try {
const hookData = JSON.parse(raw) as Record<string, unknown>;
// 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 {
Expand Down
Loading
Loading