codeburn/src/classifier.ts
voidborne-d c16b21ec50 fix(classifier): surface skill name as subCategory for general turns (#203)
Turns whose only assistant tool is `Skill` collapse to category `general`
because `classifyByToolPattern` returns `'general'` and `refineByKeywords`
only operates on `coding`/`exploration`. In environments that lean on Claude
Code skills, the per-activity dashboard column flattens — every `/init`,
`/review`, `/security-review`, `/claude-api`, plus user-defined skills, all
land in `general` with no signal about which workflow ran.

Implements Option A from the issue:

- `ParsedApiCall.skills: string[]` populated in the Anthropic-path parser
  via a new `extractSkillNames` helper that reads `input.skill || input.name`
  from each `Skill` ToolUseBlock (mirrors `detectGhostSkills` extraction at
  optimize.ts:765 so the two stay in sync).
- `ClassifiedTurn.subCategory?: string` set to the first skill name when the
  resolved category is `general` AND any skill identifier was extracted.
  Top-level category stays `general` — existing aggregations, exports, and
  category-keyed code paths unchanged.
- `SessionSummary.skillBreakdown: Record<string, {turns,costUSD,editTurns,
  oneShotTurns}>` populated in the same per-turn loop that builds
  `categoryBreakdown`. Provider sessions (Codex/Cursor/etc.) keep `skills:
  []` — they don't expose the Skill tool surface today.
- Dashboard `ActivityBreakdown` renders top-N skill sub-rows beneath the
  `general` row when present (indented `/skill-name`, dimmed). Other
  categories render exactly as before; if no skills were invoked, the panel
  is byte-identical to current output.

Existing 419 tests still pass. New `tests/classifier.test.ts` adds 8 cases:
single skill via `input.skill`, single via `input.name`, first-wins for
multi-skill turns, aggregation across multiple assistant calls in one turn,
no-name fallback (`subCategory` stays undefined), `Skill+Edit` promoting to
`coding` and dropping subCategory, non-Skill general turns, and a legacy
ParsedApiCall shape with `skills` field absent (forward-compat). Pre-fix
verification by stashing the source change reproduces 4/8 failures with the
exact "expected 'init', received undefined" diff; restoring → 8/8 pass.

Closes #203.

🤖 AI assistance disclosure: assistant-scaffolded by Claude (Opus 4.7);
author of record reviewed every line, ran the full vitest suite locally
(`npm test` → 32 files / 427 tests pass), `npx tsc --noEmit` clean, and
`npm run build` produces a clean ESM bundle.
2026-05-04 06:26:45 +08:00

174 lines
6.5 KiB
TypeScript

import type { ClassifiedTurn, ParsedTurn, TaskCategory } from './types.js'
const TEST_PATTERNS = /\b(test|pytest|vitest|jest|mocha|spec|coverage|npm\s+test|npx\s+vitest|npx\s+jest)\b/i
const GIT_PATTERNS = /\bgit\s+(push|pull|commit|merge|rebase|checkout|branch|stash|log|diff|status|add|reset|cherry-pick|tag)\b/i
const BUILD_PATTERNS = /\b(npm\s+run\s+build|npm\s+publish|pip\s+install|docker|deploy|make\s+build|npm\s+run\s+dev|npm\s+start|pm2|systemctl|brew|cargo\s+build)\b/i
const INSTALL_PATTERNS = /\b(npm\s+install|pip\s+install|brew\s+install|apt\s+install|cargo\s+add)\b/i
const DEBUG_KEYWORDS = /\b(fix|bug|error|broken|failing|crash|issue|debug|traceback|exception|stack\s*trace|not\s+working|wrong|unexpected|status\s+code|404|500|401|403)\b/i
const FEATURE_KEYWORDS = /\b(add|create|implement|new|build|feature|introduce|set\s*up|scaffold|generate|make\s+(?:a|me|the)|write\s+(?:a|me|the))\b/i
const REFACTOR_KEYWORDS = /\b(refactor|clean\s*up|rename|reorganize|simplify|extract|restructure|move|migrate|split)\b/i
const BRAINSTORM_KEYWORDS = /\b(brainstorm|idea|what\s+if|explore|think\s+about|approach|strategy|design|consider|how\s+should|what\s+would|opinion|suggest|recommend)\b/i
const RESEARCH_KEYWORDS = /\b(research|investigate|look\s+into|find\s+out|check|search|analyze|review|understand|explain|how\s+does|what\s+is|show\s+me|list|compare)\b/i
const FILE_PATTERNS = /\.(py|js|ts|tsx|jsx|json|yaml|yml|toml|sql|sh|go|rs|java|rb|php|css|html|md|csv|xml)\b/i
const SCRIPT_PATTERNS = /\b(run\s+\S+\.\w+|execute|scrip?t|curl|api\s+\S+|endpoint|request\s+url|fetch\s+\S+|query|database|db\s+\S+)\b/i
const URL_PATTERN = /https?:\/\/\S+/i
const EDIT_TOOLS = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit', 'cursor:edit'])
const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool'])
export const BASH_TOOLS = new Set(['Bash', 'BashTool', 'PowerShellTool'])
const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TodoWrite'])
const SEARCH_TOOLS = new Set(['WebSearch', 'WebFetch', 'ToolSearch'])
function hasEditTools(tools: string[]): boolean {
return tools.some(t => EDIT_TOOLS.has(t))
}
function hasReadTools(tools: string[]): boolean {
return tools.some(t => READ_TOOLS.has(t))
}
function hasBashTool(tools: string[]): boolean {
return tools.some(t => BASH_TOOLS.has(t))
}
function hasTaskTools(tools: string[]): boolean {
return tools.some(t => TASK_TOOLS.has(t))
}
function hasSearchTools(tools: string[]): boolean {
return tools.some(t => SEARCH_TOOLS.has(t))
}
function hasMcpTools(tools: string[]): boolean {
return tools.some(t => t.startsWith('mcp__'))
}
function hasSkillTool(tools: string[]): boolean {
return tools.some(t => t === 'Skill')
}
function getAllTools(turn: ParsedTurn): string[] {
return turn.assistantCalls.flatMap(c => c.tools)
}
function getAllSkills(turn: ParsedTurn): string[] {
return turn.assistantCalls.flatMap(c => c.skills ?? [])
}
function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null {
const tools = getAllTools(turn)
if (tools.length === 0) return null
if (turn.assistantCalls.some(c => c.hasPlanMode)) return 'planning'
if (turn.assistantCalls.some(c => c.hasAgentSpawn)) return 'delegation'
const hasEdits = hasEditTools(tools)
const hasReads = hasReadTools(tools)
const hasBash = hasBashTool(tools)
const hasTasks = hasTaskTools(tools)
const hasSearch = hasSearchTools(tools)
const hasMcp = hasMcpTools(tools)
const hasSkill = hasSkillTool(tools)
if (hasBash && !hasEdits) {
const userMsg = turn.userMessage
if (TEST_PATTERNS.test(userMsg)) return 'testing'
if (GIT_PATTERNS.test(userMsg)) return 'git'
if (BUILD_PATTERNS.test(userMsg)) return 'build/deploy'
if (INSTALL_PATTERNS.test(userMsg)) return 'build/deploy'
}
if (hasEdits) return 'coding'
if (hasBash && hasReads) return 'exploration'
if (hasBash) return 'coding'
if (hasSearch || hasMcp) return 'exploration'
if (hasReads && !hasEdits) return 'exploration'
if (hasTasks && !hasEdits) return 'planning'
if (hasSkill) return 'general'
return null
}
function refineByKeywords(category: TaskCategory, userMessage: string): TaskCategory {
if (category === 'coding') {
if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging'
if (REFACTOR_KEYWORDS.test(userMessage)) return 'refactoring'
if (FEATURE_KEYWORDS.test(userMessage)) return 'feature'
return 'coding'
}
if (category === 'exploration') {
if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration'
if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging'
return 'exploration'
}
return category
}
function classifyConversation(userMessage: string): TaskCategory {
if (BRAINSTORM_KEYWORDS.test(userMessage)) return 'brainstorming'
if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration'
if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging'
if (FEATURE_KEYWORDS.test(userMessage)) return 'feature'
if (FILE_PATTERNS.test(userMessage)) return 'coding'
if (SCRIPT_PATTERNS.test(userMessage)) return 'coding'
if (URL_PATTERN.test(userMessage)) return 'exploration'
return 'conversation'
}
function countRetries(turn: ParsedTurn): number {
let sawEditBeforeBash = false
let sawBashAfterEdit = false
let retries = 0
for (const call of turn.assistantCalls) {
const hasEdit = call.tools.some(t => EDIT_TOOLS.has(t))
const hasBash = call.tools.some(t => BASH_TOOLS.has(t))
if (hasEdit) {
if (sawBashAfterEdit) retries++
sawEditBeforeBash = true
sawBashAfterEdit = false
}
if (hasBash && sawEditBeforeBash) {
sawBashAfterEdit = true
}
}
return retries
}
function turnHasEdits(turn: ParsedTurn): boolean {
return turn.assistantCalls.some(c => c.tools.some(t => EDIT_TOOLS.has(t)))
}
export function classifyTurn(turn: ParsedTurn): ClassifiedTurn {
const tools = getAllTools(turn)
let category: TaskCategory
if (tools.length === 0) {
category = classifyConversation(turn.userMessage)
} else {
const toolCategory = classifyByToolPattern(turn)
if (toolCategory) {
category = refineByKeywords(toolCategory, turn.userMessage)
} else {
category = classifyConversation(turn.userMessage)
}
}
const result: ClassifiedTurn = { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) }
if (category === 'general') {
const skills = getAllSkills(turn)
if (skills.length > 0) result.subCategory = skills[0]
}
return result
}